注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

探索 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
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

比浏览器 F12 更好用的免费调试抓包工具 Fiddler 介绍

身为一名前端搬砖工,长久以来有两个问题困扰着我,一个是做后台项目接口返回的数据都为空,不方便做更进一步的对数据的查改及测试;另一个是做移动端的项目,比如 uniapp,每次遇到接口问题都只能 console 在 HBuilder 进行调试,苦不堪言,后来发现我...
继续阅读 »

身为一名前端搬砖工,长久以来有两个问题困扰着我,一个是做后台项目接口返回的数据都为空,不方便做更进一步的对数据的查改及测试;另一个是做移动端的项目,比如 uniapp,每次遇到接口问题都只能 console 在 HBuilder 进行调试,苦不堪言,后来发现我司 TE 同学用 Fiddler 进行抓包测试,一问这软件还是免费的,遂进行了一番学习了解,发现可以直接解决刚刚提到的这两个问题,所以在这里做个分享。


简介



  • Fiddler 是位于客户端和服务器端的 HTTP 代理

  • 目前最常用的 HTTP 抓包工具之一

  • 功能非常强大,是 web 调试的利器

    • 监控浏览器所有的 HTTP/HTTPS 流量

    • 查看、分析请求内容细节

    • 伪造客户端请求和服务器响应

    • 解密 HTTPS 的 web 会话

    • 全局、局部断点功能

    • 第三方插件



  • 使用场景

    • 接口的测试与调试

    • 线上环境调试

    • web 性能分析




下载


直接去官网下载 Fiddler Classic 即可:


image.png


原理


学习一件新事物,最好是知其然亦知其所以然,这样遇到问题心里有底,才不容易慌,下面就介绍下 Fiddler 抓包的原理。


Fiddler 是位于客户端和服务器端之间的 HTTP 代理。一旦启动 Fiddler,其会自动将代理服务器设置成本机,默认端口为 8888,并设置成系统代理(Act as system proxy on startup)。可以在 Fiddler 通过 'Tools -> Options -> Connections' 查看, 图示如下:


image.png

在 Fiddler 运行的情况下,以 Chrome 浏览器为例,可以在其 '设置 -> 高级 -> 系统 -> 打开您计算机的代理设置 -> 连接 -> 局域网(LAN)设置' 里看到,'代理服务器' 下的 '为 LAN 使用代理服务器' 选项被勾选了(如果没有运行 Fiddler,默认情况下是不会被勾选的),如下图:


image (1).png

点开 '高级',会发现 '要使用的代理服务器地址' 就是本机 ip,端口为 8888。如下图:


image (2).png

也就是说浏览器的 HTTP 请求/响应都被代理到了系统的 8888 端口,被 Fiddler 拦截了。


界面介绍


下面开始对整个 Fiddler 的界面进行一个庖丁解牛


工具栏


image.png
主要介绍上图中几个标了号的我认为比较常用的功能:



  1. Replay:重放选中的那条请求,同时按下 shift + R 键,可以输入重复发送请求的次数(这些请求是串行发送的)。可以用来做重放攻击的测试。

  2. 删除会话(sessions)

  3. 继续打了断点的请求:打断点后请求会被拦截在 Fiddler,点击这个 Go 继续发送。打断点的方式是点击界面底部的空格,具体位置如下图所示:


image (1).png



  1. 这个类似瞄准器的工具时用于选择抓取请求的应用:按住不放将鼠标拖放到目标应用即可

  2. 可用于查找某条请求,比如你知道请求参数里的某个字段,可以直接输入进行查找

  3. 编码解码工具,可以进行多种编码的转换,是个人觉得挺好用的一个工具,能够编码的格式包括但不限于 base64、md5 和 URLEncode 等

  4. 可以查看一些诸如本机 ip(包括 IPv4,IPv6) 等信息,就用不着去 cmd 里 输入ipconfig 查看了,如下图:


image (2).png


会话列表(Session List)


位于软件界面的左半部的就是会话列表了,抓取到的每条 http 请求都列在这,每一条被称为一个 session,如下图所示:

image (3).png


每条会话默认包含的信息



  • 请求的状态码(result)

  • 协议(protocol)

  • 主机名(host)

  • URL

  • 请求大小(body,以字节为单位)

  • 缓存信息(caching)

  • 响应类型(content-type)

  • 发出请求的 Windows 进程及进程 ID(process)


自定义列


除了以上这些,我们还可以添加自定义列,比如想添加一列请求方法信息:



  1. 点击菜单栏 -> Rules -> Customize Rules 调出 Fiddler ScriptEditor 窗口

  2. 按下 ctrl + f 输入 static function Main() 进行查找

  3. 然后在找到的函数 Main 里添加:


FiddlerObject.UI.lvSessions.AddBoundColumn("Method",60,getHTTPMethod );
static function getHTTPMethod(oS: Session){
if (null != oS.oRequest) return oS.oRequest.headers.HTTPMethod;
else return String.Empty;
}

图示如下:


image (4).png
4. 按下 ctrl + s 保存。然后就可以在会话列表里看到多出了名为 Method 的一列,内容为请求方法。


排序和移动



  1. 点击每一列的列表头,可以反向排序

  2. 按住列表头不放进行拖动,可以改变列表位置


QuickExec 与状态栏


位于软件界面底部的那条黑色的是 QuickExec,可用于快速执行输入的一些命令,具体命令可输入 help 跳转到官方的帮助页面查看。图示如下:


image (5).png

在 QuickExec 下面的就是状态栏,



  1. Capturing:代表目前 Fiddler 的代理功能是开启的,也就是是否进行请求响应的拦截,如果想关闭代理,只需要点击一下 Capturing 图标即可

  2. All Processes:选择抓取的进程,可以只选浏览器进程或是非浏览器进程等

  3. 断点:按一次是请求前断点,也就是请求从浏览器发出到 Fiddler 这停住;再按一次是响应后的断点,也就是响应从服务器发出,到Fiddler 这停住;再按一次就是不打断点

  4. 当前选中的会话 / 总会话数

  5. 附加信息


辅助标签 + 工具


位于软件界面右边的这一大块面板,即为辅助标签 + 工具,如下图所示,它拥有 10 个小标签,我们先从 Statistics 讲起,btw,这单词的发音是 [stəˈtɪstɪks],第 3 个字母 a 发 'ə' 的音,而不是 'æ'~


image (6).png


Statistics(统计)


这个 tab 里都是些 http 请求的性能数据分析,如 DNS Lookup(DNS 解析时间)、 TCP/IP Connect(TCP/IP 连接时间)等。


Inspectors(检查器)


image.png

以多种不同的方式查看请求的请求报文和响应报文,比如可以只看头部信息(Headers)、或者是查看请求的原始信息(Raw),再比如请求的参数是 x-www-form-urlencoded 的话,就能在 WebForms 里查看...


AutoResponder(自动响应器)


image (1).png

这是一个我认为比较有用的功能了,它可以篡改从服务器返回的数据,达到欺骗浏览器的目的。


实战案例


我在做一个后台项目的时候,因为前台还没弄好,数据库都是没有数据的,在获取列表时,请求得到的都是如下图所示的空数组:


image.png

那么在页面上显示的也就是“暂无数据”,这就影响了之后一些删改数据的接口的对接。


image (2).png

此时,我们就可以通过 AutoResponder ,按照接口文档的返回实例,对返回的数据进行编辑,具体步骤如下:



  1. 勾选上 Enable rules(激活自动响应器) 和 Unmatched requests passthrough(放行所有不匹配的请求)


image (3).png

2. 在左侧会话列表里选中要修改响应的那条请求,按住鼠标直接拖动到 AutoResponder 的面板里,如下图红框所示:


image (4).png

3. 选中上图红框里的请求单机鼠标右键,选择 Edit Response...


image (5).png

4. 进入编辑面板选择 Raw 标签就可以直接进行编辑了,这里我按照接口文档的返回示例,给 items 数组添加了数据,如下图所示:


image (6).png

这样,浏览器接收到数据,页面就如下图所示有了内容,方便进行之后的操作


image (7).png


Composer(设计者)


说完了对响应的篡改,现在介绍的 composer 就是用于对请求的篡改。这个单词的翻译是作曲家,按照我们的想法去修改一个请求,宛如作曲家谱一首乐曲一般。


image.png

用法与 AutoResponder 类似,也是可以从会话列表里直接拖拽一个请求到上图红框中,然后对请求的内容进行修改即可。应用场景之一就是可以绕过一些前端用 js 写的限制与验证,直接发送请求,通过返回的数据可以判断后端是否有做相关限制,测试系统的健壮性。


Filters(过滤器)


在默认情况下,Filters 会抓取一切能够抓取到的请求,统统列在左侧的会话列表里,如果我们是有目的对某些接口进行测试,就会觉得请求列表很杂乱,这时可以点开 Filters 标签,勾选 Use Filters,启动过滤工具,如下图:


image.png

接着就可以根据我们需要对左侧列表里展示的所抓取的接口进行过滤,比如根据 Hosts 进行过滤,只显示 Hosts 为 api.juejin.cn 的请求,就可以如下图在 Hosts 那选择 'Show only the following Hosts',然后点击右上角 Actions 里的 'Run Filterset now' 执行过滤。


image.png

过滤的筛选条件还有很多,比如据请求头字段里 URL 是否包含某个单词等,都很简单,一看便知,这里不再一一细说。


HTTPS 抓包


默认情况下,Fiddler 没办法显示 HTTPS 的请求,需要进行证书的安装:



  1. 点击 'Tools -> Options...' ,勾选上 'Decrypt HTTPS traffic' (解密HTTPS流量)


image.png



  1. 点击 Actions 按钮,点击 'Reset All Certicicates' (重置所有证书),之后遇到弹出的窗口,就一直点击 '确定' 或 'yes' 就行了。


image (1).png



  1. 查看证书是否安装成功:点击 'Open Windows Certificate Manager' 打开 Windows 证书管理器窗口


image (2).png

点击 '操作' 选择 '查找证书',在 '包含' 输入框输入 fiddler 进行查找


image (3).png

查找结果类似下图即安装证书成功


image (4).png

现在会话列表就能成功显示 https 协议的请求了。


断点应用


全局断点


通过 'Rules -> Automatic Breakpoints' 可以给请求打断点,也就是中断请求,断点分为两种:



  1. Before Requests(请求前断点):请求发送给服务器之前进行中断

  2. After Responses(响应后断点):响应返回给客户端之前进行中断


image.png

打上断点之后,选中想要修改传输参数的那一条请求,按 R 进行重发,这条请求就会按要求在请求前或响应后被拦截,我们就可以根据需要进行修改,然后点击工具栏的 'Go',或者点击如下图所示的绿色按钮 'Run to Completion',继续完成请求。


image (1).png

这样打断点是全局断点,即所有请求都会被拦截,下面介绍局部断点。


局部断点


如果只想对某一条请求打断点,则可以在 QuickExec 输入相应的命令执行。



  • 请求前断点



  1. 在 QuickExec 输入 bpu query_adverts 。注意:query_adverts 为请求的 url 的一部分,这样就只有 url 中包含 query_adverts 的请求会被打上断点。


image (2).png



  1. 按下 Enter 键,可以看到红框中显示 query_adverts 已经被 breakpoint 了,而且是 RequestURI


image (3).png



  1. 选中 url 中带 query_adverts 的这条请求,按 R 再次发送,在发给服务器前就会被中断(原谅我又拿掘金的请求做例子~)


image (4).png



  1. 取消断点:在 QuickExec 输入 bpu 按下 Enter 即可



  • 响应后断点


与请求前断点步骤基本一致,区别在于输入的命令是 bpafter get_today_status
按下 Enter 后在 'Composer' 标签下点击 'Execute' 执行,再次发送该请求则服务器的响应在发送给浏览器之前被截断,注意下红色的图标,跟之前的请求前断点的区别在于一个是向上的箭头,一个是向下的箭头。


image (5).png

取消拦截则是输入 bpafter 后回车,可以看到状态栏显示 'ResponseURI breakpoint cleared'


image (6).png


弱网测试


Fiddler 还可以用于弱网测试,'Rules -> Performance -> 勾选 Simulate Modem Speeds' 即可


image (7).png

再次刷新网页会感觉回到了拨号上网的年代,可以测试网站在网速很低的情况下的表现。


修改网速


网速还可以修改,点击 'FiddlerScript' 标签,在下图绿框中搜索 simulateM,按几下回车找到 if (m_SimulateModem) 这段代码,可以修改上下传输的速度:


image (8).png


安卓手机抓包


最后一部分主要内容是关于手机抓包的,我用的是小米手机 9,MIUI 12.5.1 稳定版,安卓版本为 11。



  1. 首先保证安装了 Fiddler 的电脑和手机连的是同一个 wifi

  2. 在 Fiddler 中,点击 'Tools -> Options...' ,在弹出的 Options 窗口选择 Connections 标签,勾选 'Allow remote computers to connect'


image (9).png



  1. 手机打开 '设置 -> WLAN -> 连接的那个 WLAN 的设置' 进入如下图所示的页面


image (10).png



  1. '代理' 选择 '手动','主机名' 填写电脑的主机名,端口则是 Fiddler 默认监听的 8888,然后点击左上角的 '打钩图标' 进行保存

  2. 下载证书:打开手机浏览器,输入 'http://192.168.1.1:8888' (注意:192.168.1.1 要替换成你电脑的 ip 地址),会出现如下页面


image (11).png

点击红框中链接进行证书的下载



  1. 安装证书:打开 '设置 -> 密码与安全 -> 系统安全 -> 加密与凭据 -> 安装证书(从存储设备安装证书)-> 证书 ' 找到刚刚下载的证书进行安装


image (12).png



  1. 安装完成可以在 '加密与凭据 -> 信任的凭据' 下查看


image (13).png



  1. 现在 Fiddler 就可以抓到手机里 app 发送的请求了

  2. 最后注意:测试完毕需要关闭手机的 WLAN 代理,否则手机就上不了网了~


One More Thing


几个常用快捷键



  • 双击某一条请求:打开该请求的 Inspectors 面板

  • ctrl + X:清除请求列表

  • R:选中某一条请求,按 R 键可重新发送该请求

  • shift+delete:删除除了选中那一条之外的请求



链接:https://juejin.cn/post/6983282278277316615

收起阅读 »

小程序自动化测试入门到实践

背景 随着小程序项目越来越复杂,业务场景越来多,花费在回归测试上的时间会越来越多,前端自动化测试就非常有必要提上日程。 今天要带来的是: 小程序自动化测试入门教程。 环境 系统 :macOS 微信开发者工具版本: 1.05.2106300 什么是小程序自动化 ...
继续阅读 »

背景


随着小程序项目越来越复杂,业务场景越来多,花费在回归测试上的时间会越来越多,前端自动化测试就非常有必要提上日程。


今天要带来的是: 小程序自动化测试入门教程


环境


系统 :macOS

微信开发者工具版本: 1.05.2106300


什么是小程序自动化


微信官方文档:小程序自动化


使用小程序自动化 SDK miniprogram-automator,可以在帮助我们在小程序中完成一些事情,比如:控制小程序跳转到指定页面,获取小程序页面数据,获取小程序页面元素状态等。


配合 jest 就可以实现小程序端自动化测试了。
话不多说,我们开始吧


准备




  1. 项目根目录 mini-auto-test-demo 里面准备两个目录 miniprogram 放小程序代码,和 test-e2e 放测试用例代码




 |— mini-auto-test-demo/  // 根目录
|— miniprogram/ // 小程序代码
|— pages/
|— index/ // 测试文件
|— test-e2e/ // 测试用例代码
|— index.spec.js // 启动文件
|— package.json

index 文件夹下准备用于测试的页面

<!--index.wxml-->
<view class="userinfo">
<view class="userinfo-avatar" bindtap="bindViewTap">
<open-data type="userAvatarUrl"></open-data>
</view>
<open-data type="userNickName"></open-data>
</view>

/**index.wxss**/
.userinfo {
margin-top: 50px;
display: flex;
flex-direction: column;
align-items: center;
color: #aaa;
}
.userinfo-avatar {
overflow: hidden;
width: 128rpx;
height: 128rpx;
margin: 20rpx;
border-radius: 50%;
}

// index.js
// 获取应用实例
const app = getApp()
Page({
data: {
userInfo: {},
},
// 事件处理函数
bindViewTap() {
wx.navigateTo({
url: '../logs/logs'
})
}
})


  1. 微信开发者工具->设置-> 安全设置 -> 打卡服务端口


image.png



  1. 安装npm包


如果根目录没有 package.json 文件,先执行


npm init

如果根目录已经有 package.json 文件 ,执行以下命令:


npm install miniprogram-automator jest --save-dev
npm i jest -g

安装需要的依赖



  1. 在根目录下新建index.spec.js 文件
const automator = require('miniprogram-automator')

automator.launch({
cliPath: '/Applications/wechatwebdevtools.app/Contents/MacOS/cli', // 工具 cli 位置
projectPath: '/Users/SONG/Documents/github/mini-auto-test-demo/miniprogram', // 项目文件地址
}).then(async miniProgram => {
const page = await miniProgram.reLaunch('/pages/index/index')
await page.waitFor(500)
const element = await page.$('.userinfo-avatar')
console.log(await element.attribute('class'))
await element.tap()
await miniProgram.close()
})

这里要注意修改为自己的cli位置和项目文件地址:



  1. cliPath:


可以在应用程序中找到微信开发者工具,点击右键点击"显示包内容"


image.png


找到cli后,快捷键 :command+option+c 复制路径, 就拿到了


image.png



  1. projectPath:


注意!!项目路径填写的是小程序文件夹miniprogram而不是mini-auto-test-demo


启动


写好路径后,在mac终端进入mini-auto-test-demo根目录或 vscode 终端根目录执行命令:


node index.spec.js

image.png


你会发现微信开发者工具被自动打开,并执行了点击事件进入了log页面,终端输出了class的值。
到此你已经感受到了自动化,接下来你要问了,自动化测试呢?别急,接着往下看。


自动化测试


在一开始准备的test-e2e 文件夹下新建integration.test.js文件,


引入'miniprogram-automator, 连接自动化操作端口,把刚刚index.spec.js中的测试代码,放到 jest it 里,jest相关内容我们这里就不赘述了,大家可以自行学习(其实我也才入门 ̄□ ̄||)。

const automator = require('miniprogram-automator');

describe('index', () => {
let miniProgram;
let page;
const wsEndpoint = 'ws://127.0.0.1:9420';
beforeAll(async() => {
miniProgram = await automator.connect({
wsEndpoint: wsEndpoint
});
}, 30000);

it('test index', async() => {
page = await miniProgram.reLaunch('/pages/index/index')
await page.waitFor(500)
const element = await page.$('.userinfo-avatar')
console.log(await element.attribute('class'))
await element.tap()
});
});

package.json scripts 添加命令


"e2e": "jest ./test-e2e integration.test.js --runInBand"

测试代码写好了,接下来如何运行呢?这里我们提另外一个方法。


cli 命令行调用


官方文档:命令行调用

你一定会问,刚刚我们不是学习了启动运行,这么还要学另外一种方法 o(╥﹏╥)o
大家都知道,一般团队里都是多人合作的,大家的项目路径都不一样,难道每次还要改projectPath吗?太麻烦了,使用cli就不需要考虑在哪里启动,项目地址在哪里,话不多说,干!


打开终端进入放微信开发者工具cli文件夹(路径仅供参考):


cd /Applications/wechatwebdevtools.app/Contents/MacOS 

执行命令(如果你的微信开发者工具开着项目,先关掉)


./cli --auto  /Users/SONG/Documents/github/mini-auto-test-demo/miniprogram  --auto-port 9420

微信开发者工具通过命令行启动


image.png


启动后在项目根目录下执行,可以看到测试通过


npm run e2e

image.png


到此,我们已经可以写测试用例了。这只是入门系列,后续会持续更文,感谢大家的耐心阅读,如果你有任何问题都可以留言给我,摸摸哒



链接:https://juejin.cn/post/6983294039852318728
收起阅读 »

面试官:能不能手写几道链表的基本操作

反转链表 示例: 输入: 1->2->3->4->5->NULL 输出: 5->4->3->2->1->NULL 循环解决方案 这道题是链表中的经典题目,充分体现链表这种数据结构 操作思路简单 ,...
继续阅读 »

反转链表


示例:


输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL


  • 循环解决方案


这道题是链表中的经典题目,充分体现链表这种数据结构 操作思路简单 , 但是 实现上 并没有那么简单的特点。


那在实现上应该注意一些什么问题呢?


保存后续节点。作为新手来说,很容易将当前节点的 next 指针直接指向前一个节点,但其实当前节点下一个节点 的指针也就丢失了。因此,需要在遍历的过程当中,先将下一个节点保存,然后再操作 next指向。


链表结构声定义如下:


function ListNode(val) {
this.val = val;
this.next = null;
}

实现如下:

/**
* @param {ListNode} head
* @return {ListNode}
*/
let reverseList = (head) => {
if (!head)
return null;
let pre = null,
cur = head;
while (cur) {
// 关键: 保存下一个节点的值
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
};


  • 递归解决方案


let reverseList = (head) =>{
let reverse = (pre, cur) => {
if(!cur) return pre;
// 保存 next 节点
let next = cur.next;
cur.next = pre;
return reverse(cur, next);
}
return reverse(null, head);
}

2.区间反转


反转从位置 m 到 n 的链表。请使用一趟扫描完成反转。


说明: 1 ≤ m ≤ n ≤ 链表长度。


示例:


输入: 1->2->3->4->5->NULL, m = 2, n = 4
输出: 1->4->3->2->5->NULL

思路
这一题相比上一个整个链表反转的题,其实是换汤不换药。我们依然有两种类型的解法:循环解法递归解法


image.png
关于前节点和后节点的定义,大家在图上应该能看的比较清楚了,后面会经常用到。


反转操作上一题已经拆解过,这里不再赘述。值得注意的是反转后的工作,那么对于整个区间反转后的工作,其实就是一个移花接木的过程,首先将前节点的 next 指向区间终点,然后将区间起点的 next 指向后节点。因此这一题中有四个需要重视的节点: 前节点 、 后节点 、 区间起点 和 区间终点 。



  • 循环解法
/**
* @param {ListNode} head
* @param {number} m
* @param {number} n
递归解法
对于递归解法,唯一的不同就在于对于区间的处理,采用递归程序进行处理,大家也可以趁着复习一下
递归反转的实现。
* @return {ListNode}
*/
var reverseBetween = function(head, m, n) {
let count = n - m;
let p = dummyHead = new ListNode();
let pre, cur, start, tail;
p.next = head;
for(let i = 0; i < m - 1; i ++) {
p = p.next;
}
// 保存前节点
front = p;
// 同时保存区间首节点
pre = tail = p.next;
cur = pre.next;
// 区间反转
for(let i = 0; i < count; i++) {
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// 前节点的 next 指向区间末尾
front.next = pre;
// 区间首节点的 next 指向后节点(循环完后的cur就是区间后面第一个节点,即后节点)
tail.next = cur;
return dummyHead.next;
};


  • 递归解法
var reverseBetween = function(head, m, n) {
// 递归反转函数
let reverse = (pre, cur) => {
if(!cur) return pre;
// 保存 next 节点
let next = cur.next;
cur.next = pre;
return reverse(cur, next);
}
let p = dummyHead = new ListNode();
dummyHead.next = head;
let start, end; //区间首尾节点
let front, tail; //前节点和后节点
for(let i = 0; i < m - 1; i++) {
p = p.next;
}
front = p; //保存前节点
start = front.next;
for(let i = m - 1; i < n; i++) {
p = p.next;
}
end = p;
tail = end.next; //保存后节点
end.next = null;
// 开始穿针引线啦,前节点指向区间首,区间首指向后节点
front.next = reverse(null, start);
start.next = tail;
return dummyHead.next;
}

3.两个一组翻转链表


给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。


你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。


示例


给定 1->2->3->4, 你应该返回 2->1->4->3

思路


如图所示,我们首先建立一个虚拟头节点(dummyHead),辅助我们分析。


image.png


首先让 p 处在 dummyHead 的位置,记录下 p.next 和 p.next.next 的节点,也就是 node1 和
node2。


随后让 node1.next = node2.next, 效果:


image.png


然后让 node2.next = node1, 效果:


image.png
最后,dummyHead.next = node2,本次翻转完成。同时 p 指针指向node1, 效果如下:


image.png
依此循环,如果 p.next 或者 p.next.next 为空,也就是 找不到新的一组节点 了,循环结束。



  • 循环解决
var swapPairs = function(head) {
if(head == null || head.next == null)
return head;
let dummyHead = p = new ListNode();
let node1, node2;
dummyHead.next = head;
while((node1 = p.next) && (node2 = p.next.next)) {
node1.next = node2.next;
node2.next = node1;
p.next = node2;
p = node1;
}
return dummyHead.next;
};


  • 递归方式


var swapPairs = function(head) {
if(head == null || head.next == null)
return head;
let node1 = head, node2 = head.next;
node1.next = swapPairs(node2.next);
node2.next = node1;
return node2;
};

4.K个一组翻转


给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。


k 是一个正整数,它的值小于或等于链表的长度。


如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。


示例


给定这个链表:1->2->3->4->5
当 k = 2 时,应当返回: 2->1->4->3->5
当 k = 3 时,应当返回: 3->2->1->4->5

说明 :


你的算法只能使用常数的额外空间。


你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。


思路
思路类似No.3中的两个一组翻转。唯一的不同在于两个一组的情况下每一组只需要反转两个节点,而在K 个一组的情况下对应的操作是将 K 个元素 的链表进行反转。



  • 递归解法
/**
* @param {ListNode} head
* @param {number} k
* @return {ListNode}
*/
var reverseKGroup = function(head, k) {
let pre = null, cur = head;
let p = head;
// 下面的循环用来检查后面的元素是否能组成一组
for(let i = 0; i < k; i++) {
if(p == null) return head;
p = p.next;
}
for(let i = 0; i < k; i++){
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// pre为本组最后一个节点,cur为下一组的起点
head.next = reverseKGroup(cur, k);
return pre;
};


  • 循环解法
var reverseKGroup = function(head, k) {
let count = 0;
// 看是否能构成一组,同时统计链表元素个数
for(let p = head; p != null; p = p.next) {
if(p == null && i < k) return head;
count++;
}
let loopCount = Math.floor(count / k);
let p = dummyHead = new ListNode();
dummyHead.next = head;
// 分成了 loopCount 组,对每一个组进行反转
for(let i = 0; i < loopCount; i++) {
let pre = null, cur = p.next;
for(let j = 0; j < k; j++) {
let next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// 当前 pre 为该组的尾结点,cur 为下一组首节点
let start = p.next;// start 是该组首节点
// 开始穿针引线!思路和2个一组的情况一模一样
p.next = pre;
start.next = cur;
p = start;
}
return dummyHead.next;
}


链接:https://juejin.cn/post/6983580875842093092

收起阅读 »

前端工程化实战 - 企业级 CLI 开发

背景 先罗列一些小团队会大概率会遇到的问题: 规范 代码没有规范,每个人的风格随心所欲,代码交付质量不可控 提交 commit 没有规范,无法从 commit 知晓提交开发内容 流程 研发没有流程,没有 prd,没有迭代的需求管理,这个项目到底做了...
继续阅读 »

背景


image.png


先罗列一些小团队会大概率会遇到的问题:



  1. 规范

    • 代码没有规范,每个人的风格随心所欲代码交付质量不可控

    • 提交 commit 没有规范,无法从 commit 知晓提交开发内容



  2. 流程

    • 研发没有流程,没有 prd,没有迭代的需求管理,这个项目到底做了点啥也不知道



  3. 效率

    • 不断的重复工作,没有技术积累与沉淀



  4. 项目质量

    • 项目没有规范就一定没有质量

    • 测试功能全部靠人工发现与回归,费时费力



  5. 部署

    • 人工构建、部署,刀耕火种般的操作

    • 依赖不统一、人为不可控

    • 没有版本追踪、回滚等功能




除了上述比较常见的几点外,其余的一些人为环境因素就不一一列举了,总结出来其实就是混乱 + 不舒服


同时处在这样的一个团队中,团队自身的规划就不明确,个人就更难对未来有一个清晰的规划与目标,容易全部陷于业务不可自拔、无限循环。


当你处在一个混乱的环境,遇事不要慌(乱世出英雄,为什么不能是你呢),先把事情捋顺,然后定个目标与规划,一步步走。


工程化


上述列举的这些问题可以通过引入工程化体系来解决,那么什么是工程化呢?


广义上,一切以提高效率、降低成本、保障质量为目的的手段,都属于工程化的范畴。


通过一系列的规范、流程、工具达到研发提效、自动化、保障质量、服务稳定、预警监控等等。


对前端而言,在 Node 出现之后,可以借助于 Node 渗透到传统界面开发之外的领域,将研发链路延伸到整个 DevOps 中去,从而脱离“切图仔”成为前端工程师。


image.png


上图是一套简单的 DevOps 流程,技术难度与成本都比较适中,作为小型团队搭建工程化的起点,性价比极高。


在团队没有制定规则,也没有基础建设的时候,通常可以先从最基础的 CLI 工具开始然后切入到整个工程化的搭建。


所以先定一个小目标,完成一个团队、项目通用的 CLI 工具。


CLI 工具分析


小团队里面的业务一般迭代比较快,能抽出来提供开发基建的时间与机会都比较少,为了避免后期的重复工作,在做基础建设之前,一定要做好规划,思考一下当前最欠缺的核心与未来可能需要用到的功能是什么?



Coding 永远不是最难的,最难的是不知道能使用 code 去做些什么有价值的事情。



image.png


参考上述的 DevOps 流程,本系列先简单规划出 CLI 的四个大模块,后续如果有需求变动再说。



可以根据自己项目的实际情况去设计 CLI 工具,本系列仅提供一个技术架构参考。



构建


通常在小团队中,构建流程都是在一套或者多套模板里面准备多环境配置文件,再使用 Webpack Or Rollup 之类的构建工具,通过 Shell 脚本或者其他操作去使用模板中预设的配置来构建项目,最后再进行部署之类的。


这的确是一个简单、通用的 CI/CD 流程,但问题来了,只要最后一步的发布配置不在可控之内,任意团队的开发成员都可以对发布的配置项做修改。


即使构建成功,也有可能会有一些不可预见的问题,比如 Webpack 的 mode 选择的是 dev 模式、没有对构建代码压缩混淆、没有注入一些全局统一方法等等,此时对生产环境而言是存在一定隐患的


所以需要将构建配置、过程从项目模板中抽离出来,统一使用 CLI 来接管构建流程,不再读取项目中的配置,而通过 CLI 使用统一配置(每一类项目都可以自定义一套标准构建配置)进行构建。


避免出现业务开发同学因为修改了错误配置而导致的生产问题。


质量


与构建是一样的场景,业务开发的时候为了方便,很多时候一些通用的自动化测试以及一些常规的格式校验都会被忽略。比如每个人开发的习惯不同也会导致使用的 ESLINT 校验规则不同,会对 ESLINT 的配置做一些额外的修改,这也是不可控的一个点。一个团队还是使用同一套代码校验规则最好。


所以也可以将自动化测试、校验从项目中剥离,使用 CLI 接管,从而保证整个团队的某一类项目代码格式的统一性。


模板


至于模板,基本上目前出现的博客中,只要是关于 CLI 的,就必然会有模板功能。


因为这个一个对团队来说,快速、便捷初始化一个项目或者拉取代码片段是非常重要的,也是作为 CLI 工具来说产出最高、收益最明显的功能模块,但本章就不做过多的介绍,放在后面模板的博文统一写。


工具合集


既然是工具合集,那么可以放一些通用的工具类在里面,比如



  1. 图片压缩(png 压缩的更小的那种)、上传 CDN 等

  2. 项目升级(比如通用配置更新了,CLI 提供一键升级模板的功能)

  3. 项目部署、发布 npm 包等操作。

  4. 等等其他一些重复性的操作,也都可以放在工具合集里面


CLI 开发


前面介绍了 CLI 的几个模块功能设计,接下来可以正式进入开发对应的 CLI 工具的环节。


搭建基础架构


CLI 工具开发将使用 TS 作为开发语言,如果此时还没有接触过 TS 的同学,刚好可以借此项目来熟悉一下 TS 的开发模式。


mkdir cli && cd cli // 创建仓库目录
npm init // 初始化 package.json
npm install -g typescript // 安装全局 TypeScript
tsc --init // 初始化 tsconfig.json

全局安装完 TypeScript 之后,初始化 tsconfig.json 之后再进行修改配置,添加编译的文件夹与输出目录。

{
"compilerOptions": {
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"outDir": "./lib", /* Redirect output structure to the directory. */
"strict": true, /* Enable all strict type-checking options. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"include": [
"./src",
]
}

上述是一份已经简化过的配置,但应对当前的开发已经足够了,后续有需要可以修改 TypeScript 的配置项。


ESLINT


因为是从 0 开发 CLI 工具,可以先从简单的功能入手,例如开发一个 Eslint 校验模块。


npm install eslint --save-dev // 安装 eslint 依赖
npx eslint --init // 初始化 eslint 配置

直接使用 eslint --init 可以快速定制出适合自己项目的 ESlint 配置文件 .eslintrc.json

{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"plugin:react/recommended",
"standard"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint"
],
"rules": {
}
}


如果项目中已经有定义好的 ESlint,可以直接使用自己的配置文件,或者根据项目需求对初始化的配置进行增改。


创建 ESlint 工具类


第一步,对照文档 ESlint Node.js API,使用提供的 Node Api 直接调用 ESlint。


将前面生成的 .eslintrc.json 的配置项按需加入,同时使用 useEslintrc:fase 禁止使用项目本身的 .eslintrc 配置,仅使用 CLI 提供的规则去校验项目代码。

import { ESLint } from 'eslint'
import { getCwdPath, countTime } from '../util'

// 1. Create an instance.
const eslint = new ESLint({
fix: true,
extensions: [".js", ".ts"],
useEslintrc: false,
overrideConfig: {
"env": {
"browser": true,
"es2021": true
},
"parser": getRePath("@typescript-eslint/parser"),
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint",
],
},
resolvePluginsRelativeTo: getDirPath('../../node_modules') // 指定 loader 加载路径
});


export const getEslint = async (path: string = 'src') => {
try {
countTime('Eslint 校验');
// 2. Lint files.
const results = await eslint.lintFiles([`${getCwdPath()}/${path}`]);

// 3. Modify the files with the fixed code.
await ESLint.outputFixes(results);

// 4. Format the results.
const formatter = await eslint.loadFormatter("stylish");

const resultText = formatter.format(results);

// 5. Output it.
if (resultText) {
console.log('请检查===》', resultText);
}
else {
console.log('完美!');
}
} catch (error) {

process.exitCode = 1;
console.error('error===>', error);
} finally {
countTime('Eslint 校验', false);
}
}

创建测试项目


npm install -g create-react-app // 全局安装 create-react-app
create-react-app test-cli // 创建测试 react 项目

测试项目使用的是 create-react-app,当然你也可以选择其他框架或者已有项目都行,这里只是作为一个 demo,并且后期也还会再用到这个项目做测试。


测试 CLI


新建 src/bin/index.ts, demo 中使用 commander 来开发命令行工具。

#!/usr/bin/env node // 这个必须添加,指定 node 运行环境
import { Command } from 'commander';
const program = new Command();

import { getEslint } from '../eslint'

program
.version('0.1.0')
.description('start eslint and fix code')
.command('eslint')
.action((value) => {
getEslint()
})
program.parse(process.argv);

修改 pageage.json,指定 bin 的运行 js(每个命令所对应的可执行文件的位置)


 "bin": {
"fe-cli": "/lib/bin/index.js"
},

先运行 tsc 将 TS 代码编译成 js,再使用 npm link 挂载到全局,即可正常使用。



commander 的具体用法就不详细介绍了,基本上市面大部分的 CLI 工具都使用 commander 作为命令行工具开发,也都有这方面的介绍。



命令行进入刚刚的测试项目,直接输入命令 fe-cli eslint,就可以正常使用 Eslint 插件,输出结果如下:


image.png


美化输出


可以看出这个时候,提示并没有那么显眼,可以使用 chalk 插件来美化一下输出。


先将测试工程故意改错一个地方,再运行命令 fe-cli eslint


image.png


至此,已经完成了一个简单的 CLI 工具,对于 ESlint 的模块,可以根据自己的想法与规划定制更多的功能。


构建模块


配置通用 Webpack


通常开发业务的时候,用的是 webpack 作为构建工具,那么 demo 也将使用 webpack 进行封装。


先命令行进入测试项目中执行命令 npm run eject,暴露 webpack 配置项。


image.png


从上图暴露出来的配置项可以看出,CRA 的 webpack 配置还是非常复杂的,毕竟是通用型的脚手架,针对各种优化配置都做了兼容,但目前 CRA 使用的还是 webpack 4 来构建。作为一个新的开发项目,CLI 可以不背技术债务,直接选择 webpack 5 来构建项目。



一般来说,构建工具替换不会影响业务代码,如果业务代码被构建工具绑架,建议还是需要去优化一下代码了。


import path from "path"

const HtmlWebpackPlugin = require('html-webpack-plugin')
const postcssNormalize = require('postcss-normalize');
import { getCwdPath, getDirPath } from '../../util'

interface IWebpack {
mode?: "development" | "production" | "none";
entry: any
output: any
template: string
}

export default ({
mode,
entry,
output,
template
}: IWebpack) => {
return {
mode,
entry,
target: 'web',
output,
module: {
rules: [{
test: /\.(js|jsx)$/,
use: {
loader: getRePath('babel-loader'),
options: {
presets: [
''@babel/preset-env',
],
},
},
exclude: [
getCwdPath('./node_modules') // 由于 node_modules 都是编译过的文件,这里做过滤处理
]
},
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
[
'postcss-preset-env',
{
ident: "postcss"
},
],
],
},
}
}
],
},
{
test: /\.(woff(2)?|eot|ttf|otf|svg|)$/,
type: 'asset/inline',
},
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: 'url-loader',
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
]
},
plugins: [
new HtmlWebpackPlugin({
template,
filename: 'index.html',
}),
],
resolve: {
extensions: [
'',
'.js',
'.json',
'.sass'
]
},
}
}

上述是一份简化版本的 webpack 5 配置,再添加对应的 commander 命令。


program
.version('0.1.0')
.description('start eslint and fix code')
.command('webpack')
.action((value) => {
buildWebpack()
})

现在可以命令行进入测试工程执行 fe-cli webpack 即可得到下述构建产物


image.png


image.png


下图是使用 CRA 构建出来的产物,跟上图的构建产物对一下,能明显看出使用简化版本的 webpack 5 配置还有很多可优化的地方,那么感兴趣的同学可以再自行优化一下,作为 demo 已经完成初步的技术预研,达到了预期目标。


image.png


此时,如果熟悉构建这块的同学应该会想到,除了 webpack 的配置项外,构建中绝大部分的依赖都是来自测试工程里面的,那么如何确定 React 版本或者其他的依赖统一呢?


常规操作还是通过模板来锁定版本,但是业务同学依然可以自行调整版本依赖导致不一致,并不能保证依赖一致性。


既然整个构建都由 CLI 接管,只需要考虑将全部的依赖转移到 CLI 所在的项目依赖即可。


解决依赖


Webpack 配置项新增下述两项,指定依赖跟 loader 的加载路径,不从项目所在 node_modules 读取,而是读取 CLI 所在的 node_modules。


resolveLoader: {
modules: [getDirPath('../../node_modules')]
}, // 修改 loader 依赖路径
resolve: {
modules: [getDirPath('../../node_modules')],
}, // 修改正常模块依赖路径

同时将 babel 的 presets 模块路径修改为绝对路径,指向 CLI 的 node_modules(presets 会默认从启动路劲读取依赖)。

{
test: /\.(js|jsx)$/,
use: {
loader: getRePath('babel-loader'),
options: {
presets: [
getRePath('@babel/preset-env'),
[
getRePath("@babel/preset-react"),
{
"runtime": "automatic"
}
],
],
},
},
exclude: [
[getDirPath('../../node_modules')]
]
}

完成依赖修改之后,一起测试一下效果,先将测试工程的依赖 node_modules 全部删除


image.png


再执行 fe-cli webpack,使用 CLI 依赖来构建此项目。


image.png


image.png


可以看出,已经可以在项目不安装任何依赖的情况,使用 CLI 也可以正常构建项目了。


那么目前所有项目的依赖、构建已经全部由 CLI 接管,可以统一管理依赖与构建流程,如果需要升级依赖的话可以使用 CLI 统一进行升级,同时业务开发同学也无法对版本依赖进行改动。



这个解决方案要根据自身的实际需求来实施,所有的依赖都来源于 CLI 工具的话,版本升级影响会非常大也会非常被动,要做好兼容措施。比如哪些依赖可以取自项目,哪些依赖需要强制通用,做好取舍。



写给迷茫 Coder 们的一段话


如果遇到最开始提到那些问题的同学们,应该会经常陷入到业务中无法自拔,而且写这种基础项目,是真的很花时间也很枯燥。容易对工作厌烦,对 coding 感觉无趣。


这是很正常的,绝大多数人都有这段经历与类似的想法,但还是希望你能去多想想,在枯燥、无味、重复的工作中去发现痛点、机会。只有接近业务、熟悉业务,才有机会去优化、革新、创造。


所有的基建都是要依托业务才能发挥最大的作用


每天抽个半小时思考一下今天的工作还能在哪些方面有所提高,提高效率的不仅仅是你的代码也可以是其他的工具或者是引入新的流程。


同时也不要仅仅限制在思考阶段,有想法就争取落地,再多抽半小时进行 coding 或者找工具什么的,但凡能够提高个几分钟的效率,即使是个小工具、多几行代码、换个流程这种也值得去尝试一下。


等你把这些零碎的小东西、想法一点点全部积累起来,到最后整合到一个体系中去,那么此时你会发现已经可以站在更高一层的台阶去思考、规划下一阶段需要做的事情,而这其中所有的经历都是你未来成长的基石。


一直相信一句话:努力不会被辜负,付出终将有回报。此时敲下去的每一行代码在未来都将是你登高的一步步台阶。



链接:https://juejin.cn/post/6982215543017193502

收起阅读 »

完了,又火一个前端项目

今天逛 GitHub 的时候,在趋势榜上看到一个项目,竟然短短一天的时间内,涨了 1000 多个星星! 就是这个名为 solid 的项目: 要知道日增上千 star 可是非常难得的,我不禁感到好奇,点进去看看这个项目到底有啥牛逼的? 啥是 Solid? 这是...
继续阅读 »

今天逛 GitHub 的时候,在趋势榜上看到一个项目,竟然短短一天的时间内,涨了 1000 多个星星!


就是这个名为 solid 的项目:



要知道日增上千 star 可是非常难得的,我不禁感到好奇,点进去看看这个项目到底有啥牛逼的?


啥是 Solid?


这是一个国外的前端项目,截止到发文前,已经收获了 8400 个 star。


我总觉得这个项目很眼熟,好像之前也看到过,于是去 Star History 上搜了一下这个项目的 star 增长历史。好家伙,这几天的增速曲线几乎接近垂直,已经连续好几天增长近千了!


项目 Star 增长曲线


看到这个曲线,我想起来了,solid 是一个 JavaScript 框架,此前在一次 JavaScript 框架的性能测试中看到过它。


要知道,现在的 JavaScript 开发框架基本就是 React、Vue、Angular 三分天下,还有就是新兴的 Svelte 框架潜力无限(近 5w star),其他框架想分蛋糕还是很难的。那么 Solid 到底有什么本事,能让他连续几天 star 数暴涨呢?


描述


打开官网,官方对 Solid 的描述是:一个用于构建用户界面的 声明性 JavaScript 库,特点是高效灵活。


顺着官网往下看,Solid 有很多特点,比如压缩后的代码体积只有 6 kb;而且天然支持 TypeScript 以及 React 框架中经常编写的 JSX(JavaScript XML)。


来看看官网给的示例代码:


Solid 语法


怎么样,他的语法是不是和 React 神似?


性能


但是,这些并不能帮助 Solid 框架脱颖而出,真正牛逼的一点是它 非常快


有多快呢?第一够不够 !


JS 框架性能测试对比


有同学说了,你这不睁着眼睛说瞎话么?Solid 明明是第二,第一是 Vanilla 好吧!


哈哈,但事实上,Vanilla 其实就是不使用任何框架的纯粹的原生 JavaScript,通常作为一个性能比较的基准。


那么 Solid 为什么能做到这么快呢?甚至超越了我们引以为神的 Vue 和 React。


这是因为 Solid 没有采用其他主流前端框架中的 Virtual DOM,而是直接被静态编译为真实的原生 DOM 节点,并且将更新控制在细粒度的局部范围内。从而让 runtime(运行时)更加轻小,也不需要所谓的脏检查和摘要循环带来的额外消耗,使得性能和原生 JavaScript 几乎无异。换句话说,编译后的 Solid 其实就是 JavaScript!



其实 Solid 的原理和新兴框架 Svelte 的原理非常类似,都是编译成原生 DOM,但为啥他更快一点呢?


为了搞清楚这个问题,我打开了百度来搜这玩意,但发现在国内根本搜不到几条和 Solid.js 有关的内容,基本全是一些乱七八糟的东西。后来还是在 Google 上搜索,才找到了答案,结果答案竟然还是来自于某乎的大神伊撒尔。。。


要搞清楚为什么 Solid 比 Svelte 更快,就要看看同一段代码经过它们编译后,有什么区别。


大神很贴心地举了个例子,比如这句代码:


<div>{aaa}</div>

经 Svelte 编译后的代码:

let a1, a2
a1 = document.creatElement('div')
a2 = docment.createTextNode('')
a2.nodeValue = ctx[0] // aaa
a1.appendChild(a2)

经 Solid 编译后的代码:

let a1, a2
let fragment = document.createElement('template')
fragment.innerHTML = `<div>aaa</div>`
a1 = fragment.firstChild
a2 = a1.fristChild
a2.nodeValue = data.aaa

可以看到,在创建 DOM 节点时,原来 Solid 耍了一点小把戏,利用了 innerHTML 代替 createElement 来创建,从而进一步提升了性能。


当然,抛去 Virtual DOM 不意味着就是 “银弹” 了,毕竟十年前各种框架出现前大家也都是写原生 JavaScript,轻 runtime 也有缺点,这里就不展开说了。


除了快之外,Solid 还有一些其他的特点,比如语法精简、WebComponent 友好(可自定义元素)等。




总的来说, 我个人还是非常看好这项技术的,日后说不定能和 Svelte 一起动摇一下三巨头的地位,给大家更多的选择呢?这也是技术选型好玩的地方,没有绝对最好的技术,只有最适合的技术。


不禁感叹道:唉,技术发展太快了,一辈子学不完啊!(不过前端初学者不用关心那么多,老老实实学基础三件套 + Vue / React 就行了)


链接:https://juejin.cn/post/6983177757219897352

收起阅读 »

判断是否完全二叉树

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
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

一行代码实现欢迎引导页-GuidePage

GuidePageGuidePage for Android 是一个App欢迎引导页。一般用于首次打开App时场景,通过引导页指南,概述App特色等相关信息功能介绍 链式调用,简单易用 自定义配置,满足各种需求引入Maven:<dep...
继续阅读 »

GuidePage

GuidePage for Android 是一个App欢迎引导页。一般用于首次打开App时场景,通过引导页指南,概述App特色等相关信息

功能介绍

  •  链式调用,简单易用
  •  自定义配置,满足各种需求


引入

Maven:

<dependency>
<groupId>com.king.guide</groupId>
<artifactId>guidepage</artifactId>
<version>1.0.0</version>
<type>pom</type>
</dependency>

Gradle:

//AndroidX
implementation 'com.king.guide:guidepage:1.0.0'

Lvy:

<dependency org='com.king.guide' name='guidepage' rev='1.0.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' }
}
}

示例

代码示例

    //简单调用示例
GuidePage.load(intArrayOf(R.drawable.guide_page_1,R.drawable.guide_page_2,R.drawable.guide_page_3,R.drawable.guide_page_4))
.pageDoneDrawableResource(R.drawable.btn_done)
.start(this)//Activity or Fragment
      //Demo中的调用示例
GuidePage.load(intArrayOf(R.drawable.guide_page_1,R.drawable.guide_page_2,R.drawable.guide_page_3,R.drawable.guide_page_4))
.pageDoneDrawableResource(R.drawable.btn_done)
// .indicatorDrawableResource(R.drawable.indicator_radius)
// .indicatorSize(this,6f)//默认5dp
.showSkip(v.id == R.id.btn1)//是否显示“跳过”
.lastPageHideSkip(true)//最后一页是否隐藏“跳过”
.onGuidePageChangeCallback(object : GuidePage.OnGuidePageChangeCallback{//引导页改变回调接口

override fun onPageDone(skip: Boolean) {
//TODO 当点击完成(立即体验)或者右上角的跳过时,触发此回调方法
//这里可以执行您的逻辑,比如跳转到APP首页或者登陆页
if(skip){
Toast.makeText(this@MainActivity,"跳过",Toast.LENGTH_SHORT).show()
}else{
Toast.makeText(this@MainActivity,"立即体验",Toast.LENGTH_SHORT).show()
}
}

})
.start(this)//Activity or Fragment

相关说明

  • 通过GuidePage链式调用,可以满足一些基本需求场景。
  • GuidePage中提供的配置无法满足需求时,可通过资源命名相同方式去自定义配置,即:资源覆盖方式。如dimensstyles等对应的资源。

更多使用详情,请查看app中的源码使用示例

代码下载:GuidePage.zip

收起阅读 »

Android通用的Adapter、Activity、Fragment、Dialog等-base

BaseBase是针对于Android开发封装好一些常用的基类,主要包括通用的Adapter、Activity、Fragment、Dialog等、和一些常用的Util类,只为更简单。Base 3.x 在Base 2.x 的基础上进行了重构,最大的变化...
继续阅读 »

Base

Base是针对于Android开发封装好一些常用的基类,主要包括通用的Adapter、Activity、Fragment、Dialog等、和一些常用的Util类,只为更简单。

Base 3.x 在Base 2.x 的基础上进行了重构,最大的变化是将base-adapter和base-util提取了出来。

单独提取library主要是为了模块化,使其更加独立。在使用时需要用哪个库就引入库,这样就能尽可能的减少引入库的体积。

  • base 主要是封装了常用的Activity、Fragment、DialogFragment、Dialog等作为基类,方便使用。
  • base-adapter 主要是封装了各种Adapter、简化自定义Adapter步骤,让写自定义适配器从此更简单。
  • base-util 主要是封装了一些常用的工具类。

AndroidX version

引入

Maven:

//base
<dependency>
<groupId>com.king.base</groupId>
<artifactId>base</artifactId>
<version>3.2.1</version>
<type>pom</type>
</dependency>

//base-adapter
<dependency>
<groupId>com.king.base</groupId>
<artifactId>adapter</artifactId>
<version>3.2.1</version>
<type>pom</type>
</dependency>

//base-util
<dependency>
<groupId>com.king.base</groupId>
<artifactId>util</artifactId>
<version>3.2.1</version>
<type>pom</type>
</dependency>

Gradle:

//---------- AndroidX 版本
//base
implementation 'com.king.base:base:3.2.1-androidx'

//base-adapter
implementation 'com.king.base:adapter:3.2.1-androidx'

//base-util
implementation 'com.king.base:util:3.2.1-androidx'


//---------- Android 版本
//base
implementation 'com.king.base:base:3.2.1'

//base-adapter
implementation 'com.king.base:adapter:3.2.1'

//base-util
implementation 'com.king.base:util:3.2.1'

Lvy:

//base
<dependency org='com.king.base' name='base' rev='3.2.1'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>

//base-adapter
<dependency org='com.king.base' name='adapter' rev='3.2.1'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>

//base-util
<dependency org='com.king.base' name='util' rev='3.2.1'>
<artifact name='$AID' ext='pom'></artifact>
</dependency>
如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
allprojects {
repositories {
//...
maven { url 'https://dl.bintray.com/jenly/maven' }
}
}

引入的库:

//---------- AndroidX 版本
//base
compileOnly 'androidx.appcompat:appcompat:1.0.0+'
compileOnly 'com.king.base:util:3.2.1-androidx'

//base-adapter
compileOnly 'androidx.appcompat:appcompat:1.0.0+'
compileOnly 'androidx.recyclerview:recyclerview:1.0.0+'

//base-util
compileOnly 'androidx.appcompat:appcompat:1.0.0+'
//---------- Android 版本
//base
compileOnly 'com.android.support:appcompat-v7:28.0.0'
compileOnly 'com.king.base:util:3.2.1'

//base-adapter
compileOnly 'com.android.support:appcompat-v7:28.0.0'
compileOnly 'com.android.support:recyclerview-v7:28.0.0'

//base-util
compileOnly 'com.android.support:appcompat-v7:28.0.0'

简要说明:

Base主要实用地方体现在:出统一的代码风格,实用的各种基类,BaseActivity和BaseFragment里面还有许多实用的代码封装,只要用了Base,使用Fragment就感觉跟使用Activtiy基本是一样的。

代码示例:

通用的Adapter

/**
*
* 只需继承通用的适配器(ViewHolderAdapter或ViewHolderRecyclerAdapter),简单的几句代码,妈妈再也不同担心我写自定义适配器了。
*/
public class TestAdapter extends ViewHolderAdapter<String> {


public TestAdapter(Context context, List<String> listData) {
super(context, listData);
}

@Override
public View buildConvertView(LayoutInflater layoutInflater,T t,int position, ViewGroup parent) {
return inflate(R.layout.list_item,parent,false);
}

@Override
public void bindViewDatas(ViewHolder holder, String s, int position) {
holder.setText(R.id.tv,s);
}
}

基类BaseActivity

public class TestActivity extends BaseActivity {

private TextView tv;
private Button btn;

@Override
public void initUI() {
//TODO:初始化UI
setContentView(R.layout.activity_test);
tv = findView(R.id.tv);
btn = findView(R.id.btn);
}

@Override
public void initData() {
//TODO:初始化数据(绑定数据)
tv.setText("text");
}

}

GestureActivity

public class TestGestureActivity extends GestureActivity {

private TextView tv;
private Button btn;

@Override
public void initUI() {
//TODO:初始化UI
setContentView(R.layout.activity_test);
tv = findView(R.id.tv);
btn = findView(R.id.btn);
}

@Override
public void initData() {
//TODO:初始化数据(绑定数据)
tv.setText("text");
}

@Override
public void onLeftFling() {
//TODO:向左滑动
}

@Override
public boolean onRightFling() {
//TODO:向右滑动,默认执行finish,返回为true表示拦截事件。
return false;
}
}

SplashActivity

public class TestSplashActivity extends SplashActivity {
@Override
public int getContentViewId() {
return R.layout.activity_splash;
}

@Override
public Animation.AnimationListener getAnimationListener() {
return new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {

}

@Override
public void onAnimationEnd(Animation animation) {
//TODO: 启动动画结束,可执行跳转逻辑
}

@Override
public void onAnimationRepeat(Animation animation) {

}
};
}
}

BaseFragment

public class TestFragment extends BaseFragment {
@Override
public int inflaterRootView() {
return R.layout.fragment_test;
}

@Override
public void initUI() {
//TODO:初始化UI
}

@Override
public void initData() {
//TODO:初始化数据(绑定数据)
}

}

BaseDialogFragment

public class TestDialogFragment extends BaseDialogFragment {
@Override
public int inflaterRootView() {
return R.layout.fragment_test_dialog;
}

@Override
public void initUI() {
//TODO:初始化UI
}

@Override
public void initData() {
//TODO:初始化数据(绑定数据)
}


}

WebFragment

    WebFragment实现基本webView功能

其他小功能

使用Log: 统一控制管理Log

 LogUtils.v();

LogUtils.d();

LogUtils.i();

LogUtils.w();

LogUtils.e();

LogUtils.twf();

LogUtils.println();

使用Toast

 showToast(CharSequence text);

showToast(@StringRes int resId);

使用Dialog

 showDialog(View v);
 showProgressDialog();

showProgressDialog(@LayoutRes int resId);

showProgressDialog(View v);

App中的源码使用示例或直接查看API帮助文档。更多实用黑科技,请速速使用Base体会吧。

代码下载:Base.zip

收起阅读 »

Android 路线规划和导航的地图帮助类库-MapHelper

MapHelperMapHelper for Android 是一个整合了高德地图、百度地图、腾讯地图、谷歌地图等相关路线规划和导航的地图帮助类库。功能介绍 简单易用,一句代码实现 地图路线规划/导航 GCJ-02 /&...
继续阅读 »


MapHelper

Image


MapHelper for Android 是一个整合了高德地图、百度地图、腾讯地图、谷歌地图等相关路线规划和导航的地图帮助类库。

功能介绍

  •  简单易用,一句代码实现
  •  地图路线规划/导航
  •  GCJ-02 / WGS-84 / BD09LL 等相关坐标系互转

Gif 展示

Image

引入

Maven:

<dependency>
<groupId>com.king.map</groupId>
<artifactId>maphelper</artifactId>
<version>1.0.0</version>
<type>pom</type>
</dependency>

Gradle:

implementation 'com.king.map:maphelper:1.0.0'

Lvy:

<dependency org='com.king.map' name='maphelper' rev='1.0.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' }
}
}

示例

代码示例

Kotlin 示例
    //调用相关地图线路/导航示例(params表示一些具体参数)

//跳转到地图(高德、百度、腾讯、谷歌地图等)
MapHelper.gotoMap(params)
//跳转到高德地图
MapHelper.gotoAMap(params)
//跳转到百度地图
MapHelper.gotoBaiduMap(params)
//跳转腾讯地图
MapHelper.gotoTencentMap(params)
//跳转到谷歌地图
MapHelper.gotoGoogleMap(params)
//坐标系转换:WGS-84转GCJ-02(火星坐标系)
MapHelper.wgs84ToGCJ02(lat,lng)
//...更多示例详情请查看MapHelper
Java 示例
    //调用相关地图线路/导航示例(params表示一些具体参数)

//跳转到地图(高德、百度、腾讯、谷歌地图等)
MapHelper.INSTANCE.gotoMap(params);
//跳转到高德地图
MapHelper.INSTANCE.gotoAMap(params);
//跳转到百度地图
MapHelper.INSTANCE.gotoBaiduMap(params);
//跳转腾讯地图
MapHelper.INSTANCE.gotoTencentMap(params);
//跳转到谷歌地图
MapHelper.INSTANCE.gotoGoogleMap(params);
//坐标系转换:WGS-84转GCJ-02(火星坐标系)
MapHelper.INSTANCE.wgs84ToGCJ02(lat,lng);
//...更多示例详情请查看MapHelper

更多使用详情,请查看app中的源码使用示例或直接查看API帮助文

代码下载:MapHelper.zip

收起阅读 »

一文读懂JavaScript函数式编程重点-- 实践 总结

什么是函数式编程?函数式编程是一种思维方式,函数式编程与命令式编程最大的不同其实在于:函数式编程关心数据的映射,命令式编程关心解决问题的步骤。函数式编程的初衷来, 也就是: 希望可以允许程序员用计算来表示程序, 用计算的组合来表达程序的组合, 而非函数式编程则...
继续阅读 »

什么是函数式编程?

函数式编程是一种思维方式,函数式编程与命令式编程最大的不同其实在于:

函数式编程关心数据的映射,命令式编程关心解决问题的步骤。函数式编程的初衷来, 也就是: 希望可以允许程序员用计算来表示程序, 用计算的组合来表达程序的组合, 而非函数式编程则习惯于用命令来表示程序, 用命令的顺序执行来表达程序的组合。

好记性不如烂笔头,有时间将JS函数式编程,在JS方面毕竟有限,如果真要学习好函数式编程,建议学习下Haskell,本文就是将关于JS方面知识点尽可能总结全面。

  • 柯里化
  • 偏应用
  • 组合与管道
  • 函子
  • Monad

1. 柯里化

  • 什么是柯里化呢?

柯里化是把一个多参数函数转化为一个嵌套的一元函数的过程。下面我们用介绍柯里化时候很多文章都会使用的例子,加法例子(bad smile)。

// 原始版本
const add = (x,y) => x + y;

// ES6 柯里化版本
const addCurried = x => y => x + y;

你没有看错,就是这么简单,柯里化就是将之前传入的多参数变为传入单参数,解释下,柯里化版本,其实当传入一个参数addCurried(1)时,实际会返回一个函数 y=>1+y,实际上是将add函数转化为含有嵌套的一元函数的addCurried函数。如果要调用柯里化版本,应该使用addCurried(1)(2)方式进行调用 会达到和add(1,2)一样的效果,n 个连续箭头组成的函数实际上就是柯里化了 n - 1次,前 n - 1 次调用,其实是提前将参数传递进去,并没有调用最内层函数体,最后一次调用才会调用最内层函数体,并返回最内层函数体的返回值。

看到这里感觉是不是很熟悉,没错,React 中间件。

以上是通过ES6箭头函数实现的,下面我们构建curryFn来实现这个过程。

此函数应该比较容易理解,比较函数参数以及参数列表的长度,递归调用合并参数,当参数都为3,不满足,调用fn.apply(null, args)。

例子: 使用以上的curryFn 数组元素平方函数式写法。

const curryFn = (fn) => {
if(typeof fn !== 'function'){
throw Error ('Not Function');
}
return function curriedFn(...args){
if(args.length < fn.length){
return function(){
return curriedFn.apply(null, args.concat(
[].slice.call(arguments)
))
}
}
return fn.apply(null, args);
}
}
const map = (fn, arr) => arr.map(fn);
const square = (x) => x * x;
const squareFn = curryFn(map)(square)([1,2,3])

从上例子可以观察出curryFn函数应用参数顺序是从左到右。如果想从右到左,下面一会会介绍。

2. 偏应用

上面柯里化我们介绍了我们对于传入多个参数变量的情况,如何处理参数关系,实际开发中存在一种情况,写一个方法,有些参数是固定不变的,即我们需要部分更改参数,不同于柯里化得全部应用参数。

const partial = function (fn, ...partialArgs) {
let args = partialArgs;
return function(...fullArguments) {
let arg = 0;
for (let i = 0; i < args.length && arg < fullArguments.length; i++) {
if (args[i] === null) {
args[i] = fullArguments[arg++];
}
}
return fn.apply(null, args)
}
}
partial(JSON.stringify,null,null,2)({foo: 'bar', bar: 'foo'})


应用起来 2 这个参数是不变的,相当于常量。简单解释下这个函数,args指向 [null, null, 2], fullArguments指向 [{foo:'bar', bar:'foo'}] ,当i==0时候 ,这样 args[0] ==fullArguments[0],所以args就为[{foo:'bar', bar:'foo'},null,2],然后调用,fn.apply(null, args)。

3. 组合与管道

组合

组合与管道的概念来源于Unix,它提倡的概念大概就是每个程序的输出应该是另一个未知程序的输入。我们应该实现的是不应该创建新函数就可以通过compose一些纯函数解决问题。

  • 双函数情况
const compose = (a, b) => c => a(b(c))

我们来应用下:

const toNumber = (num) => Number(num);
const toRound = (num)=> Math.round(num);
// 使用compose
number = compose(toRound,toNumber)('4.67'); // 5
  • 多函数情况

我们重写上面例子测试:

const compose = (...fns) => (value) => fns.reverse().reduce((acc, fn) => fn(acc), value)
const toNumber = (num) => Number(num);
const toRound = (num)=> Math.round(num);
const toString = (num) => num.toString();
number = compose(toString,toRound,toNumber)('4.67'); // 字符串 '5'

从上面多参数以及双参数情况,我们可以得出compose的数据流是从右到左的。那有没有一种数据流是从左到右的,答案是有的就是下面我们要介绍的管道。

管道

管道我们一般称为pipe函数,与compose函数相同,只不过是修改了数据流流向而已。

const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);
const toNumber = (num) => Number(num);
const toRound = (num)=> Math.round(num);
const toString = (num) => num.toString();
number = compose(toString,toRound,toNumber)('4.67'); // 数字 5

4. 函子

函子(Functor)即用一种纯函数的方式帮我们处理异常错误,它是一个普通对象,并且实现了map函数,在遍历每个对象值得时候生成一个新对象。我们来看几个实用些的函子。

  • MayBe 函子
  • // MayBe 函数定义
    const MayBe = function (val) {
    this.value = val;
    }
    MayBe.of = function (val) {
    return new MayBe(val);
    }
    // MayBe map 函数定义
    MayBe.prototype.isNothing = function () {
    return (this.value === null || this.value === underfind)
    }
    MayBe.prototype.map = function (fn) {
    return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this.value));
    }


MayBe并不关心参数是否为null或者underfind,因为它已经被MayBe函子抽象出来了,代码不会因为null或者underfind崩溃,可以看出,通过函子我们不需要关系那些特殊情况下的判断,程序也不会以为的崩溃。

另外一点是,当都多个map链式调用时,如果第一个map参数是null或者underfind,并不会影响到第二个map正常运行,也就是说,任何map的链式调用都会调用到。

MayBe.of('abc').map((x)=>x.toUpperCase()) // MayBe { value: 'ABC' }

// 参数为null
MayBe.of(null).map((x)=>x.toUpperCase()) // MayBe { value: null }

// 链式调用中第一个参数为null
MayBe.of('abc').map(()=>null).map((x)=> 'start' + x) // MayBe { value: null }


  • Either函子

Either函子主要解决的是MayBe函子在执行失败时不能判断哪一只分支出问题而出现的,主要解决的分支扩展的问题。

我们实现一下Either函子:

const Nothing = function (val) {
this.value = val;
}
Nothing.of = function (val) {
return new Nothing(val);
}
Nothing.prototype.map = function (f) {
return this;
}
const Some = function(val){
this.value = val;
}
Some.of = function(val) {
this.value = val;
}
Some.prototype.map = function(fn) {
return Some.of(fn(this.value))
}
const Either = {
Some: Some,
Nothing: Nothing
}


实现包含两个函数,Nothing函数只返回函数自身,Some则会执行map部分,在实际应用中,可以将错误处理使用Nothing,需要执行使用Some,这样就可以分辨出分支出现的问题。

5. Monad

Monad应该是这几个中最难理解的概念了,因为本人也没有学过Haskell,所以也可能对Monad理解不是很准确,所以犹豫要不要写出来,打算学习Haskell,好吧,先记录下自己理解,永远不做无病呻吟,有自己感触与理解才会记录,学过之后再次补充。

Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤。你只要提供下一步运算所需的函数,整个运算就会自动进行下去。那么构成Monad 组成条件有哪些呢?

  • 类型构造器,因为Monad实际处理的是数据类型,而不是值,必须有一个类型构造器,这个类型构造器的作用就是如何从指定类型构造新的一元类型,比如Maybe<number>,定义Maybe<number>了基础类型的类型number,我们月可以把这种类型构造器理解为封装了一个值,这个值既可以是用数据结构进行封装,也可以使用函数,通过返回值表达封装的值,一般也说Monad是一个“未计算的值”、“包含在上下文(context)中的值”。
  • 提升函数。这个提升函数一般指的是return或者unit,说白了,提升函数就是将一个值封装进了Monad这个数据结构中,签名为 return :: a -> M a 。将unit基础类型的值包装到monad中的函数。对于Maybe monad,它将2类型number的值包装到类型的值Maybe(2)Maybe<number>。
  • 绑定函数bind。绑定函数就像一个管道,它解封一个Monad,将里面的值传到第二个参数表示的函数,生成另一个Monad。形式化定义为[公式](ma 为类型为[公式]的 Monad 实例,[公式]是转换函数)。此bind功能是不一样的Function.prototype.bind 功能。它用于创建具有绑定this值的部分应用函数或函数。

就像一个盒子一样,放进盒子里面(提升函数),从盒子里面取出来(绑定函数),放进另外一个盒子里面(提升函数),本身这个盒子就是类型构造器。

举一个常用的例子,这也是Monad for functional programming,里面除法的例子,实现一个求值函数evaluate,它可以接收类似[公式]

function evaluate(e: Expr): Maybe<number> {
if (e.type === 'value') return Maybe.just(<number>e.value);

return evaluate((<DivisionExpr>e.value).left)
.bind(left => evaluate((<DivisionExpr>e.value).right)
.bind(right => safeDiv(left, right)));
}

在像JavaScript这样的面向对象语言中,unit函数可以表示为构造函数,函数可以表示为bind实例方法。

还有三个遵守的monadic法则:

  1. bind(unit(x), f) ≡ f(x)
  2. bind(munit) ≡ m
  3. bind(bind(mf), g) ≡ bind(mx ⇒ bind(f(x), g))
  4. const unit = (value: number) => Maybe.just<number>(value);
    const f = (value: number) => Maybe.just<number>(value * 2);
    const g = (value: number) => Maybe.just<number>(value - 5);
    const ma = Maybe.just<number>(13);
    const assertEqual = (x: Maybe<number>, y: Maybe<number>) => x.value === y.value;

    // first law
    assertEqual(unit(5).bind(f), f(5));

    // second law
    assertEqual(ma.bind(unit), ma);

    // third law
    assertEqual(ma.bind(f).bind(g), ma.bind(value => f(value).bind(g)));


前两个说这unit是一个中性元素。第三个说bind应该是关联的 - 绑定的顺序无关紧要。这是添加具有的相同属性:(8 + 4) + 2与...相同8 + (4 + 2)。

举几个比较常见的Monad:

1. Promise Monad

没有想到吧,你平时使用的Promise就是高大上的Monad,它是如何体现的这三个特性呢?

  • 类型构造器就是Promise
  • unit提升函数 为x => Promise.resolve(x)
  • 绑定函数 为Promise.prototype.then
fetch('xxx')
.then(response => response.json())
.then(o => fetch(`xxxo`))
.then(response => response.json())
.then(v => console.log(v));

最简单的 P(A).then(B) 实现里,它的 P(A) 相当于 Monad 中的 unit 接口,能够把任意值包装到 Monad 容器里。支持嵌套的 Promise 实现中,它的 then 背后其实是 FP 中的 join 概念,在容器里还装着容器的时候,递归地把内层容器拆开,返回最底层装着的值。Promise 的链式调用背后,其实是 Monad 中的 bind 概念。你可以扁平地串联一堆 .then(),往里传入各种函数,Promise 能够帮你抹平同步和异步的差异,把这些函数逐个应用到容器里的值上。回归这节中最原始的问题,Monad 是什么呢?只要满足以上三个条件,我们就可以认为它是 Monad 了:正如我们已经看到的,Promise.resolve() 能够把任意值包装到 Promise 里,而 Promise/A+ 规范里的 Resolve 算法则实际上实现了 bind。因此,我们可以认为:Promise 就是一个 Monad。

2. Continuation Monad

continuation monad用于异步任务。幸运的是,ES6没有必要实现它 - Prmise对象是这个monad的一个实现。

  • Promise.resolve(value)包装一个值并返回一个promise(unit函数)。
  • Promise.prototype.then(onFullfill: value => Promise)将一个值转换为另一个promise并返回一个promise(bind函数)的函数作为参数。

Promise为基本的continuation monad提供了几个扩展。如果then返回一个简单的值(而不是一个promise对象), 他将被视为Promise,解析为该值 自动将一个值包装在monad中。

第二个区别在于错误传播。Continuation monad允许在计算步骤之间仅传递一个值。另一方面,Promise有两个不同的值 - 一个用于成功值,一个用于错误(类似于Either monad)。可以使用方法的第二个回调then或使用特殊。catch方法捕获错误。

下面定义了一个简单的Monad类型,它单纯封装了一个值作为value属性:

var Monad = function (v) {
this.value = v;
return this;
};

Monad.prototype.bind = function (f) {
return f(this.value)
};

var lift = function (v) {
return new Monad(v);
};

我们将一个除以2的函数应用的这个Monad:

console.log(lift(32).bind(function (a) {
return lift(a/2);
}));

// > Monad { value: 16 }

连续应用除以2的函数:

// 方便展示用的辅助函数,请忽视它是个有副作用的函数。
var print = function (a) {
console.log(a);
return lift(a);
};

var half = function (a) {
return lift(a/2);
};

lift(32)
.bind(half)
.bind(print)
.bind(half)
.bind(print);

//output:
// > 16
// > 8


收起阅读 »

Vue路由懒加载

vue
Vue路由懒加载对于SPA单页应用,当打包构建时,JavaScript包会变得非常大,影响页面加载速度,将不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,这就是路由的懒加载。实现方式#Vue异步组件#Vue允许以一个工厂函数的方式定...
继续阅读 »

Vue路由懒加载

对于SPA单页应用,当打包构建时,JavaScript包会变得非常大,影响页面加载速度,将不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,这就是路由的懒加载。

实现方式#

Vue异步组件#

Vue允许以一个工厂函数的方式定义你的组件,这个工厂函数会异步解析你的组件定义。Vue只有在这个组件需要被渲染的时候才会触发该工厂函数,且会把结果缓存起来供未来重渲染。

Vue.component("async-example", function (resolve, reject) {
setTimeout(function() {
// 向 `resolve` 回调传递组件定义
resolve({
template: "
I am async!
"
})
}, 1000)
})


这个工厂函数会收到一个resolve回调,这个回调函数会在你从服务器得到组件定义的时候被调用,当然也可以调用reject(reason)来表示加载失败,此处的setTimeout仅是用来演示异步传递组件定义用。将异步组件和webpackcode-splitting功能一起配合使用可以达到懒加载组件的效果。

Vue.component("async-webpack-example", function (resolve) {
// 这个特殊的 `require` 语法将会告诉 webpack
// 自动将你的构建代码切割成多个包,这些包
// 会通过 Ajax 请求加载
require(["./my-async-component"], resolve)
})

也可以在工厂函数中返回一个Promise,把webpack 2ES2015语法加在一起。

Vue.component(
"async-webpack-example",
// 这个动态导入会返回一个 `Promise` 对象。
() => import("./my-async-component")
)


事实上我们在Vue-Router的配置上可以直接结合Vue的异步组件和Webpack的代码分割功能可以实现路由组件的懒加载,打包后每一个组件生成一个js文件。

{
path: "/example",
name: "example",
//打包后,每个组件单独生成一个chunk文件
component: reslove => require(["@/views/example.vue"], resolve)
}

动态import#

Webpack2中,可以使用动态import语法来定义代码分块点split point,官方也是推荐使用这种方法,如果使用的是Bable,需要添加syntax-dynamic-import插件, 才能使Babel可以正确的解析语法。

//默认将每个组件,单独打包成一个js文件
const example = () => import("@/views/example.vue")

有时我们想把某个路由下的所有组件都打包在同一个异步块chunk中,需要使用命名chunk一个特殊的注释语法来提供chunk name,需要webpack > 2.4

const example1 = () => import(/* webpackChunkName: "Example" */ "@/views/example1.vue")
const example2 = () => import(/* webpackChunkName: "Example" */ "@/views/example2.vue");

事实上我们在Vue-Router的配置上可以直接定义懒加载。

{
path: "/example",
name: "example",
//打包后,每个组件单独生成一个chunk文件
component: () => import("@/views/example.vue")
}

webpack提供的require.ensure#

使用webpackrequire.ensure,也可以实现按需加载,同样多个路由指定相同的chunkName也会合并打包成一个js文件。

// require.ensure(dependencies: String[], callback: function(require), chunkName: String)
{
path: "/example1",
name: "example1",
component: resolve => require.ensure([], () => resolve(require("@/views/example1.vue")), "Example")
},
{
path: "/example2",
name: "example2",
component: resolve => require.ensure([], () => resolve(require("@/views/example2.vue")), "Example")
}




收起阅读 »

iOS MachO文件

目标文件.aFramework可执行文件.dsym1.2.1 .out、可执行文件test.c文件,内容如下:#include int main() { printf("test\n"); return 0; }验证不指定默认生成...
继续阅读 »

一、MachO文件概述


Mach-O(Mach Object)是mac以及iOS上的格式, 类似于windows上的PE格式 (Portable Executable ),linux上的elf格式 (Executable and Linking Format)。

Mach-O是一种用于可执行文件目标代码动态库的文件格式。作为a.out格式的替代,Mach-O提供了更强的扩展性。


1.1 MachO格式的常见文件

  • 目标文件.o
  • 库文件
  • .a
  • .dylib
  • Framework
  • 可执行文件
  • dyld
  • .dsym

1.2 格式验证

1.2.1 .o.out、可执行文件

新建test.c文件,内容如下:

#include 

int main() {
printf("test\n");
return 0;
}

验证.o文件:

clang -c  test.c
//clang -c test.c -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk
不指定-c默认生成a.out,如果报找不到'stdio.h' file not found,则可以指定-isysroot。文章最后有具体的解决方案,
通过file指令查看文件格式:

file test.o
test.o: Mach-O 64-bit object x86_64

验证a.out可执行文件:

clang test.o
file a.out
a.out: Mach-O 64-bit executable x86_64
./a.out
test

验证可执行文件:

clang -o test2 test.c 
file test2
test2: Mach-O 64-bit executable x86_64
./test2
test

至此再生成一个test3可执行文件:

clang -o test3 test.o

那么生成的a.outtest2test3一样么?



可以看到生成的可执行文件md5相同。



⚠️原则上test3md5应该和test2a.out相同。源码没有变化,所以应该相同的。在指定-isysroot后生成的可能不同,推测和CommandLineTools有关(系统中一个,Xcode中一个)。

再创建一个test1.c文件,内容如下:


#include 

void test1Func() {
printf("test1 func \n");
}

修改test.c:

#include 

void test1Func();

int main() {
test1Func();
printf("test\n");
return 0;
}

这个时候相当于有多个文件了,编译生成可执行文件demodemo1demo2:


clang -o demo  test1.c test.c 
clang -c test1.c test.c
clang -o demo1 test.o test1.o
clang -o demo2 test1.o test.o



这里demo1demo2``md5不同是因为test.otest1.o顺序不同。

objdump --macho -d demo查看下macho:



这也就解释了md5不同的原因。这里很像XcodeBuild Phases -> Compile Sources中源文件的顺序。

⚠️源文件顺序不同,编译出来的二进制文件不同( 大小相同),二进制排列顺序不同。


1.2.2.a文件

直接创建一个library库查看:

//find /usr -name "*.a"
file libTestLibrary.a
libTestLibrary.a: current ar archive random library

1.2.3. .dylib

cd /usr/lib
file dyld
dyld: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit dynamic linker x86_64] [i386:Mach-O dynamic linker i386]
dyld (for architecture x86_64): Mach-O 64-bit dynamic linker x86_64
dyld (for architecture i386): Mach-O dynamic linker i386

1.2.4 dyld

cd /usr/lib
file dyld
dyld: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit dynamic linker x86_64] [i386:Mach-O dynamic linker i386]
dyld (for architecture x86_64): Mach-O 64-bit dynamic linker x86_64
dyld (for architecture i386): Mach-O dynamic linker i386

这里需要注意的是dyld不是可执行文件,是一个dynamic linker。系统内核触发。

1.2.5 .dsym

file TestDsym.app.dSYM
TestDsym.app.dSYM: directory

cd TestDsym.app.dSYM/Contents/Resources/DWARF

file TestDsym
TestDsym: Mach-O 64-bit dSYM companion file arm64

二、可执行文件

创建一个工程,默认生成的文件就是可执行文件,查看对应的MachO:

file TestDsym
TestDsym: Mach-O 64-bit executable arm64
可以看到是一个单一架构的可执行文件(⚠️11以上的系统都只支持64位架构,所以默认就没有32位的)。将Deployment Info改为iOS 10编译再次查看MachO

file TestDsym
TestDsym: Mach-O universal binary with 2 architectures: [arm_v7:Mach-O executable arm_v7] [arm64:Mach-O 64-bit executable arm64]
TestDsym (for architecture armv7): Mach-O executable arm_v7
TestDsym (for architecture arm64): Mach-O 64-bit executable arm64

这个时候就有多个架构了。
当然也可以在Xcode中直观的看到支持的架构:



  • Architectures:支持的架构。
  • Build Active Architecture Only:默认情况下debug模式下只编译当前设备架构,release模式下需要根据支持的设备。
  • $(ARCHS_STANDARD):环境变量,代表当前支持的架构。

如果我们要修改架构直接在Architectures中配置(增加armv7s):



编译再次查看MachO:

file TestDsym
TestDsym: Mach-O universal binary with 3 architectures: [arm_v7:Mach-O executable arm_v7] [arm_v7s:Mach-O executable arm_v7s] [arm64:Mach-O 64-bit executable arm64]
TestDsym (for architecture armv7): Mach-O executable arm_v7
TestDsym (for architecture armv7s): Mach-O executable arm_v7s
TestDsym (for architecture arm64): Mach-O 64-bit executable arm64

2.1通用二进制文件(Universal binary


  • 苹果公司提出的一种程序代码,能同时适用多种架构的二进制文件。
  • 同一个程序包中同时为多种架构提供最理想的性能。
  • 因为需要储存多种代码,通用二进制应用程序通常比单一平台二进制的程序要大。
  • 由于多种架构有共同的非执行资源(代码以外的),所以并不会达到单一版本的多倍之多(特殊情况下,只有少量代码文件的情况下有可能会大于多倍)。
  • 由于执行中只调用一部分代码,运行起来不需要额外的内存。


当我们将通用二进制文件拖入Hopper时,能够看到让我们选择对应的架构:



2.2lipo命令

lipo是管理Fat File的工具,可以查看cpu架构,,提取特定架构,整合和拆分库文件。

使用lipo -info 可以查看MachO文件包含的架构
lipo -info MachO文件


lipo -info TestDsym
Architectures in the fat file: TestDsym are: armv7 armv7s arm64
使用lifo –thin 拆分某种架构
lipo MachO文件 –thin 架构 –output 输出文件路径

lipo TestDsym -thin armv7 -output macho_armv7
lipo TestDsym -thin arm64 -output macho_arm64
file macho_armv7
macho_armv7: Mach-O executable arm_v7
file macho_arm64
macho_arm64: Mach-O 64-bit executable arm64

使用lipo -create 合并多种架构
lipo -create MachO1 MachO2 -output 输出文件路径

lipo -create macho_armv7 macho_arm64 -output  macho_v7_64

file macho_v7_64
macho_v7_64: Mach-O universal binary with 2 architectures: [arm_v7:Mach-O executable arm_v7] [arm64:Mach-O 64-bit executable arm64]
macho_v7_64 (for architecture armv7): Mach-O executable arm_v7
macho_v7_64 (for architecture arm64): Mach-O 64-bit executable arm64

三、MachO文件结构


Mach-O 的组成结构如图所示:

  • Header:包含该二进制文件的一般信息。

    • 字节顺序、架构类型、加载指令的数量等。
    • 快速确认一些信息,比如当前文件用于32位还是64位,对应的处理器是什么、文件类型是什么。
  • Load Commands:一张包含很多内容的表。

    • 内容包括区域的位置、符号表、动态符号表等。
  • Data:通常是对象文件中最大的部分。

    • 包含Segement的具体数据

通用二进制文件就是包含多个这种结构。

otool -f MachO文件查看Header信息:


otool -f TestDsym

Fat headers
fat_magic 0xcafebabe
nfat_arch 3
architecture 0
cputype 12
cpusubtype 9
capabilities 0x0
offset 16384
size 79040
align 2^14 (16384)
architecture 1
cputype 12
cpusubtype 11
capabilities 0x0
offset 98304
size 79040
align 2^14 (16384)
architecture 2
cputype 16777228
cpusubtype 0
capabilities 0x0
offset 180224
size 79760
align 2^14 (16384)

分析MachO最好的工具就是 MachOView了:



otool的内容相同,对于多架构MachO会有一个Fat Header其中包含了CPU类型和架构。OffsetSize代表了每一个每一个架构在二进制文件中的偏移和大小。

这里有个问题是16384+79040 = 95424 < 9830498304 - 16384 = 8192081920 / 4096 / 4 = 5,可以验证这里是以页对齐的。(iOS中一页16KMachO中都是以页为单位对齐的,这也就是为什么能在Load Commands中插入LC_LOAD_DYLIB的原因。)。

MachO对应结构如下:




3.1Header

Header数据结构:



对应dyld的定义如下(loader.h):

struct mach_header {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
};

struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};
  • magic:魔数,快速定位属于64位还是32位。
  • cputypeCPU类型,比如ARM
  • cpusubtypeCPU具体类型,arm64armv7
  • filetype:文件类型,比如可执行文件,具体包含类型如下:

  • #define MH_OBJECT   0x1     /* relocatable object file */
    #define MH_EXECUTE 0x2 /* demand paged executable file */
    #define MH_FVMLIB 0x3 /* fixed VM shared library file */
    #define MH_CORE 0x4 /* core file */
    #define MH_PRELOAD 0x5 /* preloaded executable file */
    #define MH_DYLIB 0x6 /* dynamically bound shared library */
    #define MH_DYLINKER 0x7 /* dynamic link editor */
    #define MH_BUNDLE 0x8 /* dynamically bound bundle file */
    #define MH_DYLIB_STUB 0x9 /* shared library stub for static
    linking only, no section contents */

    #define MH_DSYM 0xa /* companion file with only debug
    sections */

    #define MH_KEXT_BUNDLE 0xb /* x86_64 kexts */
    #define MH_FILESET 0xc /* a file composed of other Mach-Os to
    be run in the same userspace sharing
    a single linkedit. */

  • ncmdsNumber of Load CommandsLoad Commands条数。
  • sizeofcmdsSize of Load CommandsLoad Commands大小。
  • flags:标识二进制文件支持的功能,主要是和系统加载、链接有关。
  • reservedarm64特有,保留字段。

  • 3.2 LoadCommands


    Load Commands指示dyld如何加载二进制文件。
    一个基本的Load Comands如下:




    空指针陷阱,目的是为了和32位指令完全分开。(32位地址在4G以下,64位地址大于4G 0xffffffff = 4G)。
    __PAGEZERO不占用数据(file size0),唯一有的是VM Sizearm64 4Garmv7比较小)。

    VM Addr : 虚拟内存地址
    VM Size: 虚拟内存大小。运行时刻在内存中的大小,一般情况下和File size相同,__PAGEZERO例外。
    File offset:数据在文件中偏移量。
    File size: 数据在文件中的大小。
    我们定位是看VM Addr + ASLR

  • __TEXT__DATA__LINKEDIT:将文件中(32位/64位)的段映射到进程地址空间中。
    分为三大块,分别对应DATA中的Section__TEXT + __DATA)、__LINKEDIT。告诉dyld占用多大空间。

  • LC_DYLD_INFO_ONLY:动态链接相关信息。




  • Rebase:重定向(ASLR)偏移地址和大小。从Rebase Info Offset + ASLR开始加载336个字节数据。
    Binding:绑定外部符号。
    Weak Binding:弱绑定。
    Lazy Binding:懒绑定,用到的时候再绑定。
    Export info:对外开放的函数。

  • LC_SYMTAB:符号表地址。





  • LC_DSYMTAB:动态符号表地址。


    LC_LOAD_DYLINKER:使用何种动态加载器。iOS使用的是dyld







    • LC_FUNCTION_DYLIB:函数起始地址表。

    • LC_DATA_IN_CODE:定义在代码段内的非指令的表。

    • LC_DATA_SIGNATURE:代码签名。


    3.3Data

    Data包含Section__TEXT + __DATA)、__LINKEDIT

    3.3.1__TEXT



    __TEXT代码段,就是我们的代码。

    • __text:主程序代码。开始是代码起始位置,和Compile Sources中文件顺序有关。

    __stubs & __stub_helper:用于符号绑定。



    这里65a0就是325a0,这里是循环做符号绑定。

    • __objc_methname:方法名称

    • __objc_classname:类名称

    • __objc_methtype:方法类型

    • __cstring:字符串常量


    3.3.2__DATA


    __DATA数据段。

    • __got & __la_symbol_ptr:外部符号有两张表Non-LazyLazy


    Lazy懒加载表,表中的指针一开始都指向 __stub_helper


    • __cfstring:程序中使用的 Core Foundation 字符串(CFStringRefs)。

    • __objc_classlist:类列表。

    • __objc_protolist: 原型。

    • __objc_imageinfo:镜像信息

    • __objc_selrefsself 引用

    • __objc_classrefs:类引用

    • __objc_superrefs:超类引用

    • __data:初始化过的可变数据。


    3.3.3 __LINKEDIT

  • Dynamic Loader Info:动态加载信息

  • Function Starts:入口函数

  • Symbol Table:符号表

  • Dynamic Symbol Table:动态库符号表

  • String Table:字符串表

  • Code Signature:代码签名


  • 总结

  • MachO属于一种文件格式。
    • 包含:可执行文件、静态库、动态库、dyld等。
    • 可执行文件:
      • 通用二进制文件(Fat):集成了多种架构。
      • lipo命令:-thin拆分架构,-creat合并架构。
  • MachO结构:
    • Header:快速确定该文件的CPU类型,文件类型等。
    • Load Commands:知识加载器(dyld)如何设置并加载二进制数据。
    • Data:存放数据,代码、数据、字符串常量、类、方法等。


  • 作者:HotPotCat
    链接:https://www.jianshu.com/p/9f6955575213


    收起阅读 »

    iOS面试可以怼HR的点-应用重签名

    首先理解一件事:签名是可以被替换的。签名:原始数据->hash->加密重签名:原始数据->hash->加密这也就是签名可以被替换的原因。一、codesign重签名codesign安装Xcode就有,Xcode也是用的这个工具。签名包含:...
    继续阅读 »

    首先理解一件事:签名是可以被替换的。
    签名:原始数据->hash->加密
    重签名:原始数据->hash->加密
    这也就是签名可以被替换的原因。


    一、codesign重签名

    codesign安装Xcode就有,Xcode也是用的这个工具。
    签名包含:
    资源文件
    macho文件
    framework
    ...


    1.1终端命令

    1.1.1查看签名信息

    codesign -vv -d xxx.app


    1.1.2列出钥匙串里可签名的证书

    security find-identity -v -p codesigning


    1.1.3otool分析macho文件信息并导出到指定文件

    otool -l xxx > ~/Desktop/machoMessage.txt

    其中cryptid0表示没有用到加密算法(也就是脱壳的), 其它则表示加密。



    也可以直接过滤查看是否砸壳:

    otool -l xxx | grep cryptid

    1.1.4强制替换签名


    codesign –fs “证书串” 文件名

    codesign -fs "Apple Development: xxx@qq.com (9AN9M5S786)" andromeda.framework

    1.1.5给文件添加权限

    chmod +x 可执行文件

    1.1.6查看描述文件


    security cms -D -i ../embedded.mobileprovision

    1.1.7macho签名

    codesign -fs “证书串” --no-strict --entitlements=权限文件.plist APP包

    1.1.8将输入文件压缩为输出文件

    zip –ry 输出文件 输入文件 

    1.1.9越狱的手机dump app 包

    // 建立连接
    sh usbConnect.sh
    //连接手机
    sh usbX.sh
    //查看进程
    ps -A
    //找到微信进程,拿到路径
    ps -A | grep WeChat
    //进入目标文件夹拷贝微信(这里是没有砸壳的)
    scp -r -p 12345 root@localhost:微信路径 ./

    1.2codesign命令重签名


    这里以砸过壳的微信(7.0.8)为例,使用免费开发者账号重签名微信,然后安装到非越狱手机上。

    1. 解压缩.ipa包,Payload中找到.app显示包内容。
      ⚠️由于免费证书没有办法签名PlugInsWatch,直接将这两个文件夹删除。

    2. 签名Frameworks
      逐个签名Frameworks目录下的framework(使用自己本机的免费证书)


    codesign -fs "Apple Development: xxx@qq.com (9AN9M5S786)" andromeda.framework



    1. 确保要签名的appmacho文件的可执行权限
    ➜  WeChat.app ls -l WeChat
    -rwxr-xr-x@ 1 zaizai staff 126048560 10 16 2019 WeChat
    4.获取免费账号对应的描述文件

    创建空工程使用免费账号&真机编译获运行取描述文件。


    这个时候描述文件已经拷贝到手机中去了,并且已经信任设备。
    将获取到的描述文件拷贝到 WeChatapp包中。

    5.修改bundleId
    找到WeChat info.plist修改BundleId为我们生成描述文件的BundleId





    1. 获取描述文件的权限
    security cms -D -i embedded.mobileprovision

    找到对应的权限Entitlements:

        <dict>

    <key>application-identifier</key>
    <string>S48J667P47.com.guazai.TestWeChat</string>

    <key>keychain-access-groups</key>
    <array>
    <string>S48J667P47.*</string>
    </array>

    <key>get-task-allow</key>
    <true/>

    <key>com.apple.developer.team-identifier</key>
    <string>S48J667P47</string>

    </dict>

    创建一个.plist文件,将权限内容粘贴进去:



    内容如下:

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <!--
    Entitlements.plist
    TestWeChat

    Created by ZP on 2021/4/19.
    Copyright (c) 2021 ___ORGANIZATIONNAME___. All rights reserved.
    -->

    <plist version="1.0">
    <dict>

    <key>application-identifier</key>
    <string>S48J667P47.com.guazai.TestWeChat</string>

    <key>keychain-access-groups</key>
    <array>
    <string>S48J667P47.*</string>
    </array>

    <key>get-task-allow</key>
    <true/>

    <key>com.apple.developer.team-identifier</key>
    <string>S48J667P47</string>

    </dict>
    </plist>

    将权限文件(Entitlements.plist)拷贝到和PayloadWeChat.app同一目录


    1. 签名Wechat
    codesign -fs "Apple Development: xxx@qq.com (9AN9M5S786)" --no-strict --entitlements=entitlements.plist WeChat.app

    这里entitlments参数需要和上一步生成的权限文件名称对应上。




    这个时候通过XcodeWeChat.app包安装到手机就已经能正常安装了。

    通过debug->attach to process->WeChat就可以调试微信了:



    ⚠️这个时候不要用自己的常用账号登录重签名的微信(有可能被封号)。

    重签名步骤:

    1. 删除插件以及带有插件的.app包(如:watch
      PlugInsWatch文件夹
    2. Frameworks中的库重签名
      codesign -fs "Apple Development: xxx@qq.com (9AN9M5S786)" andromeda.framework
    3. 对可执行文件+X(可执行)权限
      chmod +x WeChat
    4. 添加描述文件(创建工程,真机编译得到,并且需要运行将描述文件安装到手机)
    5. 替换info.plist BundleIdBundleId要和描述文件中的一致)
    6. 通过授权文件(entitlments)重签名.app
      a.获取描述文件权限内容security cms -D -i embedded.mobileprovision
      b.将描述文件权限内容拷贝生成plist文件Entitlements.plist
      c.用全线文件签名App包:codesign -fs "Apple Development: xxx@qq.com (9AN9M5S786)" --no-strict --entitlements=entitlements.plist WeChat.app

    二、利用Xcode重签名调试三方应用

    1.新建和微信同名工程WeChat



    2.将空工程运行到真机上。

    3.解压.ipa包,并且删除WatchPlugIns文件夹

    4.重签名Frameworks

    5.修改BundleId

    6.将修改后的WeChat.app替换空工程的Products.app





    7.运行
    这个时候Products工程中有WeChat.appXcode认为有就直接使用这个了。这个时候就可以调试了(不需要attach



    ⚠️在某些系统下会出现启动重签名微信黑屏,建议通过脚本重签名。

    三、SHELL脚本

    shell是一种特殊的交互式工具,它为用户提供了启动程序、管理文件系统中文件以及运行在系统上的进程的途径。Shell一般是指命令行工具。它允许你输入文本命令,然后解释命令,并在内核中执行。
    Shell脚本,也就是用各类命令预先放入到一个文本文件中,方便一次性执行的一个脚本文件。


    脚本切换

    chsh -s /bin/zsh
    执行脚本的几种方式:
    有如下脚本:

    mkdir shell1
    cd shell1
    touch test.txt


  • source FileName  
    作用:在当前shell环境中读取并执行FileName中的命令
    特点:命令可以强行让一个脚本去立即影响当前的环境(一般用于加载配置文件)。
    命令会强制执行脚本中的全部命令,而忽略文件的权限。

  • bash FileNamezsh FileName  
    作用:重新建立一个子shell(进程),在子shell中执行脚本里面的句子。当前环境没有变化。

  • ./FileName
    作用:读取并执行文件中的命令。但有一个前提,脚本文件需要有可执行权限。


  • MAC中shell种类

    cd /private/etc
    cat shells





  • bashmacOS默认shell(老系统),新系统切换为zsh了。
  • csh:被tcsh替换了
  • dash:比bash小很多,效率高。
  • ksh:兼容bash
  • sh:已经被bash替换了
  • tcsh:整合了csh提供了更多功能
  • zsh:替换了bash

  • 四、用户组&文本权限

    UnixLinux都是多用户、多任务的系统,所以这样的系统里面就拥有了用户、组的概念。那么同样文件的权限也就有相应的所属用户和所属组。
    windows不同的是unixlinuxmacOS都是多用户的系统:





    4.1mac文件属性


    4.2权限

    权限有10位:

    drwx-r-xr-x

  • 1位文件类型d/-
    d目录(directory)
    -文件
  • 后面9位,文件权限:
    [r]:read,读
    [w]:write,写
    [x]:execute,执行
    ⚠️这三个权限的位置不会变,依次是rwx。出现-对应的位置代表没有此权限。
    • 一个文件的完整权限分为三组:
      第一组:文件所有者权限
      第二组:这一组其它用户权限
      第三组:非本组用户的权限


    4.3权限改变chmod

    文件权限的改变使用chmod命令。
    设置方法有两种:数字类型改变 和 符号类型改变。
    文件权限分为三种身份:[user][group][other]  
    三个权限:[read] [write] [execute]

    4.3.1数字类型


    各个权限数字对照:r:4(0100)  w:2(0010)  x:1(0001),这么设计的好处是可以按位或。与我们开发中位移枚举同理。
    如果一个文件权限为[!–rwxr-xr-x],则对应:
    User : 4+2+1 = 7
    Group: 4+0+1 = 5
    Other: 4+0+1 = 5
    命令为:chmod 755 文件名

    数字与权限对应表:


    0代表没有任何权限。

    4.3.2符号类型

    chmod [u(User)、g(Group)、o(Other)、a(All)] [+(加入)、-(除去)、=(设置)]  [r、w、x] 文件名称
    例:

    chmod a+x test.txt

    默认是all

    五、通过shell脚本自动重签名

    脚本实现逻辑和codesign逻辑相同。
    完整脚本如下:

    #临时解压目录
    TEMP_PATH="${SRCROOT}/Temp"
    #资源文件夹,我们提前在工程目录下新建一个APP文件夹,里面放ipa包(砸壳后的)
    ASSETS_PATH="${SRCROOT}/APP"
    #目标ipa包路径
    TARGET_IPA_PATH="${ASSETS_PATH}/*.ipa"


    #清空&创建Temp文件夹
    rm -rf TEMP_PATH
    mkdir -p TEMP_PATH


    # 1. 解压IPA到Temp目录下
    unzip -oqq "$TARGET_IPA_PATH" -d "$TEMP_PATH"
    # 拿到解压后的临时的APP的路径
    TEMP_APP_PATH=$(set -- "$TEMP_PATH/Payload/"*.app;echo "$1")

    #2. 将解压出来的.app拷贝进入工程下
    #2.1拿到当前工程目标Target路径
    # BUILT_PRODUCTS_DIR 工程生成的APP包的路径
    # TARGET_NAME target名称
    TARGET_APP_PATH="$BUILT_PRODUCTS_DIR/$TARGET_NAME.app"
    echo "app path:$TARGET_APP_PATH"

    #2.2删除工程本身的Target,将解压的Target拷贝到工程本身的路径
    rm -rf "$TARGET_APP_PATH"
    mkdir -p "$TARGET_APP_PATH"
    cp -rf "$TEMP_APP_PATH/" "$TARGET_APP_PATH"


    # 3. 删除extension和WatchAPP,个人证书没法签名Extention
    rm -rf "$TARGET_APP_PATH/PlugIns"
    rm -rf "$TARGET_APP_PATH/Watch"



    # 4. 更新info.plist文件 CFBundleIdentifier
    # 设置:"Set : KEY Value" "目标文件路径",PlistBuddy是苹果自带的。
    /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $PRODUCT_BUNDLE_IDENTIFIER" "$TARGET_APP_PATH/Info.plist"

    #删除UISupportedDevices设备相关配置(越狱手机dump ipa包需要删除相关配置)
    /usr/libexec/PlistBuddy -c "Delete :UISupportedDevices" "$TARGET_APP_PATH/Info.plist"

    # 5. 给MachO文件上执行权限
    # 拿到MachO文件的名称
    APP_BINARY=`plutil -convert xml1 -o - $TARGET_APP_PATH/Info.plist|grep -A1 Exec|tail -n1|cut -f2 -d\>|cut -f1 -d\<`
    #上可执行权限
    chmod +x "$TARGET_APP_PATH/$APP_BINARY"



    # 6. 重签名第三方 FrameWorks
    TARGET_APP_FRAMEWORKS_PATH="$TARGET_APP_PATH/Frameworks"
    if [ -d "$TARGET_APP_FRAMEWORKS_PATH" ];
    then
    for FRAMEWORK in "$TARGET_APP_FRAMEWORKS_PATH/"*
    do
    #签名 --force --sign 就是-fs
    /usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$FRAMEWORK"
    done
    fi
    使用方式
    1.创建空工程,编译运行空工程至真机上(信任证书)。
    2.将appResign.sh脚本拷贝到工程根目录(要有可执行权限)。
    3.在工程根目录创建APP文件夹,并将微信.ipa拷贝到APP文件夹。
    4.配置脚本




    这个时候就可以调试微信了




    六、如何调试一个任意app?

    6.1获取对应ipa

    使用越狱手机dump ipa

    下载旧版本ipa包可以通过抓取iTunes的下载链接改版本号(后缀是app的版本,直接改版本)

    6.2砸壳

    砸壳后由于是dump越狱手机上的正版包,所以需要将info.plist中支持的设备信息(UISupportedDevices)删除。(当然可以写在脚本中)

    #删除UISupportedDevices设备相关配置(越狱手机dump ipa包需要删除相关配置)
    /usr/libexec/PlistBuddy -c "Delete :UISupportedDevices" "$TARGET_APP_PATH/Info.plist"

    删除完毕保存重新打包ipa

    zip -ry WeChat1.ipa Payload/

    总结

    • 重签名
      • codesign重签名
        • 删除不能签名的文件:Extensionwatch(包含了Extension
        • 重签名Frameworks(里面的库)
        • MachO添加可执行权限
        • 修改Info.plist文件(BundleID
        • 拷贝描述文件(该描述文件要在iOS真机中信任过)
        • 利用描述文件中的权限文件签名整个App
      • Xcode重签名
        • 删除不能签名的文件:Extensionwatch(包含了Extension
        • 重签名Frameworks(里面的库)
        • MachO添加可执行权限
        • 修改Info.plist文件(BundleID
        • App包拷贝进入Xcode工程目录中(剩下的交给Xcode
    • shell
      • 切换shell
        • $chsh -s shell路径
        • 现在macOSshell默认zsh(早期bash
        • 配置文件 zsh:.zshrc  bash:.bash_profile
      • 文件权限&用户组
        • 每个文件都有所属的用户、组、其它
        • 文件权限
          • 归属:用户、组、其它
          • 权限 :写、读、执行
        • 修改权限chmod
          • 数字:r:4 w:2 x:1
            • chmod 751 文件名
              • user4+2+1 = 7
              • group4+0+1 = 5
              • other0+0+1 = 1
          • 字符
            • 归属:u(用户) g(组) o(其它) a(所有)
            • +(添加) -(去掉) =(设置)
            • 默认achmod + x


    作者:HotPotCat
    链接:https://www.jianshu.com/p/ecf3d9957ebd



    收起阅读 »

    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
    来源:简书
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。




    收起阅读 »

    ArcSeekBar for Android 是一个弧形的拖动条进度控件

    ArcSeekBarArcSeekBar for Android 是一个弧形的拖动条进度控件,配置参数完全可定制化。ArcSeekBar 是基于 CircleProgressView 修改而来的库。 但青出于蓝而胜于蓝,所以&nb...
    继续阅读 »


    ArcSeekBar

    ArcSeekBar for Android 是一个弧形的拖动条进度控件,配置参数完全可定制化。

    ArcSeekBar 是基于 CircleProgressView 修改而来的库。 但青出于蓝而胜于蓝,所以 CircleProgressView 的大部分用法,ArcSeekBar基本都支持,而且可配置的参数更细致。

    之所以新造一个ArcSeekBar库,而不直接在CircleProgressView上面直接改,原因是CircleProgressView里面的部分动画效果对于SeekBar并不适用,所以ArcSeekBar是在CircleProgressView的基础上有所删减后,而再进行扩展增强的。 实际还需根据具体的需求而选择适合的。

    Gif 展示

    Image

    ArcSeekBar自定义属性说明(进度默认渐变色)

    属性值类型默认值说明
    arcStrokeWidthdimension12dp画笔描边的宽度
    arcStrokeCapenumROUND画笔的线冒样式
    arcNormalColorcolor#FFC8C8C8弧形正常颜色
    arcProgressColorcolor#FF4FEAAC弧形进度颜色
    arcStartAngleinteger270开始角度,默认十二点钟方向
    arcSweepAngleinteger360扫描角度范围
    arcMaxinteger100进度最大值
    arcProgressinteger0当前进度
    arcDurationinteger500动画时长
    arcLabelTextstring中间的标签文本,默认自动显示百分比
    arcLabelTextColorcolor#FF333333文本字体颜色
    arcLabelTextSizedimension30sp文本字体大小
    arcLabelPaddingTopdimension0dp文本居顶边内间距
    arcLabelPaddingBottomdimension0dp文本居底边内间距
    arcLabelPaddingLeftdimension0dp文本居左边内间距
    arcLabelPaddingRightdimension0dp文本居右边内间距
    arcShowLabelbooleantrue是否显示文本
    arcShowTickbooleantrue是否显示环刻度
    arcTickStrokeWidthdimension10dp刻度描边宽度
    arcTickPaddingdimension2dp环刻度与环间距
    arcTickSplitAngleinteger5刻度间隔的角度大小
    arcBlockAngleinteger1刻度的角度大小
    arcThumbStrokeWidthdimension8dp拖动按钮描边宽度
    arcThumbColorcolor#FFE8D30F拖动按钮颜色
    arcThumbRadiusdimension10dp拖动按钮半径
    arcThumbRadiusEnlargesdimension8dp触摸时按钮半径放大量
    arcShowThumbbooleantrue是否显示拖动按钮
    arcAllowableOffsetsdimension10dp触摸时可偏移距离:偏移量越大,触摸精度越小
    arcEnabledDragbooleantrue是否启用通过拖动改变进度
    arcEnabledSinglebooleantrue是否启用通过点击改变进度

    引入

    Maven:

    <dependency>
    <groupId>com.king.view</groupId>
    <artifactId>arcseekbar</artifactId>
    <version>1.0.2</version>
    <type>pom</type>
    </dependency>

    Gradle:

    implementation 'com.king.view:arcseekbar:1.0.2'

    Lvy:

    <dependency org='com.king.view' name='arcseekbar' rev='1.0.2'>
    <artifact name='$AID' ext='pom'></artifact>
    </dependency>
    如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
    allprojects {
    repositories {
    maven { url 'https://dl.bintray.com/jenly/maven' }
    }
    }

    示例

    布局示例

        <com.king.view.arcseekbar.ArcSeekBar
    android:id="@+id/arcSeekBar"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"/>

    代码示例

        //进度改变监听
    arcSeekBar.setOnChangeListener(listener);
    //设置进度
    arcSeekBar.setProgress(progress);
    //显示进度动画(进度,动画时长)
    arcSeekBar.showAnimation(80,3000);

    更多使用详情,请查看app中的源码使用示例

    代码下载: ArcSeekBar.zip

    收起阅读 »

    ImageViewer for Android 是一个图片查看器

    ImageViewerImageViewer for Android 是一个图片查看器,一般用来查看图片详情或查看大图时使用。引入Maven:<dependency> <groupId>com.king.image</grou...
    继续阅读 »

    ImageViewer

    ImageViewer for Android 是一个图片查看器,一般用来查看图片详情或查看大图时使用。


    引入

    Maven:

    <dependency>
    <groupId>com.king.image</groupId>
    <artifactId>imageviewer</artifactId>
    <version>1.0.2</version>
    <type>pom</type>
    </dependency>

    Gradle:

    implementation 'com.king.image:imageviewer:1.0.2'

    Lvy:

    <dependency org='com.king.image' name='imageviewer' rev='1.0.2'>
    <artifact name='$AID' ext='pom'></artifact>
    </dependency>
    如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
    allprojects {
    repositories {
    //...
    maven { url 'https://dl.bintray.com/jenly/maven' }
    }
    }

    示例

    代码示例

        //图片查看器 - 简单调用

    // data 可以多张图片List或单张图片,支持的类型可以是{@link Uri}, {@code url}, {@code path},{@link File}, {@link DrawableRes resId}…等
    ImageViewer.load(data)//要加载的图片数据,单张或多张
    .imageLoader(new GlideImageLoader())//加载器,imageLoader必须配置,目前内置的有GlideImageLoader或PicassoImageLoader,也可以自己实现
    .start(activity,sharedElement);//activity or fragment, 跳转时的共享元素视图
        //图片查看器

    // data 可以多张图片List或单张图片,支持的类型可以是{@link Uri}, {@code url}, {@code path},{@link File}, {@link DrawableRes resId}…等
    ImageViewer.load(data)//要加载的图片数据,单张或多张
    .selection(position)//当前选中位置,默认:0
    .indicator(true)//是否显示指示器,默认不显示
    .imageLoader(new GlideImageLoader())//加载器,imageLoader必须配置,目前内置的有GlideImageLoader或PicassoImageLoader,也可以自己实现
    .theme(R.style.ImageViewerTheme)//设置主题风格,默认:R.style.ImageViewerTheme
    .orientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)//设置屏幕方向,默认:ActivityInfo.SCREEN_ORIENTATION_BEHIND
    .start(activity,sharedElement);//activity or fragment, 跳转时的共享元素视图

    相关说明

    • 使用 ImageViewer 时,必须配置一个实现的 ImageLoader
    • ImageViewer 一次可以查看多张图片或单张图片,支持的类型可以是 Uri、 url 、 path 、 File、 Drawable、 ImageDataSource 等
    • 目前内置默认实现的 ImageLoader 有和 PicassoImageLoader ,二者选其一即可,如果二者不满足您的需求,您也可以自己实现一个 ImageLoader
    • 为了保证 ImageViewer 体积最小化,和用户更多可能的选择性,并未将 Glide 和 Picasso 打包进 aar

    当您使用了 GlideImageLoader 时,必须依赖 Glide 库。

    当您使用了 PicassoImageLoader 时,必须依赖 Picasso 库。

    更多使用详情,请查看app中的源码使用示例

    ImageViewer.zip

    收起阅读 »

    环信新产品发布|千万级 MQTT 消息服务场景和架构解析

        2021 年随着 5G 商用的快速落地,其高可靠、低时延和大连接等特性将加速解锁下一代社交通信、智能硬件、物联网等多样化应用场景。物联网市场方兴未艾,根据 IDC 数据显示,2020 年全球物联网市场规模为 1.7 万亿美元。根据 ...
    继续阅读 »

        2021 年随着 5G 商用的快速落地,其高可靠、低时延和大连接等特性将加速解锁下一代社交通信、智能硬件、物联网等多样化应用场景。物联网市场方兴未艾,根据 IDC 数据显示,2020 年全球物联网市场规模为 1.7 万亿美元。根据 GSMA 预测,全球物联网连接数会从 2019 年的 120 亿增长至 2025 年的 246 亿,年复合增长率为 17%。同时,2021 年全球 MQTT 代理服务较 2020 年增长 40%,MQTT 作为轻量级、抗弱网、易集成的消息传输协议,将满足人与人之间、设备与人之间、设备与设备之间信令、即时消息等形式的互联网通信需求。

     

        MQTT是一个极其轻量级的发布/订阅消息传输协议,它解除时间与空间耦合,可以在应用内实现推送、通知等功能;它简约、轻量,极小的SDK空间占用,适用于嵌入Android、iOS、RTOS等多端平台;它数据包小、功耗低,适用于低带宽、高延迟或不可靠的网络环境。

     

        环信MQTT消息云的产品定位就是充分发挥MQTT协议优势,为开发者提供应用与应用之间、设备与应用之间、应用与平台之间的消息传输服务。为了让大家更深入了解MQTT协议优势,环信举办本次公开课,届时,我们将线上聆听:

     

    • 环信MQTT消息云核心功能有哪些?与IM的区别是什么?
    • 环信MQTT消息云实时交互服务背后有哪些关键的技术优势?
    • 环信MQTT消息云在不同行业领域有哪些典型的场景解决方案?
    • 环信MQTT消息云未来关注的技术发展趋势有哪些?
    • 环信MQTT如何让开发者更为轻松的应用?

     

    7月14日,环信新产品发布直播间,邀请环信MQTT产品经理、中科宏一教育集团Android主管,将围绕以上议题线上展开分享,我们直播间不见不散!


     

     

    从MQTT技术干货分享,到应用实践,再到行业最新案例实践……更多精彩值得期待!

    扫描上图二维码或点击链接即可报名参加。

    报名链接:https://mudu.tv/live/watch?id=o025ayrm

    收起阅读 »

    BaseUrlManager for Android 的设计初衷主要用于开发时,有多个环境需要打包APK的场景

    BaseUrlManagerBaseUrlManager for Android 的设计初衷主要用于开发时,有多个环境需要打包APK的场景,通过BaseUrlManager提供的BaseUrl动态设置入口,只需打一 次包,即可轻松随意的切换不同的开发环境或测试...
    继续阅读 »


    BaseUrlManager

    BaseUrlManager for Android 的设计初衷主要用于开发时,有多个环境需要打包APK的场景,通过BaseUrlManager提供的BaseUrl动态设置入口,只需打一 次包,即可轻松随意的切换不同的开发环境或测试环境。在打生产环境包时,关闭BaseUrl动态设置入口即可。

    妈妈再也不用担心因环境不同需要打多个包的问题,从此告别环境不同要写一堆配置的烦恼,真香。

    配合 RetrofitHelper 动态改变BaseUrl一起使用更香。

    Gif 展示

    Image

    引入

    Maven:

    <dependency>
    <groupId>com.king.base</groupId>
    <artifactId>base-url-manager</artifactId>
    <version>1.1.1</version>
    <type>pom</type>
    </dependency>

    Gradle:


    //AndroidX 版本
    implementation 'com.king.base:base-url-manager:1.1.1'

    //-----------------------v1.0.x以前的版本
    //AndroidX 版本
    implementation 'com.king.base:base-url-manager:1.0.1-androidx'

    //Android Support 版本
    implementation 'com.king.base:base-url-manager:1.0.1'

    Lvy:

    <dependency org='com.king.base' name='base-url-manager' rev='1.1.1'>
    <artifact name='$AID' ext='pom'></artifact>
    </dependency>
    如果Gradle出现implementation失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来implementation)
    allprojects {
    repositories {
    maven { url 'https://dl.bintray.com/jenly/maven' }
    }
    }

    示例

    集成步骤代码示例 (示例出自于app中)

    Step.1 在您项目中的AndroidManifest.xml中通过配置meta-data来自定义全局配置

        <!-- 在你项目中添加注册如下配置 -->
    <activity android:name="com.king.base.baseurlmanager.BaseUrlManagerActivity"
    android:screenOrientation="portrait"
    android:theme="@style/BaseUrlManagerTheme"/>

    Step.2 在您项目Application的onCreate方法中初始化BaseUrlManager

        //获取BaseUrlManager实例(适用于v1.1.x版本)
    mBaseUrlManager = BaseUrlManager.getInstance();

    //获取BaseUrlManager实例(适用于v1.0.x旧版本)
    mBaseUrlManager = new BaseUrlManager(this);

    //获取baseUrl
    String baseUrl = mBaseUrlManager.getBaseUrl();

    Step.3 提供动态配置BaseUrl的入口(通过Intent跳转到BaseUrlManagerActivity界面)

    v.1.1.x 新版本写法

       BaseUrlManager.getInstance().startBaseUrlManager(this,SET_BASE_URL_REQUEST_CODE);

    v1.0.x 以前版本写法

        Intent intent = new Intent(this, BaseUrlManagerActivity.class);
    //BaseUrlManager界面的标题
    //intent.putExtra(BaseUrlManagerActivity.KEY_TITLE,"BaseUrl配置");
    //跳转到BaseUrlManagerActivity界面
    startActivityForResult(intent,SET_BASE_URL_REQUEST_CODE);

    Step.4 当配置改变了baseUrl时,在Activity或Fragment的onActivityResult方法中重新获取baseUrl即可


    //方式1:通过BaseUrlManager获取baseUrl
    String baseUrl = BaseUrlManager.getInstance().getBaseUrl();
    //方式2:通过data直接获取baseUrl
    UrlInfo urlInfo = BaseUrlManager.parseActivityResult(data);
    String baseUrl = urlInfo.getBaseUrl();

    更多使用详情,请查看app中的源码使用示例或直接查看API帮助文档

    BaseUrlManager.zip

    收起阅读 »

    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
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


    收起阅读 »

    KingPlayer 一个专注于 Android 视频播放器(IjkPlayer、ExoPlayer、VlcPlayer、SysPlayer)的基础库

    KingPlayerKingPlayer 一个专注于 Android 视频播放器(IjkPlayer、ExoPlayer、VlcPlayer、SysPlayer)的基础库,无缝切换内核。功能说明 主要播放相关核心功能 播放器无缝切换&nbs...
    继续阅读 »

    KingPlayer

    KingPlayer 一个专注于 Android 视频播放器(IjkPlayer、ExoPlayer、VlcPlayer、SysPlayer)的基础库,无缝切换内核。

    功能说明

    •  主要播放相关核心功能
    •  播放器无缝切换
      •  MediaPlayer封装实现(SysPlayer)
      •  IjkPlayer封装实现
      •  ExoPlayer封装实现
      •  vlc-android封装实现
    •  控制图层相关
      •  待补充...

    Gif 展示

    Image

    录制的gif效果有点不清晰,可以下载App查看详情。

    引入

    gradle:

    使用 SysPlayer (Android自带的MediaPlayer)

    //KingPlayer基础库,内置SysPlayer
    implementation 'com.king.player:king-player:1.0.0-beta1'

    使用 IjkPlayer

    //KingPlayer基础库(必须)
    implementation 'com.king.player:king-player:1.0.0-beta1'
    //IjkPlayer
    implementation 'com.king.player:ijk-player:1.0.0-beta1'

    // 根据您的需求选择ijk模式的so
    implementation 'tv.danmaku.ijk.media:ijkplayer-armv7a:0.8.8'
    // Other ABIs: optional
    implementation 'tv.danmaku.ijk.media:ijkplayer-armv5:0.8.8'
    implementation 'tv.danmaku.ijk.media:ijkplayer-arm64:0.8.8'
    implementation 'tv.danmaku.ijk.media:ijkplayer-x86:0.8.8'
    implementation 'tv.danmaku.ijk.media:ijkplayer-x86_64:0.8.8'

    使用 ExoPlayer

    //KingPlayer基础库(必须)
    implementation 'com.king.player:king-player:1.0.0-beta1'
    //ExoPlayer
    implementation 'com.king.player:exo-player:1.0.0-beta1'

    使用 VlcPlayer

    //KingPlayer基础库(必须)
    implementation 'com.king.player:king-player:1.0.0-beta1'
    //VlcPlayer
    implementation 'com.king.player:vlc-player:1.0.0-beta1'

    示例

    布局示例

        <com.king.player.kingplayer.view.VideoView
    android:id="@+id/videoView"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

    代码示例

            //初始化一个视频播放器(IjkPlayer、ExoPlayer、VlcPlayer、SysPlayer)
    videoView.player = IjkPlayer(context)
    //初始化数据源
    val dataSource = DataSource(url)
    videoView.setDataSource(dataSource)

    videoView.setOnSurfaceListener(object : VideoView.OnSurfaceListener {
    override fun onSurfaceCreated(surface: Surface, width: Int, height: Int) {
    LogUtils.d("onSurfaceCreated: $width * $height")
    videoView.start()
    }

    override fun onSurfaceSizeChanged(surface: Surface, width: Int, height: Int) {
    LogUtils.d("onSurfaceSizeChanged: $width * $height")
    }

    override fun onSurfaceDestroyed(surface: Surface) {
    LogUtils.d("onSurfaceDestroyed")
    }

    })

    //缓冲更新监听
    videoView.setOnBufferingUpdateListener {
    LogUtils.d("buffering: $it")
    }
    //播放事件监听
    videoView.setOnPlayerEventListener { event, bundle ->

    }
    //错误事件监听
    videoView.setOnErrorListener { event, bundle ->

    }


            
    //------------ 控制相关
    //开始
    videoView.start()
    //暂停
    videoView.pause()
    //进度调整到指定位置
    videoView.seekTo(pos)
    //停止
    videoView.stop()
    //释放
    videoView.release()
    //重置
    videoView.reset()

    更多使用详情,请查看app中的源码使用示例或直接查看API帮助文档

    其他

    需使用JDK8+编译,在你项目中的build.gradle的android{}中添加配置:

    compileOptions {
    targetCompatibility JavaVersion.VERSION_1_8
    sourceCompatibility JavaVersion.VERSION_1_8
    }

    代码下载:KingPlayer.zip

    收起阅读 »

    KingKeyboard for Android 是一个自定义键盘

    KingKeyboardKingKeyboard for Android 是一个自定义键盘。内置了满足各种场景的键盘需求:包括但不限于混合、字母、数字、电话、车牌号等可输入场景。还支持自定义。集成简单,键盘可定制化。引入Maven:<dependency...
    继续阅读 »


    KingKeyboard

    KingKeyboard for Android 是一个自定义键盘。内置了满足各种场景的键盘需求:包括但不限于混合、字母、数字、电话、车牌号等可输入场景。还支持自定义。集成简单,键盘可定制化。


    引入

    Maven:

    <dependency>
    <groupId>com.king.keyboard</groupId>
    <artifactId>kingkeyboard</artifactId>
    <version>1.0.0</version>
    <type>pom</type>
    </dependency>

    Gradle:

    //AndroidX
    implementation 'com.king.keyboard:kingkeyboard:1.0.0'

    Lvy:

    <dependency org='com.king.keyboard' name='kingkeyboard' rev='1.0.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' }
    }
    }

    自定义按键值


    /*
    * 在KingKeyboard的伴生对象中定义了一些核心的按键值,当您需要自定义键盘时,可能需要用到
    */

    //------------------------------ 下面是定义的一些公用功能按键值
    /**
    * Shift键 -> 一般用来切换键盘大小写字母
    */
    const val KEYCODE_SHIFT = -1
    /**
    * 模式改变 -> 切换键盘输入法
    */
    const val KEYCODE_MODE_CHANGE = -2
    /**
    * 取消键 -> 关闭输入法
    */
    const val KEYCODE_CANCEL = -3
    /**
    * 完成键 -> 长出现在右下角蓝色的完成按钮
    */
    const val KEYCODE_DONE = -4
    /**
    * 删除键 -> 删除输入框内容
    */
    const val KEYCODE_DELETE = -5
    /**
    * Alt键 -> 预留,暂时未使用
    */
    const val KEYCODE_ALT = -6
    /**
    * 空格键
    */
    const val KEYCODE_SPACE = 32

    /**
    * 无作用键 -> 一般用来占位或者禁用按键
    */
    const val KEYCODE_NONE = 0

    //------------------------------

    /**
    * 键盘按键 -> 返回(返回,适用于切换键盘后界面使用,如:NORMAL_MODE_CHANGE或CUSTOM_MODE_CHANGE键盘)
    */
    const val KEYCODE_MODE_BACK = -101

    /**
    * 键盘按键 ->返回(直接返回到最初,直接返回到NORMAL或CUSTOM键盘)
    */
    const val KEYCODE_BACK = -102

    /**
    * 键盘按键 ->更多
    */
    const val KEYCODE_MORE = -103

    //------------------------------ 下面是自定义的一些预留按键值,与共用按键功能一致,但会使用默认的背景按键

    const val KEYCODE_KING_SHIFT = -201
    const val KEYCODE_KING_MODE_CHANGE = -202
    const val KEYCODE_KING_CANCEL = -203
    const val KEYCODE_KING_DONE = -204
    const val KEYCODE_KING_DELETE = -205
    const val KEYCODE_KING_ALT = -206

    //------------------------------ 下面是自定义的一些功能按键值,与共用按键功能一致,但会使用默认背景颜色

    /**
    * 键盘按键 -> 返回(返回,适用于切换键盘后界面使用,如:NORMAL_MODE_CHANGE或CUSTOM_MODE_CHANGE键盘)
    */
    const val KEYCODE_KING_MODE_BACK = -251

    /**
    * 键盘按键 ->返回(直接返回到最初,直接返回到NORMAL或CUSTOM键盘)
    */
    const val KEYCODE_KING_BACK = -252

    /**
    * 键盘按键 ->更多
    */
    const val KEYCODE_KING_MORE = -253

    /*
    用户也可自定义按键值,primaryCode范围区间为-999 ~ -300时,表示预留可扩展按键值。
    其中-399~-300区间为功能型按键,使用Special背景色,-999~-400自定义按键为默认背景色
    */

    示例

    代码示例

        //初始化KingKeyboard
    kingKeyboard = KingKeyboard(this,keyboardParent)
    //然后将EditText注册到KingKeyboard即可
    kingKeyboard.register(editText,KingKeyboard.KeyboardType.NUMBER)

    /*
    * 如果目前所支持的键盘满足不了您的需求,您也可以自定义键盘,KingKeyboard对外提供自定义键盘类型。
    * 自定义步骤也非常简单,只需自定义键盘的xml布局,然后将EditText注册到对应的自定义键盘类型即可
    *
    * 1. 自定义键盘Custom,自定义方法setKeyboardCustom,键盘类型为{@link KeyboardType#CUSTOM}
    * 2. 自定义键盘CustomModeChange,自定义方法setKeyboardCustomModeChange,键盘类型为{@link KeyboardType#CUSTOM_MODE_CHANGE}
    * 3. 自定义键盘CustomMore,自定义方法setKeyboardCustomMore,键盘类型为{@link KeyboardType#CUSTOM_MORE}
    *
    * xmlLayoutResId 键盘布局的资源文件,其中包含键盘布局和键值码等相关信息
    */
    kingKeyboard.setKeyboardCustom(R.xml.keyboard_custom)
    // kingKeyboard.setKeyboardCustomModeChange(xmlLayoutResId)
    // kingKeyboard.setKeyboardCustomMore(xmlLayoutResId)
    kingKeyboard.register(et12,KingKeyboard.KeyboardType.CUSTOM)
     //获取键盘相关的配置信息
    var config = kingKeyboard.getKeyboardViewConfig()

    //... 修改一些键盘的配置信息

    //重新设置键盘配置信息
    kingKeyboard.setKeyboardViewConfig(config)

    //按键是否启用震动
    kingKeyboard.setVibrationEffectEnabled(isVibrationEffectEnabled)

    //... 等等,还有各种监听方法。更多详情,请直接使用。
        //在Activity或Fragment相应的生命周期中调用,如下所示

    override fun onResume() {
    super.onResume()
    kingKeyboard.onResume()
    }

    override fun onDestroy() {
    super.onDestroy()
    kingKeyboard.onDestroy()
    }

    相关说明

    • KingKeyboard主要采用Kotlin编写实现,如果您的项目使用的是Java编写,集成时语法上可能稍微有点不同,除了结尾没有分号以外,对应类伴生对象中的常量,需要通过点伴生对象才能获取。
      //Kotlin 写法
    var keyCode = KingKeyboard.KEYCODE_SHIFT
      //Java 写法
    int keyCode = KingKeyboard.Companion.KEYCODE_SHIFT;

    更多使用详情,请查看app中的源码使用示例

    代码下载:KeyboardVisibilityEvent.zip

    收起阅读 »

    WordPOI是一个将Word接口文档转换成JavaBean的工具库

    WordPOIWordPOI是一个将Word接口文档转换成JavaBean的工具库,主要目的是减少部分无脑的开发工作。核心功能:将文档中表格定义的实体转换成Java实体对象WordPOI特性说明支持解析doc格式和docx格式的Word文档支持批量解析Word...
    继续阅读 »


    WordPOI


    WordPOI是一个将Word接口文档转换成JavaBean的工具库,主要目的是减少部分无脑的开发工作。

    核心功能:将文档中表格定义的实体转换成Java实体对象

    WordPOI特性说明

    1. 支持解析doc格式和docx格式的Word文档
    2. 支持批量解析Word文档并转换成实体
    3. 解析配置支持自定义,详情请查看{@link ParseConfig}相关配置
    4. 虽然解析可配置,但因文档内容的不可控,解析转换也具有一定的局限性

    只要在文档上定义实体对象时,尽量满足示例文档的规则,就可以规避解析转换时的局限性。

    ParseConfig属性说明

    属性值类型默认值说明
    startTableint0开始表格
    startRowint1开始行
    startColumnint0开始列
    fieldNameColumnint0字段名称所在列
    fieldTypeColumnint1字段类型所在列
    fieldDescColumnint2字段注释说明所在列
    charsetNameStringUTF-8字符集编码
    genGetterAndSetterbooleantrue是否生成get和set方法
    genToStringbooleantrue是否生成toString方法
    useLombokbooleanfalse是否使用Lombok
    parseEntityNamebooleanfalse是否解析实体名称
    entityNameRowint0实体名称所在行
    entityNameColumnint0实体名称所在列
    serializablebooleanfalse是否实现Serializable序列化
    showHeaderbooleantrue是否显示头注释
    headerStringCreated by WordPOI头注释内容
    transformationsMap<String,String>需要转型的集合(自定义转型配置)

    引入

    Maven:

    <dependency>
    <groupId>com.king.poi</groupId>
    <artifactId>word-poi</artifactId>
    <version>1.0.1</version>
    <type>pom</type>
    </dependency>

    Gradle:

    compile 'com.king.poi:word-poi:1.0.1'

    Lvy:

    <dependency org='com.king.poi' name='word-poi' rev='1.0.1'>
    <artifact name='$AID' ext='pom'></artifact>
    </dependency>
    如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
    allprojects {
    repositories {
    maven { url 'https://dl.bintray.com/jenly/maven' }
    }
    }

    引入的库:

    compile 'org.apache.poi:poi:4.1.0'
    compile 'org.apache.poi:poi-ooxml:4.1.0'
    compile 'org.apache.poi:poi-scratchpad:4.1.0'

    如想直接引入jar包可直接点击左上角的Download下载最新的jar,然后引入到你的工程即可。

    示例

    代码示例 (直接在main方法中调用即可)

            try {

    /**
    * 解析文档中的表格实体,表格包含了实体名称,只需配置 {@link ParseConfig#parseEntityName} 为 true 和相关对应行,即可开启自动解析实体名称,自动解析实体名称
    * {@link ParseConfig}中包含解析时需要的各种配置,方便灵活的支持文档中更多的表格样式
    */
    ParseConfig config = new ParseConfig.Builder().startRow(2).parseEntityName(true).build();
    WordPOI.wordToEntity(Test.class.getResourceAsStream("Api3.docx"),false,"C:/bean/","com.king.poi.bean",config);
    //解析文档docx格式 需要传生成的对象实体名称
    // WordPOI.wordToEntity(Test.class.getResourceAsStream("Api1.docx"),false,"C:/bean/","com.king.poi.bean","Result","PageInfo");
    //解析文档docx格式 需要传生成的对象实体名称
    // WordPOI.wordToEntity(Test.class.getResourceAsStream("Api2.doc"),true,"C:/bean/","com.king.poi.bean","TestBean");
    } catch (Exception e) {
    e.printStackTrace();
    }
    • 文档实体示例一(默认格式,见文档 Api1.docx)

    1.1. Result (响应结果实体)

    字段字段类型说明
    codeString0-代表成功,其它代表失败
    descString操作失败时的说明信息
    dataT返回对应的泛型实体对象

    1.2. PageInfo (页码信息实体)

    字段字段类型说明
    curPageInteger当前页码
    pageSizeInteger页码大小,每一页的记录条数
    totalPageInteger总页数
    hasNextBoolean是否有下一页
    dataList<T>泛型T为对应的数据记录实体
    • 文档实体示例二(自动解析实体名称格式,见文档 Api3.docx)

    1.1. 响应结果实体

    Result
    字段字段类型说明
    codeString0-代表成功,其它代表失败
    descString操作失败时的说明信息
    dataT返回对应的泛型<T>实体对象

    1.2. 页码信息实体

    PageInfo
    字段字段类型说明
    curPageInteger当前页码
    curPageInteger当前页码
    pageSizeInteger页码大小,每一页的记录条数
    totalPageInteger总页数
    hasNextBoolean是否有下一页
    dataList<T>泛型T为对应的数据记录实体

    更多使用详情,请查看Test中的源码使用示例或直接查看API帮助文档

    代码下载:WordPOI.zip

    收起阅读 »

    iOS 中的事件传递和响应机制 - 原理篇

    注:根据史上最详细的iOS之事件的传递和响应机制-原理篇重新整理(适当删减及补充)。在 iOS 中,只有继承了 UIReponder(响应者)类的对象才能接收并处理事件。其公共子类包括 UIView 、UIViewController 和 UIApplicat...
    继续阅读 »

    注:根据史上最详细的iOS之事件的传递和响应机制-原理篇重新整理(适当删减及补充)。

    在 iOS 中,只有继承了 UIReponder(响应者)类的对象才能接收并处理事件。其公共子类包括 UIView 、UIViewController 和 UIApplication 。

    UIReponder 类中提供了以下 4 个对象方法来处理触摸事件:

    /// 触摸开始
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {}
    /// 触摸移动
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {}
    /// 触摸取消(在触摸结束之前)
    /// 某个系统事件(例如电话呼入)会打断触摸过程
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
    /// 触摸结束
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}

    注意:

    如果手指同时触摸屏幕,touches(_:with:) 方法只会调用一次,Set<UITouch> 包含两个对象;

    如果手指前后触摸屏幕,touches(_:with:) 会依次调用,且每次调用时 Set<UITouch> 只有一个对象

    iOS 中的事件传递

    事件传递和响应的整个流程

    触发事件后,系统会将该事件加入到一个由 UIApplication 管理的事件队列中;
    UIApplication 会从事件队列中取出最前面的事件,将之分发出去以便处理,通常,先发送事件给应用程序的主窗口( keyWindow );
    主窗口会在视图层次结构中<u>找到一个最适合的视图</u>来处理触摸事件;
    找到适合的视图控件后,就会调用该视图控件的 touches(_:with:) 方法;
    touches(_:with:) 的默认实现是将事件顺着响应者链(后面会说)一直传递下去,直到连 UIApplication 对象也不能响应事件,则将其丢弃。

    如何寻找最适合的控件来处理事件

    当事件触发后,系统会调用控件的 hitTest(_:with:) 方法来遍历视图的层次结构,以确定哪个子视图应该接收触摸事件,过程如下:

    调用自己的 hitTest(_:with:) 方法;
    判断自己能否触发事件、是否隐藏、alpha <= 0.01;
    调用 point(inside:with:) 来判断触摸点是否在自己身上;
    倒序遍历 subviews ,并重复前面三个步骤。直到找到包含触摸点的最上层视图,并返回这个视图,那么该视图就是那个最适合的处理事件的 view;
    如果没有符合条件的子控件,就认为自己最适合处理事件,也就是自己是最适合的 view;
    通俗一点来解释就是,其实系统也无法决定应该让哪个视图处理事件,那么就用遍历的方式,依次找到包含触摸点所在的最上层视图,则认为该视图最适合处理事件。

    注意:

    触摸事件传递的过程是从父控件传递到子控件的,如果父控件也不能接收事件,那么子控件就不可能接收事件。

    寻找最适合的的 view 的底层剖析

    hitTest(_:with:) 的调用时机

    事件开始产生时会调用;
    只要事件传递给一个控件,就会调用这个控件的 hitTest(_:with:) 方法(不管这个控件能否处理事件或触摸点是否自己身上)。
    hitTest(_:with:) 的作用

    返回一个最适合的 view 来处理触摸事件。

    注意:

    如果 hitTest(_:with:) 方法中返回 nil ,那么该控件本身和其 subview 都不是最适合的 view,而是该控件的父控件。

    在默认的实现中,如果确定最终父控件是最适合的 view,那么仍然会调用其子控件的 hitTest(_:with:) 方法(不然怎么知道有没有更适合的 view?参考 如何寻找最适合的控件来处理事件。)

    hitTest(_:with:) 的默认实现

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    // 1. 判断自己能否触发事件
    if !self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01 {
    return nil
    }
    // 2.判断触摸点是否在自己身上
    if !self.point(inside: point, with: event) {
    return nil
    }
    // 3. 倒序遍历 `subviews` ,并重复前面两个步骤;
    // 直到找到包含触摸点的最前面的视图,并返回这个视图,那么该视图就是那个最合适的接收事件的 view;
    for view in subviews.reversed() {
    // 把坐标转换成控件上的坐标
    let p = self.convert(point, to: view)
    if let hitView = view.hitTest(p, with: event) {
    return hitView
    }
    }

    return self
    }

    iOS 中的事件响应

    找到最适合的 view 接收事件后,如果不重写实现该 view 的 touches(_:with:) 方法,那么这些方法的默认实现是将事件顺着响应者链向下传递, 将事件交给下一个响应者去处理。


    可以说,响应者链是由多个响应者对象链接起来的链条。UIReponder 的一个对象属性 next 能够很好的解释这一规则。

    UIReponder().next

    返回响应者链中的下一个响应者,如果没有下一个响应者,则返回 nil 。

    例如,UIView 调用此属性会返回管理它的 UIViewController 对象(如果有),没有则返回它的 superview;UIViewController 调用此属性会返回其视图的 superview;UIWindow 返回应用程序对象;共享的 UIApplication 对象则通常返回 nil 。

    例如,我们可以通过 UIView 的 next 属性找到它所在的控制器:

    extension UIView {
    var next = self.next
    while next != nil { // 符合条件就一直循环
    if let viewController = next as? UIViewController {
    return viewController
    }
    // UIView 的下一个响应控件,直到找到控制器。
    next = next?.next
    }
    return nil
    }

    转自:https://www.jianshu.com/p/024f0c719715

    收起阅读 »

    iOS开发笔记(十)— Xcode、UITabbar、特殊机型问题分析

    前言本文分享iOS开发中遇到的问题,和相关的一些思考。正文一、Xcode10.1 import头文件无法索引【问题表现】如图,当import头文件的时候,索引无效,无法联想出正确的文件;【问题分析】通过多个文件尝试,发现并非完全不能索引头文件,而是只能索引和当...
    继续阅读 »

    前言

    本文分享iOS开发中遇到的问题,和相关的一些思考。

    正文

    一、Xcode10.1 import头文件无法索引
    【问题表现】如图,当import头文件的时候,索引无效,无法联想出正确的文件;


    【问题分析】通过多个文件尝试,发现并非完全不能索引头文件,而是只能索引和当前文件在同级目录的头文件;
    有点猜测是Xcode10.1的原因,但是在升级完的半年多时间里,都没有出现过索引。
    从已有的知识来分析,很可能是Xcode的头文件搜索路径有问题,于是尝试把工程文件下的路径设置递归搜索,结果又出现以下问题:


    【问题解决】在多次尝试无效之后,最终还是靠Google解决该问题。
    如下路径,修改设置
    Xcode --> File --> Workspace Settings --> Build System --> Legacy Build System


    二、NSAssert的断点和symbolic 断点

    【问题表现】NSAssert是常见的断言,可以在debug阶段快速暴露问题,但是在触发的时候无法保持上下文;
    【问题分析】NSAssert的本质就是抛出一个异常,可以通过Xcode添加一个Exception Breakpoint:


    如下,便可以NSAssert触发时捕获现场。


    同理,在Exception Breakpoint,还有Smybolic Breakpoint较为常用。
    以cookie设置接口为例,以下为一段设置cookies的代码
    [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookies];
    但是有时候设置cookies的地方可能较多,此时可以添加一个Smybolic Breakpoint并设置符号为cookies。
    如下,可以看到所有设置cookies的接口:


    三、.m文件改成.mm文件后编译失败

    【问题表现】Pointer is missing a nullability type specifier (_Nonnull, _Nullable, or _Null_unspecified)
    出错代码行: typedef void(^SSDataCallback)(NSError *error, id obj);
    手动给参数添加 nullable的声明并无法解决。

    【问题分析】
    首先确定的是,这个编译失败实际上是一个warning,只是因为工程设置了把warning识别为error;
    其次.m文件可以正常编译,并且.m文件也是开启了warning as error的设置;而从改成.mm就报错的表现和提示log来看,仍然是因为参数为空的原因导致。

    【问题解决】
    经过对比正常编译的.mm文件,找到一个解决方案:
    1,添加NS_ASSUME_NONNULL_BEGIN在代码最前面,NS_ASSUME_NONNULL_END在代码最后面;
    2、手动添加_Nullable到函数的参数;
    typedef void(^SSDataCallback)(NSError * _Nullable error, id _Nullable obj);

    四、UITabbar疑难杂症

    问题1、batItem的染色异常问题

    【问题表现】添加UITabBarItem到tabbar上,但是图片会被染成蓝色;
    【问题分析】tabbar默认会帮我们染色,所以我们创建的UITabBarItem默认会被tinkColor染色的影响。
    解决办法就是添加参数imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal,这样UITabBarItem的图片变不会受到tinkColor影响。

    UITabBarItem *item1 = [[UITabBarItem alloc] initWithTitle:@"商城" image:[UIImage imageNamed:@"tabbar_item_store"] selectedImage:[[UIImage imageNamed:@"tabbar_item_store_selected"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]];

    问题2、tabbar的背景色问题

    【问题表现】设置tabbar的背景色是0xFFFFFF的白色,但是实际的效果确是灰白色,并不是全白色;
    【问题分析】tabbar默认是透明的(属性translucent),会对tabbar下面的视图进行高斯模糊,然后再与背景色混合。
    【问题解决】
    1、自由做法,addSubview:一个view到tabbar上,接下来自己绘制4个按钮;(可操作性强,缺点是tabbar的逻辑需要自己再实现一遍)
    2、改变tabbar透明度做法,设置translucent=YES,再修改背景色;(引入一个巨大的坑,导致UITabbarViewController上面的子VC的self.view属性高度会变化!)
    3、空白图做法,把背景图都用一张空白的图片替代,如下:(最终采纳的做法)

    self.tabBar.backgroundImage = [[UIImage alloc] init];
    self.tabBar.backgroundColor = [UIColor whiteColor];

    问题3、tabbar顶部的线条问题

    【问题表现】UITabbar默认在tabbar的顶部会有一条灰色的线,但是并没有一个属性可以修改其颜色。
    【问题分析】从Xcode的工具来看,这条线是一个UIImageView:


    再从UITabbar的头文件来看,这条线的图片可能是shadowImage。
    【问题解决】将shadowImage用一张空白的图片替代,然后自己再添加想要的线条大小和颜色。

    self.tabBar.shadowImage = [[UIImage alloc] init];
    UIView *lineView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.tabBar.width, 0.5)];
    lineView.backgroundColor = [UIColor colorWithHexString:@"e8e8e8"];
    [self.tabBar addSubview:lineView];

    五、特殊机型出现的异常现象

    1、iOS 11.4 充电时无法正常获取电量

    【问题表现】在某个场景需要获取电池,于是通过以下addObserverForName:UIDeviceBatteryLevelDidChangeNotification的方式监听电量的变化,在iOS 12的机型表现正常,但是在iOS 11.4的机型上会出现无法获取电量的原因。

    void (^block)(NSNotification *notification) = ^(NSNotification *notification) {
    SS_STRONG_SELF(self);
    NSLog(@"%@", self);
    self.batteryView.width = (self.batteryImageView.width - Padding_battery_width) * [UIDevice currentDevice].batteryLevel;
    };
    //监视电池剩余电量
    [[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceBatteryLevelDidChangeNotification
    object:nil
    queue:[NSOperationQueue mainQueue]
    usingBlock:block];

    【问题分析】从电量获取的api开始入手分析,在获取电量之前,需要显式调用接口
    [UIDevice currentDevice].batteryMonitoringEnabled = YES;
    于是点击batteryMonitoringEnabled属性进入UIDevice.h,发现有个batteryState属性,里面有一个状态是充电UIDeviceBatteryStateCharging,但是对问题并无帮助;
    点击UIDeviceBatteryLevelDidChangeNotification发现还有一个通知是UIDeviceBatteryStateDidChangeNotification,猜测可能是充电状态下的回调有所不同;
    【问题解决】最终通过添加新通知的监听解决。该问题并不太难,但是养成多看.h文件相关属性的习惯,还是会有好处。

    [[NSNotificationCenter defaultCenter] addObserverForName:UIDeviceBatteryStateDidChangeNotification
    object:nil
    queue:[NSOperationQueue mainQueue]
    usingBlock:block];

    2、iOS 10.3的UILabel富文本排版异常

    【问题表现】有一段文本的显示需要设置首行缩进,所以用的富文本添加段落属性的方式;但是在iOS 10.3的6p机型上出现异常现象,如下:
    测试文本:contentStr=@"一年佛山电脑放山东难道是防空洞念佛"
    如下,最后的字符没有显示完全。
    实现方式是计算得到富文本,然后赋值给UILabel,再调用-sizeToFit的接口。


    以上的问题仅在一行的时候出现异常,两行又恢复正常。


    【问题分析】
    从表现来看,是sizeToFit的时候宽度结算出错;通过多次尝试,发现是少计算了大概两个空格的距离,也即是首行缩进的距离。
    【问题解决】
    方法1、去除首行缩进,每行增加两个空格;
    方法2、一行的时候,把宽度设置到最大;
    如何判断1行的情况,可以用以下的代码简短判断

    if (self.contentLabel.height < self.contentLabel.font.lineHeight * 2) { // 一行的情况
    self.contentLabel.width = self.width - 40;
    }

    总结

    日常开发遇到的问题,如果解决过程超过10分钟,我都会记录下来。
    这些问题有的很简单,仅仅是改个配置(如第一个Xcode索引问题),但是在解决过程中还是走了一些弯路,因为完全没想过可能会去改Workspace setting,都是在Build setting修改进行尝试。
    还有些问题纯粹是特定现象,比如说特殊机型问题,只是做一个备忘和提醒


    链接:https://www.jianshu.com/p/6c964411fc03

    收起阅读 »

    iOS 任务调度器:为 CPU 和内存减负

    GitHub 地址:YBTaskScheduler支持 cocopods,使用简便,效率不错,一个性能优化的基础组件。前言前些时间有好几个技术朋友问过笔者类似的问题:主线程需要执行大量的任务导致卡顿如何处理?异步任务量级过大导致 CPU 和内存压力过高如何优化...
    继续阅读 »

    GitHub 地址:YBTaskScheduler
    支持 cocopods,使用简便,效率不错,一个性能优化的基础组件。

    前言

    前些时间有好几个技术朋友问过笔者类似的问题:主线程需要执行大量的任务导致卡顿如何处理?异步任务量级过大导致 CPU 和内存压力过高如何优化?

    解决类似的问题可以用几个思路:降频、淘汰、优先级调度。

    本来解决这些问题并不需要很复杂的代码,但是涉及到一些 C 代码并且要注意线程安全的问题,所以笔者就做了这样一个轮子,以解决任务调度引发的性能问题。

    本文讲述 YBTaskScheduler 的原理,读者朋友需要有一定的 iOS 基础,了解一些性能优化的知识,基本用法可以先看看 GitHub README,DEMO 中也有一个相册列表的应用案例。

    一、需求分析

    就拿 DEMO 中的案例来说明,一个显示相册图片的列表:


    实现图中业务,必然考虑到几个耗时操作:

    1、从相册读取图片
    2、解压图片
    3、圆角处理
    4、绘制图片

    理所当然的想到处理方案(DEMO中有实现):

    1、异步读取图片
    2、异步裁剪图片为正方形(这个过程中就解压了)
    3、异步裁剪圆角
    4、回到主线程绘制图片

    一整套流程下来,貌似需求很好的解决了,但是当快速滑动列表时,会发现 CPU 和内存的占用会比较高(这取决于从相册中读取并显示多大的图片)。当然 DEMO 中按照屏幕的物理像素处理,就算不使用任务调度器组件快速滑动列表也基本不会有掉帧的现象。考虑到老旧设备或者技术人员的水平,很多时候这种需求会导致严重的 CPU 和内存负担,甚至导致闪退。

    以上处理方案可能存在的性能瓶颈:

    从相册读取图片、裁剪图片,处理圆角、主线程绘制等操作会导致 CPU 计算压力过大。
    同时解压的图片、同时绘制的图片过多导致内存峰值飙升(更不要说做了图片的缓存)。
    任何一种情况都可能导致客户端卡死或者闪退,结合业务来分析问题,会发现优化的思路还是不难找到:

    · 滑出屏幕的图片不会存在绘制压力,而当前屏幕中的图片会在一个 RunLoop 循环周期绘制,可能造成掉帧。所以可以减少一个 RunLoop 循环周期所绘制的图片数量。
    · 快速滑动列表,大量的异步任务直接交由 CPU 执行,然而滑出屏幕的图片已经没有处理它的意义了。所以可以提前删除掉已经滑出屏幕的异步任务,以此来降低 CPU 和内存压力。

    没错, YBTaskScheduler 组件就是替你做了这些事情 ,而且还不止于此。

    二、命令模式与 RunLoop

    想要管理这些复杂的任务,并且在合适的时机调用它们,自然而然的就想到了命令模式。意味着任务不能直接执行,而是把任务作为一个命令装入容器。

    在 Objective-C 中,显然 Block 代码块能解决延迟执行这个问题:

    [_scheduler addTask:^{
    /*
    具体任务代码
    解压图片、裁剪图片、访问磁盘等
    */
    }];

    然后组件将这些代码块“装起来”,组件由此“掌握”了所有的任务,可以自由的决定何时调用这些代码块,何时对某些代码块进行淘汰,还可以实现优先级调度。

    既然是命令模式,还差一个 Invoker (调用程序),即何时去触发这些任务。结合 iOS 的技术特点,可以监听 RunLoop 循环周期来实现:

    static void addRunLoopObserver() {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
    taskSchedulers = [NSHashTable weakObjectsHashTable];
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(), kCFRunLoopBeforeWaiting | kCFRunLoopExit, true, 0xFFFFFF, runLoopObserverCallBack, NULL);
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
    CFRelease(observer);
    });
    }

    然后在回调函数中进行任务的调度。

    三、策略模式

    考虑到任务的淘汰策略和优先级调度,必然需要一些高效数据结构来支撑,为了提高处理效率,笔者直接使用了 C++ 的数据结构:deque和priority_queue。

    因为要实现任务淘汰,所以使用deque双端队列来模拟栈和队列,而不是直接使用stack和queue。使用priority_queue优先队列来处理自定义的优先级调度,它的缺点是不能删除低优先级节点,为了节约时间成本姑且够用。

    具体的策略:

    栈:后加入的任务先执行(可以理解为后加入的任务优先级高),优先淘汰先加入的任务。
    队列:先加入的任务先执行(可以理解为先加入的任务优先级高),优先淘汰后加入的任务。
    优先队列:自定义任务优先级,不支持任务淘汰。
    实际上组件是推荐使用栈和队列这两种策略,因为插入和取出的时间复杂度是常数级的,需要定制任务的优先级时才考虑使用优先队列,因为其插入复杂度是 O(logN) 的。

    至此,整个组件的业务是比较清晰了,组件需要让这三种处理方式可以自由的变动,所以采用策略模式来处理,下面是 UML 类图:


    嗯,这是个挺标准的策略模式。

    四、线程安全

    由于任务的调度可能在任意线程,所以必须要做好容器(栈、队列、优先队列)访问的线程安全问题,组件是使用pthread_mutex_t和dispatch_once来保证线程安全,同时笔者尽量减少临界区来提高性能。值得注意的是,如果不会存在线程安全的代码就不要去加锁了。

    后语

    部分技术细节就不多说了,组件代码量比较少,如果感兴趣可以直接看源码。实际上这个组件的应用场景并不是很多,在项目稳定需要做深度的性能优化时可能会比较需要它,并且希望使用它的人也能了解一些原理,做到胸有成竹,才能灵活的运用。

    转自:https://www.jianshu.com/p/f2a610c77d26

    收起阅读 »

    从 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
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    被React Native插件狂虐2天之后,写下c++_share.so冲突处理心路历程

    为了应对活体检测客户 react-native 端的支持,需要开发 react-native 插件供客户使用。关于react-native 插件开发具体可以参考react官网: reactnative.cn/docs/native… reactnative....
    继续阅读 »

    为了应对活体检测客户 react-native 端的支持,需要开发 react-native 插件供客户使用。关于react-native 插件开发具体可以参考react官网:



    具体包含两部分



    1. ViewManager:包装原生的 view 供 react-native 的 js 部分使用

    2. NativeModule:提供原生的 api 能力供 react-native 的 js 部分调用


    心路历程


    参考着官方事例,插件代码很快就完成。开开心心把插件发布到 github 之后试用了一下就遇到了第一个问题


    image.png


    看错误很容易发现是 so 冲突了,也就是说 react-native 脚手架创建的项目原本就存在libc++_share.so,正好我们的活体检测 sdk 也存在 libc++_shared.so。冲突的解决方法也很简单,在 android 域中添加如下配置:


    packagingOptions {
    pickFirst 'lib/arm64-v8a/libc++_shared.so'
    pickFirst 'lib/armeabi-v7a/libc++_shared.so'
    pickFirst 'lib/x86/libc++_shared.so'
    pickFirst 'lib/x86_64/libc++_shared.so'
    }

    这边顺便解释下packagingOptions中几个关键字的意思和作用

    关键字含义实例
    doNotStrip可以设置某些动态库不被优化压缩doNotStrip '*/arm64-v8a/libc++_shared.so'
    pickFirst匹配到多个相同文件,只提取第一个pickFirst 'lib/arm64-v8a/libc++_shared.so'
    exclude过滤掉某些文件或者目录不添加到APK中exclude 'lib/arm64-v8a/libc++_shared.so'
    merge将匹配的文件合并添加到APK中merge 'lib/arm64-v8a/libc++_shared.so'

    上述例子中处理的方式是遇到冲突取第一个libc++_shared.so。冲突解决之后继续运行,打开摄像头过一会儿就崩溃了,报错如下:


    com.awesomeproject A/libc: Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 30755 (work), pid 30611 (.awesomeproject)

    从报错信息来看只知道错误的地方在jni部分,具体在什么位置?哪行代码?一概不知。从现象来看大致能猜到错误的入口,于是逐行代码屏蔽去试,最后定位到报错的代码竟然是:


    std::cout << "src: (" << h << ", " << w << ")" << std::endl;

    仅仅是简单的c++输出流,对功能本来没有影响。很好奇为什么会崩溃,查了好久一无所获。既然不影响功能就先删掉了这行代码,果然就不报错了,功能都能正常使用了,开开心心的交给测试回归。一切都是好好的,直到跑在arm64-v8a的设备上,出现了如下报错:


    1e14141e-51c8-42e7-a5c7-440905742247.png


    这次有明显的报错信息,意思是当运行opencv_java3.so的时候缺少_sfp_handler_exception函数,这个函数实际上是在c++_shared.so库中的。奇怪的是原生代码运行在arm64-v8a的设备上是好的,那怎么跑在react-native环境就会缺少_sfp_handler_exception函数了呢?
    直到我在原生用ndk20a编译代码报了同样的错误,才意识到一切问题的源头是pickFirst引起的。


    4a1a64b0-296d-4753-abc1-92da09d60cde.png


    a4d4f827-ccea-4817-9175-e47458f1c917.png


    可以明显的看到react-native和原生环境跑出来的apk包中c++_shared.so的大小是不同的。
    也就是说pickFirst是存在安全隐患的,就拿这个例子来说,假如两个c++_shared.so是用不同版本的ndk打出来的,其实内部的库函数是不一样的,pickFirst贸然选择第一个必然导致另外的库不兼容。那么是不是可以用merge合并两个c++_shared.so,试了一下针对so merge失效了,只能是另辟蹊径。
    如果我们的sdk只有一个库动态依赖于c++_shared.so,大可把c++_shared.so以静态库的方式打入,这样就不会有so冲突问题,同时也解决了上述问题。配置如下:


    externalNativeBuild {
    ndk {
    abiFilters "armeabi-v7a", "arm64-v8a"
    }
    cmake {
    cppFlags "-std=c++11 -frtti -fexceptions"
    arguments "-DANDROID_STL=c++_shared" //shared改为static
    }
    }

    可惜的是例子中的sdk不止一个库动态依赖于c++_shared.so,所以这条路也行不通。那么只能从react-native侧出发寻找方案。


    方案一(推荐)


    找出react-native这边的c++_shared.so是基于什么ndk版本打出来的,想办法把两端的ndk版本保持统一,问题也就迎刃而解了。


    b2d4115d-0316-47c5-a14f-3dd5daf167f9.png


    从react-native对应的android工程的蛛丝马迹中发现大概是基于ndk r20b打出来的。接下来就是改造sdk中c++_shared.so基于的ndk版本了。



    1. 基于ndk r20b版本重新编译opencv库

    2. 把opencv库连接到项目,基于ndk r20b版本重新编译alive_detected.so库


    把编译好的sdk重新导入插件升级,运行之后果然所有的问题得以解决。


    方案二


    去除react-native中的c++_shared.so库,react-native并不是一开始就引入了c++_shared.so。从React Native版本升级中去查看c++_shared.so是哪个版本被引入的,可以发现0.59之前的版本是没有c++_shared.so库的,详见对比:


    bd504920-1855-445d-8f8f-cf4b6e4feabd.png


    4a77bb53-f0ad-45b4-862c-2e264b88db9d.png


    那么我们把react-native版本降级为0.59以下也能解决问题,降级步骤如下:



    1. 进入工程


    cd Temple


    1. 指定版本


    npm install --save react-native@0.58.6


    1. 更新


    react-native upgrade


    1. 一路替换文件


    fdf99f54-b121-4321-8956-6e3bce7efb99.png


    总结


    Android开发会面临各种环境问题,遇到问题还是要从原理出发,理清问题发生的根源,这样问题就很好解决。



    链接:https://juejin.cn/post/6976473129262514207

    收起阅读 »

    React Native 团队怎么看待 Flutter 的?终于有官方回复了

    昨天 React Native 官方团队在 reddit 上发起了一次 AUA(ask us anything)活动,地址在文末。看到这个活动的时候,我脑海里想到的第一个问题就是,他们怎么看待 Flutter 的?结果打开活动后,发现已经有人问了,而且还得到了...
    继续阅读 »

    昨天 React Native 官方团队在 reddit 上发起了一次 AUA(ask us anything)活动,地址在文末。看到这个活动的时候,我脑海里想到的第一个问题就是,他们怎么看待 Flutter 的?结果打开活动后,发现已经有人问了,而且还得到了官方的回复。



    提问者:



    你们是怎么看待 Flutter 的,和 Flutter 比起来 React Native 有什么优劣?



    官方回复:



    我认为 React Native 和 Flutter 的目标是完全不同的,因此在实现上也采取了完全不同的方法,所以如何看待二者,就取决于你要达到什么样的目的了。举例来说,React Native 更倾向于将每个平台各自的特性和组件样式进行保留,而 Flutter 是通过自己的渲染引擎去渲染组件样式,以代替平台原生的效果。这取决于你想做什么以及想做成什么样,这个应该就是你最需要考虑的事情了。



    话里有话:



    看完了也没说哪里好,哪里不好,很标准的官方回复。看来是早就想好了答案,算准了肯定会有人问这个。而且看完这个回复,我感觉像是在说:“小孩才做选择,大人就都要!”





    除了这个绕不开的问题以外,还有一个我认为比较关键的问题,就是关于 React Native 未来的发展。当然,这个问题也有人问了,就排在热门第一个。



    提问者:



    React Native 已经发布了有 4 年之久了,想问下你们对它未来 4 年的发展有什么想法呢?



    官方回复:



    我认为未来 React Native 的发展将有两个阶段。




    在第一个阶段发展结束的时候,我认为 React Native 将成为一个把 React 语法带到任何一个原生平台上的框架。现在我们已经可以看到,通过 Fabric 以及 TurboModules 会让 React Naitve 变得更易用更通用。我希望 React Native 可以支持任何移动、桌面、AR/VR 平台。目前我们应该也可以看到,公司希望 React Native 能运行在除了 Android 和 iOS 以外的设备上。




    在我开始讲述第二阶段前,首先需要明白我们要通过 React Native 达到什么目的是非常重要的,我们在尝试把 React 带到原生界面开发中。我们认为 React 在表现力、直观性以及灵活性之间,做到了一个非常好的平衡,以提供良好的性能和灵活的界面。




    在第二阶段发展结束的时候,我认为 React Native 将会重新回归 "React",这意味着很多事情,并且他的定位也会更加模糊。但是,这意味着在 React Native 和 React for web 之间更加聚合与抽象。这可能意味着会将抽象的级别提高到目前开发人员熟悉的 Web 水平上来。然而有趣的是,Twitter 整个网站已经使用 React Native(react-native-web)编写了。虽然这看起来像“代码共享”的 holy grail。但其实没有必要,我相信它可以在任何平台上都能带来高质量的体验。



    话里有话:



    这段话的大概意思就是,未来,第一阶段,React Native 计划先把 React 搬到所有原生平台上,然后第二阶段,就是逐渐抹平 React Native 和 React for web 之间的区别,代码会朝着 Web 开发者熟悉的方向进行抽象和聚合




    从这段话中,给我的感觉像是在说,React Native 是 React 的扩充而已,不要老拿我们和 Flutter 比,我们不一样,OK?至于未来怎么发展,那肯定是不会脱离我们庞大的 React 用户群体的。这本来就不是开发出来给你们原生开发者用的,而是给 Web 开发者扩充技能栈的。这么说,可能也是想避开和 Flutter 的正面交锋吧?毕竟在原生开发领域,Google 的技术积累比 Facebook 还是要深厚。





    现在这个活动已经有超过 200 多条回复了,其中有很多大家比较关心的问题,我觉得所有在用 React Native 的开发者都可以去看一下。由于内容实在是太多了,我也就不逐一翻译了。


    还有一点需要特别提一下,React Native 为什么要在这个时候搞这次 AUA 活动呢?正如他们在活动详情里提到的,因为 RN0.59 正式版马上就要发布了,官方宣称这次更新带来了“非常值得期待”的更新,所以可能是想出来好好宣传一下吧。


    如果你也有关注 React Native 开发,可以关注我的公众号,会不定时分享一些国内外的动态,当然不只有 React Native,也会分享一些关于移动开发的其他原创内容。




    围观地址:(要梯子)


    https://www.reddit.com/r/reactnative/comments/azuy4v/were_the_react_native_team_aua/


    收起阅读 »

    RN几种脚手架工具的使用和对比(react-native-cli、create-react-native-app、exp)

    1、react-native-cli 无法使用exp服务 react-native init program-name #初始化项目 npm start(react-native start) #在项目目录下启动 js service react-nat...
    继续阅读 »

    1、react-native-cli



    无法使用exp服务



    react-native init program-name  #初始化项目
    npm start(react-native start) #在项目目录下启动 js service
    react-native run-android #已连接真机或开启模拟器前提下,启动项目
    react-native run-ios #已连接真机或开启模拟器前提下(仅支持mac系统),启动项目

    2、create-react-native-app



    create-react-native-app是React 社区孵化出来的一种无需构建配置就可以创建>RN App的一个开源项目,一个创建react native应用的脚手架工具(最好用,无需翻墙



    初始化后项目可使用exp服务




    安装使用


    npm install -g create-react-native-app #全局安装

    使用create-react-native-app来创建APP


    create-react-native-app program-name #初始化项目
    cd program-name #进入项目目录
    npm start #启动项目服务

    create-react-native-app常用命令


    npm start  #启动本地开发服务器,这样一来你就可以通过Expo扫码将APP运行起来了
    npm run ios #将APP运行在iOS设备上,仅仅Mac系统支持,且需要安装Xcode
    npm run android #将APP运行在Android设备上,需要Android构建工具
    npm test # 运行测试用例


    如果本地安装了yarn管理工具,会提示使用yarn命令来启动管理服务




    运行项目



    Expo App扫码启动项目服务屏幕上自动生成的二维码,program-name就可以运
    行在Expo App上



    expo下载配置参考下一条


    3、Expo



    Expo是一组工具、库和服务,可以通过编写JavaScript来构建本地的ios和Android应用程序
    需翻墙使用,下载资源速度慢



    安装使用



    PC上通过命令行安装expo服务



    1、npm install exp --global #全局安装 简写: npm i -g exp


    手机上安装Expo Client App(app store上叫Expo Client)
    安装包下载地址:expo官网
    手机安装好后注册expo账号(必须,后续用于PC expo 服务直接通过账号将项目应用于expo app




    提示:为了确保Expo App能够正常访问到你的PC,你需要确保你的手机和PC处于同一网段内或者他们能够联通



    初始化一个项目(Create your first project)


    2、exp init my-new-project  #初始化项目,会要求你选择模板


    The Blank project template includes the minimum dependencies to run and an empty root component 空白项目模板包含运行的最小依赖项和空白根组件


    The Tab Navigation project template includes several example screens Tab Navigation项目模板包含几个示例屏幕




    报错:



    Set EXPO_DEBUG=true in your env to view the stack trace. 报错如下图
    解决方法:下载Expo XDE(PC客户端使用) --初始化项目需翻墙





    注:使用命令行初始化项目可能会卡在下载react-native资源,可转换成XDE初始化项目,再使用命令行启动项目并推送



    3、cd my-new-project #进入项目目录
    4、exp start #启动项目,推送至手机端

    启动项目后会要求你输入你在App上注册的Expo账号和密码




    初始化后项目结构



    主要windows下android目录结构


    |- program-name             | 项目工作空间
    |- android | android 端代码
    |- app | app 模块
    |- build.gradle | app 模块 Gradle 配置文件
    |- progurad-rules.pro | 混淆配置文件
    |- src/main | 源代码
    |- AndroidManifest.xml | APK 配置信息
    |- java | 源代码
    |- 包名 | java 源代码
    |- MainActivity.java | 界面文件, (加载ReactNative源文件入口)
    |- MainApplication.java | 应用级上下文, (ReactNative 插件配置)
    |- res | APK 资源文件
    |- gradle | Gradle 版本配置信息
    |- keystores | APK 打包签名文件(如果正式开发需要自己定义修改签名文件)
    |- gradlew | Gradle运行脚本, 与 react-native run-android 有关
    |- gradlew.bat | Gradle运行脚本, 与 react-native run-android 有关
    |- gradle.properties | Gradle 的配置文件, 正常是 AndroidHome, NDK, JDK, 环境变量的配置
    |- build.gradle | Gradle的全局配置文件, 主要是是配置编译 Android 的 Gradle 插件,及配置 Gradle仓库
    |- settings.gradle | Gradle模块配置
    |- ios | iOS 端代码
    |- node_modules | 项目依赖库
    |- package.json | node配置文件, 主是要配置项目的依赖库,
    |- index.android.js | Android 项目启动入口
    |- index.ios.js | iOS 项目启动入口


    package.json文件说明



    dependencies




    • 项目的依赖配置

      • 依赖配置,配置信息配置方式

        • “version” 强制使用特定版本

        • “^version” 兼容版本

        • “git…” 从 git版本控制地址获取依赖版本库

        • “path/path/path” 指定本地位置下的依赖库

        • “latest” 使用最新版本

        • “>version” 会在 npm 库中找最新的版本, 并且大于此版本

        • “>=version” 会在 npm 库中找最新的版本, 并且大于等于此版本“







    devDependencies



    • 开发版本的依赖库




    version




    • js 版本标志



    description




    • 项目描述, 主要使用于做第三方支持库时,对库的描述信息



    main




    • 项目的缺省入口



    engines




    • 配置引擎版本信息, 如 node, npm 的版本依赖



    **index.*.js
    新版RN统一入口:index.js




    • 正常只作为项目入口,不做其他业务代码处理


    注:
    1、虚拟机上很消耗电脑内存, 建议使用真机进行安装测试



    链接:https://juejin.cn/post/6844903599793766413

    收起阅读 »

    一份传男也传女的 React Native 学习笔记

    这段时间了解了一些前端方面的知识,并且用 React Native 写了一个简易新闻客户端 Demo。 React Native 和原生开发各有所长,具体就不细说。混合使用能充分发挥各自长处,唯一的缺憾就是 React Native 和原生通信过程相对不那么友...
    继续阅读 »

    这段时间了解了一些前端方面的知识,并且用 React Native 写了一个简易新闻客户端 Demo。


    React Native 和原生开发各有所长,具体就不细说。混合使用能充分发挥各自长处,唯一的缺憾就是 React Native 和原生通信过程相对不那么友好。


    在这里分享一下学习过程中个人认为比较重要的知识点和学习资料,本文尽量写得轻一些,希望对读者能够有所帮助。


    预备知识


    有些前端经验的小伙伴学起 React Native 就像老马识途,东西都差不多,变来变去也玩不出什么花样。


    HTML5:H5 元素对比 React Native 组件,使用方式如出一辙。


    CSS:React Native 的 FlexBox 用来为组件布局的,和 CSS 亲兄弟关系。


    JavaScript:用 JavaScript 写,能不了解一下吗? JavaScript 之于 React Native 就如同砖瓦之于摩天大楼。


    React JSX:React 使用 JSX 来替代常规的 JavaScript。JSX 是一个看起来很像 XML 的 JavaScript 语法扩展。


    一、开始学习 React Native


    图片来自网络


    React Native 社区相对比较成熟,中文站的内容也比较全面,从入门到进阶,环境安装到使用指南,学习 React Native 推荐从官网 reactnative.cn 开始。FlexBox 布局、组件、API 建议在该官网查看,注意网页顶部可以切换 React Native 的历史版本。


    1.1 安装开发环境



    1. React Native 官网推荐搭建开发环境指南传送门。(记得设置 App Transport Security Settings ,允许 http 请求)

    2. 已建立原生项目,将 React Native 集成到现有原生项目传送门

    3. 基于第2点,React Native 与原生混编的情况下,React Native 与原生如何通信传送门

    4. 在 IDE 选择这一点上,不要过多纠结,个人使用 WebStorm ,比较省心。


    1.2 生命周期

    class Clock extends React.Component {
    // 构造函数 通常进行一些初始化操作 如定义 state 初始值
    constructor(props) {
    super(props);
    }

    // 组件已挂载
    componentDidMount() {}

    // 组件即将被卸载
    componentWillUnmount() {}

    // 渲染函数
    render() {
    return (
    <View></View>
    );
    }
    }


    1.3 Props 与 State


    一个组件所有的数据来自于 Props 与 State ,分布是外部传入的属性和内部状态。


    Props 是父组件给子组件传递数据用的,Props 由外部传入后无法改变,可以同时传递多个属性。

    // 父组件 传递一个属性 name 给子组件
    <Greeting name='xietao3' />

    // 子组件使用父组件传递下来的属性 name
    <Text>Hello {this.props.name}!</Text>


    State :用来控制组件内部状态,每次修改都会重新渲染组件。

    // 初始化 state
    constructor(props) {
    super(props);
    this.state = { showText: 'hello xietao3' };
    }

    // 使用 state
    render() {
    // 根据当前showText的值决定显示内容
    return (
    <Text>{this.state.showText}</Text>
    );
    }

    // 修改state,触发 render 函数,重新渲染页面
    this.setState({showText: 'hello world'});


    举个栗子(如果理解了就跳过吧):


    我们使用两种数据来控制一个组件:props 和 state。 props 是在父组件中指定,而且一经指定,在被指定的组件的生命周期中则不再改变。 对于需要改变的数据,我们需要使用 state 。


    一般来说,你需要在 constructor 中初始化 state ,然后在需要修改时调用setState方法。


    假如我们需要制作一段不停闪烁的文字。文字内容本身在组件创建时就已经指定好了,所以文字内容应该是一个 prop 。而文字的显示或隐藏的状态(快速的显隐切换就产生了闪烁的效果)则是随着时间变化的,因此这一状态应该写到 state 中。


    1.4 组件与 API


    说到组件就不得不说 React Native 的组件化思想,尼古拉斯 · 赵四 曾经说过,组合由于继承。简单来说就是多级封装嵌套、组合使用,提高基础组件复用率。


    组件怎么用?


    授人以鱼不如授人以渔,点击这里打开官方文档 ,在左边导航栏中找到你想使用的组件并且点击,里面就有组件的使用方式和属性的详细介绍。


    关于 API


    建议写第一个 Demo 之前把所有 API 浏览一遍,磨刀不误砍柴工,不一定要会用,但一定要知道这些提供了哪些功能,后面开发中可能会用得上。API 列表同样可以在官网左边导航栏中找到。


    二、助力 React Native 起飞


    以下内容不建议在第一个 Demo 中使用:


    2.1 Redux


    Redux(中文教程英文教程) 是 JavaScript 状态容器,提供可预测化的状态管理。


    部分推荐教程:



    2.2 CodePush


    React Native 热更新的发动机,接入的时候绕了很多圈圈,后面发现接入还挺方便的。CodePush 除了可以使用微软提供的服务进行热更新之外,还可以自建服务器进行热更新。


    推荐教程:



    三、 与原生端通信


    3.1 在 React Native 中使用原生UI组件


    填坑:



    • 原生端的 Manager 文件如果有 RCT 前缀,在 RN 中引用的时候不要加 RCT。

    • 原生 UI 组件的 RCTBubblingEventBlock 类型属性命名一定要以 on 开头,例如 onChange。


    3.2 在 React Native 中发消息通知给原生端(由于RN调用原生端是异步的,最好在回调中通过通知把消息传递到具体的类)


    3.3 在原生端发消息通知给 React Native (建议在Manager中写一个类方法,这样外部也可以灵活发送通知)


    这里其实是有 Demo 的,但是还没整理好🤦️。


    四、React Native 进阶资源


    有时候一下子看到好多感兴趣的东西,容易分散注意力,在未到达一定水平之前建议不要想太多,入门看官网就足够了。当你掌握了那些知识之后,你就可以拓展一下你的知识库了。



    • awesome-react-native 19000+⭐️(包含热门文章、信息、第三方库、工具、学习书籍视频等)

    • react-native-guide 11900+⭐️ (中文 react-native 学习资源、开 源App 和组件)

    • js.coach (第三方库搜索平台)

    • 个人收集的一些开源项目(读万卷书不如行万里路,行万里路不如阅码无数!经常看看别人的代码,总会有新收获的)


    五、React Native 第一个小 Demo


    5.1 MonkeyNews 简介


    MonkeyNews,纯 React Native 新闻客户端,部分参考知乎日报,并且使用了其部分接口
    由于是练手项目,仅供参考,这里附上 GitHub 地址,感兴趣的可以了解(star)一下。


    首页


    频道


    个人中心


    5.2 用到的第三方库:



    • react-native-code-push:React Native 热更新

    • react-native-swiper:用于轮播图

    • react-navigation:TabBar + NavigationBar


    5.3 项目结构



    Common



    MKSwiper.js

    MKNewsListItem.js
    MKImage.js

    MKPlaceholderView.js

    MKThemeListItem.js

    MKLoadingView.js

    ...





    Config



    MKConstants.js





    Pages



    Home



    MKHomePage.js

    MKNewsDetailPage.js





    Category



    MKCategoryPage.js

    MKThemeDetailPage.js





    UserCenter



    MKUserCenterPage.js






    Services



    MKServices.js

    APIConstants.js





    Styles



    commonStyles.js




    六、总结


    在对 React Native 有了一些了解之后,个人感觉目前 React Native 的状况很难替代原生开发,至少现阶段还不行。


    个人认为的缺点:React Native 的双端运行的优点并不明显,很多原生 API 使用起来都比较麻烦,很大程度上抵消了双端运行带来的开发效率提升,这种情况下我甚至更愿意用原生 iOS 和 Android 各写一套。


    优点:React Native 和原生组合使用,通过动态路由动态在原生页面和 React Native 页面之间切换,可以在原生页面出现 bug 的时候切换至 React Native 页面,或者比较简单的页面直接使用 React Native 开发都是非常不错的。


    总之, React Native 也是可以大有作为的。



    链接:https://juejin.cn/post/6844903605137342477

    收起阅读 »

    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
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »