注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

iOS大解密:玄之又玄的KVO (上)

导读:大多数 iOS 开发人员对 KVO 的认识只局限于 isa 指针交换这一层,而 KVO 的实现细节却鲜为人知。如果自己也仿照 KVO 基础原理来实现一套类 KVO 操作且独立运行时会发现一切正常,然而一旦你的实现和系统的 KVO 实现同时作用在同一个实例...
继续阅读 »

导读:

大多数 iOS 开发人员对 KVO 的认识只局限于 isa 指针交换这一层,而 KVO 的实现细节却鲜为人知。

如果自己也仿照 KVO 基础原理来实现一套类 KVO 操作且独立运行时会发现一切正常,然而一旦你的实现和系统的 KVO 实现同时作用在同一个实例上那么各种各样诡异的 bug 和 crash 就会层出不穷。

这究竟是为什么呢?此类问题到底该如何解决呢?接下来我们将尝试从汇编层面来入手以层层揭开 KVO 的神秘面纱......

1. 缘起 Aspects

SDMagicHook 开源之后很多小伙伴在问“ SDMagicHook 和 Aspects 的区别是什么?”,我在 GitHub 上找到 Aspects 了解之后发现 Aspects 也是以 isa 交换为基础原理进行的 hook 操作,但是两者在具体实现和 API 设计上也有一些区别,另外 SDMagicHook 还解决了 Aspects 未能解决的 KVO 冲突难题。

1.1 SDMagicHook 的 API 设计更加友好灵活

SDMagicHook 和 Aspects 的具体异同分析见:https://github.com/larksuite/SDMagicHook/issues/3。

1.2 SDMagicHook 解决了 Aspects 未能解决的 KVO 冲突难题

在 Aspects 的 readme 中我还注意到了这样一条关于 KVO 兼容问题的描述:



SDMagicHook 会不会有同样的问题呢?测试了一下发现 SDMagicHook 果然也中招了,而且其实此类问题的实际情况要比 Aspects 作者描述的更为复杂和诡异,问题的具体表现会随着系统 KVO(以下简称 native-KVO)和自己实现的类 KVO(custom-KVO)的调用顺序和次数的不同而各异,具体如下:

  1. 先调用 custom-KVO 再调用 native-KVO,native-KVO 和 custom-KVO 都运行正常
  2. 先调用 native-KVO 再调用 custom-KVO,custom-KVO 运行正常,native-KVO 会 crash
  3. 先调用 native-KVO 再调用 custom-KVO 再调用 native-KVO,native-KVO 运行正常,custom-KVO 失效,无 crash

目前,SDMagicHook 已经解决了上面提到的各类问题,具体的实现方案我将在下文中详细介绍。

2. 从汇编层面探索 KVO 本质

想要弄明白这个问题首先需要研究清楚系统的 KVO 到底是如何实现的,而系统的 KVO 实现又相当复杂,我们该从哪里入手呢?想要弄清楚这个问题,我们首先需要了解下当对被 KVO 观察的目标属性进行赋值操作时到底发生了什么。这里我们以自建的 Test 类为例来说明,我们对 Test 类实例的 num 属性进行 KVO 操作:


当我们给 num 赋值时,可以看到断点命中了 KVO 类自定义的 setNum:的实现即_NSSetIntValueAndNotify 函数


那么_NSSetIntValueAndNotify 的内部实现是怎样的呢?我们可以从汇编代码中发现一些蛛丝马迹:



Foundation`_NSSetIntValueAndNotify:
    0x10e5b0fc2 <+0>:   pushq  %rbp
->  0x10e5b0fc3 <+1>:   movq   %rsp, %rbp
    0x10e5b0fc6 <+4>:   pushq  %r15
    0x10e5b0fc8 <+6>:   pushq  %r14
    0x10e5b0fca <+8>:   pushq  %r13
    0x10e5b0fcc <+10>:  pushq  %r12
    0x10e5b0fce <+12>:  pushq  %rbx
    0x10e5b0fcf <+13>:  subq   $0x48, %rsp
    0x10e5b0fd3 <+17>:  movl   %edx, -0x2c(%rbp)
    0x10e5b0fd6 <+20>:  movq   %rsi, %r15
    0x10e5b0fd9 <+23>:  movq   %rdi, %r13
    0x10e5b0fdc <+26>:  callq  0x10e7cc882               ; symbol stub for: object_getClass
    0x10e5b0fe1 <+31>:  movq   %rax, %rdi
    0x10e5b0fe4 <+34>:  callq  0x10e7cc88e               ; symbol stub for: object_getIndexedIvars
    0x10e5b0fe9 <+39>:  movq   %rax, %rbx
    0x10e5b0fec <+42>:  leaq   0x20(%rbx), %r14
    0x10e5b0ff0 <+46>:  movq   %r14, %rdi
    0x10e5b0ff3 <+49>:  callq  0x10e7cca26               ; symbol stub for: pthread_mutex_lock
    0x10e5b0ff8 <+54>:  movq   0x18(%rbx), %rdi
    0x10e5b0ffc <+58>:  movq   %r15, %rsi
    0x10e5b0fff <+61>:  callq  0x10e7cb472               ; symbol stub for: CFDictionaryGetValue
    0x10e5b1004 <+66>:  movq   0x36329d(%rip), %rsi      ; "copyWithZone:"
    0x10e5b100b <+73>:  xorl   %edx, %edx
    0x10e5b100d <+75>:  movq   %rax, %rdi
    0x10e5b1010 <+78>:  callq  *0x2b2862(%rip)           ; (void *)0x000000010eb89d80: objc_msgSend
    0x10e5b1016 <+84>:  movq   %rax, %r12
    0x10e5b1019 <+87>:  movq   %r14, %rdi
    0x10e5b101c <+90>:  callq  0x10e7cca32               ; symbol stub for: pthread_mutex_unlock
    0x10e5b1021 <+95>:  cmpb   $0x0, 0x60(%rbx)
    0x10e5b1025 <+99>:  je     0x10e5b1066               ; <+164>
    0x10e5b1027 <+101>: movq   0x36439a(%rip), %rsi      ; "willChangeValueForKey:"
    0x10e5b102e <+108>: movq   0x2b2843(%rip), %r14      ; (void *)0x000000010eb89d80: objc_msgSend
    0x10e5b1035 <+115>: movq   %r13, %rdi
    0x10e5b1038 <+118>: movq   %r12, %rdx
    0x10e5b103b <+121>: callq  *%r14
    0x10e5b103e <+124>: movq   (%rbx), %rdi
    0x10e5b1041 <+127>: movq   %r15, %rsi
    0x10e5b1044 <+130>: callq  0x10e7cc2b2               ; symbol stub for: class_getMethodImplementation
    0x10e5b1049 <+135>: movq   %r13, %rdi
    0x10e5b104c <+138>: movq   %r15, %rsi
    0x10e5b104f <+141>: movl   -0x2c(%rbp), %edx
    0x10e5b1052 <+144>: callq  *%rax
    0x10e5b1054 <+146>: movq   0x364385(%rip), %rsi      ; "didChangeValueForKey:"
    0x10e5b105b <+153>: movq   %r13, %rdi
    0x10e5b105e <+156>: movq   %r12, %rdx
    0x10e5b1061 <+159>: callq  *%r14
    0x10e5b1064 <+162>: jmp    0x10e5b10be               ; <+252>
    0x10e5b1066 <+164>: movq   0x2b22eb(%rip), %rax      ; (void *)0x00000001120b9070: _NSConcreteStackBlock
    0x10e5b106d <+171>: leaq   -0x68(%rbp), %r9
    0x10e5b1071 <+175>: movq   %rax, (%r9)
    0x10e5b1074 <+178>: movl   $0xc2000000, %eax         ; imm = 0xC2000000
    0x10e5b1079 <+183>: movq   %rax, 0x8(%r9)
    0x10e5b107d <+187>: leaq   0xf5d(%rip), %rax         ; ___NSSetIntValueAndNotify_block_invoke
    0x10e5b1084 <+194>: movq   %rax, 0x10(%r9)
    0x10e5b1088 <+198>: leaq   0x2b7929(%rip), %rax      ; __block_descriptor_tmp.77
    0x10e5b108f <+205>: movq   %rax, 0x18(%r9)
    0x10e5b1093 <+209>: movq   %rbx, 0x28(%r9)
    0x10e5b1097 <+213>: movq   %r15, 0x30(%r9)
    0x10e5b109b <+217>: movq   %r13, 0x20(%r9)
    0x10e5b109f <+221>: movl   -0x2c(%rbp), %eax
    0x10e5b10a2 <+224>: movl   %eax, 0x38(%r9)
    0x10e5b10a6 <+228>: movq   0x364fab(%rip), %rsi      ; "_changeValueForKey:key:key:usingBlock:"
    0x10e5b10ad <+235>: xorl   %ecx, %ecx
    0x10e5b10af <+237>: xorl   %r8d, %r8d
    0x10e5b10b2 <+240>: movq   %r13, %rdi
    0x10e5b10b5 <+243>: movq   %r12, %rdx
    0x10e5b10b8 <+246>: callq  *0x2b27ba(%rip)           ; (void *)0x000000010eb89d80: objc_msgSend
    0x10e5b10be <+252>: movq   0x362f73(%rip), %rsi      ; "release"
    0x10e5b10c5 <+259>: movq   %r12, %rdi
    0x10e5b10c8 <+262>: callq  *0x2b27aa(%rip)           ; (void *)0x000000010eb89d80: objc_msgSend
    0x10e5b10ce <+268>: addq   $0x48, %rsp
    0x10e5b10d2 <+272>: popq   %rbx
    0x10e5b10d3 <+273>: popq   %r12
    0x10e5b10d5 <+275>: popq   %r13
    0x10e5b10d7 <+277>: popq   %r14
    0x10e5b10d9 <+279>: popq   %r15
    0x10e5b10db <+281>: popq   %rbp
    0x10e5b10dc <+282>: retq

上面这段汇编代码翻译为伪代码大致如下:

typedef struct {
    Class originalClass;                // offset 0x0
    Class KVOClass;                     // offset 0x8
    CFMutableSetRef mset;               // offset 0x10
    CFMutableDictionaryRef mdict;       // offset 0x18
    pthread_mutex_t *lock;              // offset 0x20
    void *sth1;                         // offset 0x28
    void *sth2;                         // offset 0x30
    void *sth3;                         // offset 0x38
    void *sth4;                         // offset 0x40
    void *sth5;                         // offset 0x48
    void *sth6;                         // offset 0x50
    void *sth7;                         // offset 0x58
    bool flag;                          // offset 0x60
} SDTestKVOClassIndexedIvars;

typedef struct {
    Class isa;                          // offset 0x0
    int flags;                          // offset 0x8
    int reserved;
    IMP invoke;                         // offset 0x10
    void *descriptor;                   // offset 0x18
    void *captureVar1;                  // offset 0x20
    void *captureVar2;                  // offset 0x28
    void *captureVar3;                  // offset 0x30
    int captureVar4;                    // offset 0x38

} SDTestStackBlock;

void _NSSetIntValueAndNotify(id obj, SEL sel, int number) {
    Class cls = object_getClass(obj);
    // 获取类实例关联的信息
    SDTestKVOClassIndexedIvars *indexedIvars = object_getIndexedIvars(cls);
    pthread_mutex_lock(indexedIvars->lock);
    NSString *str = (NSString *)CFDictionaryGetValue(indexedIvars->mdict, sel);
    str = [str copyWithZone:nil];
    pthread_mutex_unlock(indexedIvars->lock);
    if (indexedIvars->flag) {
        [obj willChangeValueForKey:str];
        ((void(*)(id obj, SEL sel, int number))class_getMethodImplementation(indexedIvars->originalClass, sel))(obj, sel, number);
        [obj didChangeValueForKey:str];
    } else {
        // 生成block
        SDTestStackBlock block = {};
        block.isa = _NSConcreteStackBlock;
        block.flags = 0xC2000000;
        block.invoke = ___NSSetIntValueAndNotify_block_invoke;
        block.descriptor = __block_descriptor_tmp;
        block.captureVar2 = indexedIvars;
        block.captureVar3 = sel;
        block.captureVar1 = obj;
        block.captureVar4 = number;
        [obj _changeValueForKey:str key:nil key:nil usingBlock:&SDTestStackBlock];
    }
}

这段代码的大致意思是说首先通过 object_getIndexedIvars(cls)获取到 KVO 类的 indexedIvars,如果 indexedIvars->flag 为 true 即开发者自己重写实现过 willChangeValueForKey:或者 didChangeValueForKey:方法的话就直接以 class_getMethodImplementation(indexedIvars->originalClass, sel))(obj, sel, number)的方式实现对被观察的原方法的调用,否则就用默认实现为 NSSetIntValueAndNotify_block_invoke 的栈 block 并捕获 indexedIvars、被 KVO 观察的实例、被观察属性对应的 SEL、赋值参数等所有必要参数并将这个 block 作为参数传递给 [obj _changeValueForKey:str key:nil key:nil usingBlock:&SDTestStackBlock]调用。看到这里你或许会有个疑问:伪代码中通过 object_getIndexedIvars(cls)获取到的 indexedIvars 是什么信息呢?block.invoke = ___ NSSetIntValueAndNotify_block_invoke 又是如何实现的呢?


篇幅过长  分上下2篇

收起阅读 »

iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+

iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+iOS OOM 崩溃在生产环境中的归因一直是困扰业界已久的疑难问题,字节跳动旗下的头条、抖音等产品也面临同样的问题。在字节跳动性能与稳定性保障团队的研发实践中,我们自研了一款基于内存快照技术并且可应用...
继续阅读 »

iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+

iOS OOM 崩溃在生产环境中的归因一直是困扰业界已久的疑难问题,字节跳动旗下的头条、抖音等产品也面临同样的问题。

在字节跳动性能与稳定性保障团队的研发实践中,我们自研了一款基于内存快照技术并且可应用于生产环境中的 OOM 归因方案——线上 Memory Graph。基于此方案,3 个月内头条抖音 OOM 崩溃率下降 50%+。

本文主要分享下该解决方案的技术背景,技术原理以及使用方式,旨在为这个疑难问题提供一种新的解决思路。

OOM 崩溃背景介绍

OOM

OOM 其实是Out Of Memory的简称,指的是在 iOS 设备上当前应用因为内存占用过高而被操作系统强制终止,在用户侧的感知就是 App 一瞬间的闪退,与普通的 Crash 没有明显差异。但是当我们在调试阶段遇到这种崩溃的时候,从设备设置->隐私->分析与改进中是找不到普通类型的崩溃日志,只能够找到Jetsam开头的日志,这种形式的日志其实就是 OOM 崩溃之后系统生成的一种专门反映内存异常问题的日志。那么下一个问题就来了,什么是Jetsam

Jetsam

Jetsam是 iOS 操作系统为了控制内存资源过度使用而采用的一种资源管控机制。不同于MacOSLinuxWindows等桌面操作系统,出于性能方面的考虑,iOS 系统并没有设计内存交换空间的机制,所以在 iOS 中,如果设备整体内存紧张的话,系统只能将一些优先级不高或占用内存过大的进程直接终止掉。


上图是截取一份Jetsam日志中最关键的一部分。关键信息解读:

  • pageSize:指的是当前设备物理内存页的大小,当前设备是iPhoneXs Max,大小是 16KB,苹果 A7 芯片之前的设备物理内存页大小则是 4KB。
  • states:当前应用的运行状态,对于Heimdallr-Example这个应用而言是正在前台运行的状态,这类崩溃我们称之为FOOM(Foreground Out Of Memory);与此相对应的也有应用程序在后台发生的 OOM 崩溃,这类崩溃我们称之为BOOM(Background Out Of Memory)。
  • rpages:是resident pages的缩写,表明进程当前占用的内存页数量,Heimdallr-Example 这个应用占用的内存页数量是 92800,基于 pageSize 和 rpages 可以计算出应用崩溃时占用的内存大小:16384 * 92800 / 1024 /1024 = 1.4GB。
  • reason:表明进程被终止的的原因,Heimdallr-Example这个应用被终止的原因是超过了操作系统允许的单个进程物理内存占用的上限。

Jetsam机制清理策略可以总结为下面两点:

1.  单个 App 物理内存占用超过上限
2.  整个设备物理内存占用收到压力按照下面优先级完成清理:
    1. 后台应用>前台应用
    2. 内存占用高的应用>内存占用低的应用
    3. 用户应用>系统应用

Jetsam的代码在开源的XNU代码中可以找到,这里篇幅原因就不具体展开了,具体的源码解析可以参考本文最后第 2 和第 3 篇参考文献。

为什么要监控 OOM 崩溃

前面我们已经了解到,OOM 分为FOOMBOOM两种类型,显然前者因为用户的感知更明显,所以对用户的体验的伤害更大,下文中提到的 OOM 崩溃仅指的是FOOM。那么针对 OOM 崩溃问题有必要建立线上的监控手段吗?

答案是有而且非常有必要的!原因如下:

  1. 重度用户也就是使用时间更长的用户更容易发生FOOM,对这部分用户体验的伤害导致用户流失的话对业务损失更大。
  2. 头条,抖音等多个产品线上数据均显示FOOM量级比普通崩溃还要多,因为过去缺乏有效的监控和治理手段导致问题被长期忽视。
  3. 内存占用过高即使没导致FOOM也可能会导致其他应用BOOM的概率变大,一旦用户发现从微信切换到我们 App 使用,再切回微信没有停留在之前微信的聊天页面而是重新启动的话,对用户来说,体验是非常糟糕的。

OOM 线上监控



翻阅XNU源码的时候我们可以看到在Jetsam机制终止进程的时候最终是通过发送SIGKILL异常信号来完成的。

#define SIGKILL 9 kill (cannot be caught or ignored)

从系统库 signal.h 文件中我们可以找到SIGKILL这个异常信号的解释,它不可以在当前进程被忽略或者被捕获,我们之前监听异常信号的常规 Crash 捕获方案肯定也就不适用了。那我们应该如何监控 OOM 崩溃呢?

正面监控这条路行不通,2015 年的时候Facebook提出了另外一种思路,简而言之就是排除法。具体流程可以参考下面这张流程图:



我们在每次 App 启动的时候判断上一次启动进程终止的原因,那么已知的原因有:

  • App 更新了版本
  • App 发生了崩溃
  • 用户手动退出
  • 操作系统更新了版本
  • App 切换到后台之后进程终止

如果上一次启动进程终止的原因不是上述任何一个已知原因的话,就判定上次启动发生了一次FOOM崩溃。

曾经Facebook旗下的Fabric也是这样实现的。但是通过我们的测试和验证,上述这种方式至少将以下几种场景误判:

  • WatchDog 崩溃
  • 后台启动
  • XCTest/UITest 等自动化测试框架驱动
  • 应用 exit 主动退出

在字节跳动 OOM 崩溃监控上线之前,我们已经排除了上面已知的所有误判场景。需要说明的是,因为排除法毕竟没有直接的监控来的那么精准,或多或少总有一些 bad case,但是我们会保证尽量的准确。

自研线上 Memory Graph,OOM 崩溃率下降 50%+

OOM 生产环境归因

目前在 iOS 端排查内存问题的工具主要包括 Xcode 提供的 Memory Graph 和 Instruments 相关的工具集,它们能够提供相对完备的内存信息,但是应用场景仅限于开发环境,无法在生产环境使用。由于内存问题往往发生在一些极端的使用场景,线下开发测试一般无法覆盖对应的问题,Xcode 提供的工具无法分析处理大多数偶现的疑难问题。

对此,各大公司都提出了自己的线上解决方案,并开源了例如MLeaksFinderOOMDetectorFBRetainCycleDetector等优秀的解决方案。

在字节跳动内部的使用过程中,我们发现现有工具各有侧重,无法完全满足我们的需求。主要的问题集中在以下两点:

  • 基于 Objective-C 对象引用关系找循环引用的方案,适用范围比较小,只能处理部分循环引用问题,而内存问题通常是复杂的,类似于内存堆积,Root Leak,C/C++层问题都无法解决。
  • 基于分配堆栈信息聚类的方案需要常驻运行,对内存、CPU 等资源存在较大消耗,无法针对有内存问题的用户进行监控,只能广撒网,用户体验影响较大。同时,通过某些比较通用的堆栈分配的内存无法定位出实际的内存使用场景,对于循环引用等常见泄漏也无法分析。

为了解决头条,抖音等各产品日益严峻的内存问题,我们自行研发了一款基于内存快照技术的线上方案,我们称之为——线上 Memory Graph。上线后接入了集团内几乎所有的产品,帮助各产品修复了多年的历史问题,OOM 率降低一个数量级,3 个月之内抖音最新版本 OOM 率下降了 50%,头条下降了 60%。线上突发 OOM 问题定位效率大大提升,彻底告别了线上 OOM 问题归因“两眼一抹黑”的时代。

线上 Memory Graph 核心的原理是扫描进程中所有 Dirty 内存,通过内存节点中保存的其他内存节点的地址值建立起内存节点之间的引用关系的有向图,用于内存问题的分析定位,整个过程不使用任何私有 API。这套方案具备的能力如下:


  • 完整还原用户当时的内存状态。
  • 量化线上用户的大内存占用和内存泄漏,可以精确的回答 App 内存到底大在哪里这个问题。
  • 通过内存节点符号和引用关系图回答内存节点为什么存活这个问题。
  • 严格控制性能损耗,只有当内存占用超过异常阈值的时候才会触发分析。没有运行时开销,只有采集时开销,对 99.9%正常使用的用户几乎没有任何影响。
  • 支持主要的编程语言,包括 OC,C/C++,Swift,Rust 等。


  • 内存快照采集

    线上 Memory Graph 采集内存快照主要是为了获取当前运行状态下所有内存对象以及对象之间的引用关系,用于后续的问题分析。主要需要获取的信息如下:

    • 所有内存的节点,以及其符号信息(如OC/Swift/C++ 实例类名,或者是某种有特殊用途的 VM 节点的 tag 等)。
    • 节点之间的引用关系,以及符号信息(偏移,或者实例变量名),OC/Swift成员变量还需要记录引用类型。

    由于采集的过程发生在程序正常运行的过程中,为了保证不会因为采集内存快照导致程序运行异常,整个采集过程需要在一个相对静止的运行环境下完成。因此,整个快照采集的过程大致分为以下几个步骤:

    1. 挂起所有非采集线程。
    2. 获取所有的内存节点,内存对象引用关系以及相应的辅助信息。
    3. 写入文件。
    4. 恢复线程状态。

    下面会分别介绍整个采集过程中一些实现细节上的考量以及收集信息的取舍。

    内存节点的获取

    程序的内存都是由虚拟内存组成的,每一块单独的虚拟内存被称之为VM Region,通过 mach 内核的vm_region_recurse/vm_region_recurse64函数我们可以遍历进程内所有VM Region,并通过vm_region_submap_info_64结构体获取以下信息:

    • 虚拟地址空间中的地址和大小。
    • Dirty 和 Swapped 内存页数,表示该VM Region的真实物理内存使用。
    • 是否可交换,Text 段、共享 mmap 等只读或随时可以被交换出去的内存,无需关注。
    • user_tag,用户标签,用于提供该VM Region的用途的更准确信息。

    大多数 VM Region 作为一个单独的内存节点,仅记录起始地址和 Dirty、Swapped 内存作为大小,以及与其他节点之间的引用关系;而 libmalloc 维护的堆内存所在的 VM Region 则由于往往包含大多数业务逻辑中的 Objective-C 对象、C/C++对象、buffer 等,可以获取更详细的引用信息,因此需要单独处理其内部节点、引用关系。

    在 iOS 系统中为了避免所有的内存分配都使用系统调用产生性能问题,相关的库负责一次申请大块内存,再在其之上进行二次分配并进行管理,提供给小块需要动态分配的内存对象使用,称之为堆内存。程序中使用到绝大多数的动态内存都通过堆进行管理,在 iOS 操作系统上,主要的业务逻辑分配的内存都通过libmalloc进行管理,部分系统库为了性能也会使用自己的单独的堆管理,例如WebKit内核使用bmallocCFNetwork也使用自己独立的堆,在这里我们只关注libmalloc内部的内存管理状态,而不关心其它可能的堆(即这部分特殊内存会以VM Region的粒度存在,不分析其内部的节点引用关系)。

    我们可以通过malloc_get_all_zones获取libmalloc内部所有的zone,并遍历每个zone中管理的内存节点,获取 libmalloc 管理的存活的所有内存节点的指针和大小。

    符号化

    获取所有内存节点之后,我们需要为每个节点找到更加详细的类型名称,用于后续的分析。其中,对于 VM Region 内存节点,我们可以通过 user_tag 赋予它有意义的符号信息;而堆内存对象包含 raw buffer,Objective-C/Swift、C++等对象。对于 Objective-C/Swift、C++这部分,我们通过内存中的一些运行时信息,尝试符号化获取更加详细的信息。

    Objective/Swift 对象的符号化相对比较简单,很多三方库都有类似实现,Swift在内存布局上兼容了Objective-C,也有isa指针,objc相关方法可以作用于两种语言的对象上。只要保证 isa 指针合法,对象实例大小满足条件即可认为正确。

    C++对象根据是否包含虚表可以分成两类。对于不包含虚表的对象,因为缺乏运行时数据,无法进行处理。

    对于对于包含虚表的对象,在调研 mach-o 和 C++的 ABI 文档后,可以通过 std::type_info 和以下几个 section 的信息获取对应的类型信息。


  • type_name string
     - 类名对应的常量字符串,存储在__TEXT/__RODATA段的__const section中。
  • type_info - 存放在__DATA/__DATA_CONST段的__const section中。
  • vtable - 存放在__DATA/__DATA_CONST段的__const section中。

  • 在 iOS 系统内,还有一类特殊的对象,即CoreFoundation。除了我们熟知的CFStringCFDictionary外等,很多很多系统库也使用 CF 对象,比如CGImageCVObject等。从它们的 isa 指针获取的Objective-C类型被统一成__NSCFType。由于 CoreFoundation 类型支持实时的注册、注销类型,为了细化这部分的类型,我们通过逆向拿到 CoreFoundation 维护的类型 slot 数组的位置并读取其数据,保证能够安全的获取准确的类型。


    引用关系的构建

    整个内存快照的核心在于重新构建内存节点之间的引用关系。在虚拟内存中,如果一个内存节点引用了其它内存节点,则对应的内存地址中会存储指向对方的指针值。基于这个事实我们设计了以下方案:

    1. 遍历一个内存节点中所有可能存储了指针的范围获取其存储的值 A。
    2. 搜索所有获得的节点,判断 A 是不是某一个内存节点中任何一个字节的地址,如果是,则认为是一个引用关系。
    3. 对所有内存节点重复以上操作。

    对于一些特定的内存区域,为了获取更详细的信息用于排查问题,我们对栈内存以及 Objective-C/Swift 的堆内存进行了一些额外的处理。

    其中,栈内存也以VM Region的形式存在,栈上保存了临时变量和 TLS 等数据,获取相应的引用信息可以帮助排查诸如 autoreleasepool 造成的内存问题。由于栈并不会使用整个栈内存,为了获取 Stack 的引用关系,我们根据寄存器以及栈内存获取当前的栈可用范围,排除未使用的栈内存造成的无效引用。


    而对于Objective-C/Swift对象,由于运行时包含额外的信息,我们可以获得Ivar的强弱引用关系以及Ivar的名字,带上这些信息有助于我们分析问题。通过获得Ivar的偏移,如果找到的引用关系的偏移和Ivar的偏移一致,则认为这个引用关系就是这个Ivar,可以将Ivar相关的信息附加上去。

    数据上报策略

    我们在 App 内存到达设定值后采集 App 当时的内存节点和引用关系,然后上传至远端进行分析,可以精准的反映 App 当时的内存状态,从而定位问题,总的流程如下:


    整个线上 Memory Graph 模块工作的完整流程如上图所示,主要包括:

    1. 后台线程定时检测内存占用,超过设定的危险阈值后触发内存分析。
    2. 内存分析后数据持久化,等待下次上报。
    3. 原始文件压缩打包。
    4. 检查后端上报许可,因为单个文件很大,后端可能会做一些限流的策略。
    5. 上报到后端分析,如果成功后清除文件,失败后会重试,最多三次之后清除,防止占用用户太多的磁盘空间。

    后台分析

    这是字节监控平台 Memory Graph 单点详情页的一个 case:



    我们可以看到这个用户的内存占用已经将近 900MB,我们分析时候的思路一般是:

    1. 从对象数量和对象内存占用这两个角度尝试找到类列表中最有嫌疑的那个类。
    2. 从对象列表中随机选中某个实例,向它的父节点回溯引用关系,找到你认为最有嫌疑的一条引用路径。
    3. 点击引用路径模块右上角的Add Tag来判断当前选中的引用路径在同类对象中出现过多少次。
    4. 确认有问题的引用路径之后再判断究竟是哪个业务模块发生的问题。


    通过上图中引用路径的分析我们发现,所有的图片最终都被TTImagePickController这个类持有,最终排查到是图片选择器模块一次性把用户相册中的所有图片都加载到内存里,极端情况下会发生这个问题。

    整体性能和稳定性

    采集侧优化策略

    由于整个内存空间一般包含的内存节点从几十万到几千万不等,同时程序的运行状态瞬息万变,采集过程有着很大的性能和稳定性的压力。

    我们在前面的基础上还进行了一些性能优化:

    • 写出采集数据使用mmap映射,并自定义二进制格式保证顺序读写。
    • 提前对内存节点进行排序,建立边引用关系时使用二分查找。通过位运算对一些非法内存地址进行提前快速剪枝。

    对于稳定性部分,我们着重考虑了下面几点:

    • 死锁

    由于无法保证 Objective-C 运行时锁的状态,我们将需要通过运行时 api 获取的信息在挂起线程前提前缓存。同时,为了保证libmalloc锁的状态安全,在挂起线程后我们对 libmalloc 的锁状态进行了判断,如果已经锁住则恢复线程重新尝试挂起,避免堆死锁。

    • 非法内存访问

    在挂起所有其他线程后,为了减少采集本身分配的内存对采集的影响,我们使用了一个单独的malloc_zone管理采集模块的内存使用。

    性能损耗

    因为在数据采集的时候需要挂起所有线程,会导致用户感知到卡顿,所以字节模块还是有一定性能损耗的,经过我们测试,在iPhone8 Plus设备上,App 占用 1G 内存时,采集用时 1.5-2 秒,采集时额外内存消耗 10-20MB,生成的文件 zip 后大小在 5-20MB。

    为了严格控制性能损耗,线上 Memory Graph 模块会应用以下策略,避免太频繁的触发打扰用户正常使用,避免自身内存和磁盘等资源过多的占用:



    稳定性

    该方案已经在字节全系产品线上稳定运行了 6 个月以上,稳定性和成功率得到了验证,目前单次采集成功率可以达到 99.5%,剩下的失败基本都是由于内存紧张提前 OOM,考虑到大多数应用只有不到千分之一的用户会触发采集,这种情况属于极低概率事件。

    试用路径

    目前,线上 Memory Graph 已搭载在字节跳动火山引擎旗下应用性能管理平台(APMInsight)上赋能给外部开发者使用。

    APMInsight 的相关技术经过今日头条、抖音、西瓜视频等众多应用的打磨,已沉淀出一套完整的解决方案,能够定位移动端、浏览器、小程序等多端问题,除了支持崩溃、错误、卡顿、网络等基础问题的分析,还提供关联到应用启动、页面浏览、内存优化的众多功能。

    摘自字节跳动技术团队 :https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247486858&idx=1&sn=ec5964b0248b3526836712b26ef1b077&chksm=e9d0c668dea74f7e1e16cd5d65d1436c28c18e80e32bbf9703771bd4e0563f64723294ba1324&cur_album_id=1590407423234719749&scene=190#rd




    收起阅读 »

    如何清晰地掌握 Android 应用中后台任务的执行情况?

    Android Studio 包含了许多像 布局检查器 和 数据库检查器 这样的检查器,来帮助您调查并了解应用在运行时的内部状态。在 Android Studio Arctic Fox 中,我们发布了一个新的检查器 (Background Task Inspe...
    继续阅读 »

    Android Studio 包含了许多像 布局检查器数据库检查器 这样的检查器,来帮助您调查并了解应用在运行时的内部状态。在 Android Studio Arctic Fox 中,我们发布了一个新的检查器 (Background Task Inspector),用于帮助您监控和调试在应用中使用 WorkManager 2.5.0 或更高版本所调度的 Worker。


    对于运行后台的异步任务,甚至是在应用被关闭之后的情况下,都推荐使用 WorkManager。虽然可以很方便的将任务配置成 WorkManager 的 Worker,但将 Worker 加入到队列中后就很难监控它的执行情况,遇到问题也不方便调试。


    您可以通过后台任务检查器轻松地监控一个 Worker 的工作状态,查看它和与其链接的其他 Worker 的关系,或者检查 Worker 的输出、频率及其他与时间相关的信息。让我们通过一个示例项目来看看后台任务检查器能做些什么。


    我将使用 architectural-components 仓库 中的 WorkManager 示例应用来演示后台任务检查器 (需要将工程中 versions.gradle 里的 versions.work 设置为 2.5.0 或更高版本以使得 Background Task Inspect 更好的工作)。如果您想试一试,可以检出该仓库并随着阅读文章一起尝试。该应用使用 WorkManager 将用户所选择的滤镜应用在已挑选的照片上。用户可以通过该应用在图库中选择一张图片或者简单地使用一张库存照片。为了演示后台任务检查器如何工作,我将会运行应用并选择一张图片来应用滤镜。


    △ 为选定的图像应用所选的滤镜


    △ 为选定的图像应用所选的滤镜


    这些滤镜都是作为 WorkManager Worker 实现的。稍等一会儿该应用就会展示应用了所选滤镜的图片。在不了解示例应用的情况下,来看看我还能通过后台任务检查器知道些什么。


    选择菜单栏上的 View > Tool Windows > App Inspection 打开后台任务检查器。


    △ View > Tool Windows > App Inspection


    △ View > Tool Windows > App Inspection


    在 App Inspection (应用检查) 窗口中选择 Background Task Inspector 栏后,我在 API 级别 26 或更高的设备/模拟器上再次运行该应用。如果没有自动选中应用,在下拉菜单中选择应用进程。连接到应用进程后,就可以回到我正在运行的应用,选择所有的滤镜并点击 "APPLY"。此时我可以在后台任务检查器中看到运行中的作业列表。


    △ 正在运行的作业列表


    △ 正在运行的作业列表


    后台任务检查器列出了所有正在运行、已失败和已完成作业的类名、当前状态、开始时间、重试次数以及输出数据。点击列表中的一个作业打开 Work Details 面板。


    △ Work Details 面板


    △ Work Details 面板


    该面板提供了 Worker 的 Description (描述)、Execution (执行情况)、WorkContinuation (工作延续性) 和 Results (结果)。让我们来仔细看看每个部分。


    △ Work Details


    △ Work Details


    Description (描述) 一节列出了 Worker 包含完全限定软件包名称、指派给它的标签和它的 UUID。


    △ Execution


    △ Execution


    接下来,Execution (执行情况) 一节展示了 Worker 的约束 (如果有)、运行频率、状态以及是哪个类创建了该 worker 并将其加入了队列。


    △ WorkContinuation


    △ WorkContinuation


    WorkContinuation (工作延续性) 一节显示了该 Worker 在工作链上的位置。您可以检查前一个、后一个或工作链上的其他 Worker (如果有)。您可以通过点击另一个 Worker 的 UUID 导航到它的详情。在这个工作链上,我能看到应用使用了 5 个不同的 Worker。Worker 的数量根据用户选择的滤镜情况可能有所不同。


    这是个很棒的功能,但当您面对不熟悉的应用时不一定能想象出工作链。而后台任务检查器另一个很棒的特性就是它能够以图形化的形式展示工作链。仅需点击 WorkContinuation 一节中的 "Show in graph" 链接或点击作业列表顶部的 "show Graph View" 按钮来切换到 Graph View 即可。


    △ Graph View


    △ Graph View


    Graph View 能帮您了解 Worker 的顺序、在不同阶段之间传递的数据以及它们各自的状态。


    △ Results


    △ Results


    Work Details 面板的最后一节是 Results 窗格。在这一节您能看到选中的 Worker 的开始时间、重试次数及输出数据。


    现在假设我想测试当一个 Worker 停止时会发生什么。为了实现这个目的,我将再次运行应用,选择 Worker,等它的状态变为正在运行后点击左上角的 "Cancel Selected Work" 按钮。一旦我这么做了,我选择的 Worker 和链中剩余的 Worker 的状态都将变为 Canceled。


    △ 您可以取消任何正在运行的 Worker


    △ 您可以取消任何正在运行的 Worker


    如果您的应用中包含像这样复杂的链式关系,那 Graph View 就会很有用。您能够在这个图中快速查看一组复杂的 Worker 之间的关系并监控它们的进展。


    △ WorkManager 艺术展示 =)


    △ WorkManager 艺术展示 =)


    如果您想用后台任务检查器尝试一些更复杂的图形或者制作一些 WorkManager 艺术,请参阅 DummyWorker 代码,并将其 加入到 continuation 对象 中。


    后台任务检查器将会跟随 Android Studio Arctic Fox 的发布一同推出,但您现在就可以在 最新的 Arctic Fox 版本 中试用!如果您的应用使用了 WorkManager,请尝试使用并告诉我们您的想法,或者和我们分享您的 WorkManager 艺术!



    收起阅读 »

    它来了!Flutter 应用内调试工具 UME 开源啦

    作者:字节跳动终端技术 —— 赵瑞 先说重点 Pub 地址:pub.dev/packages/fl… GitHub 地址:github.com/bytedance/f… 背景 字节跳动已有累计超过 70 款 App 使用了 Flutter...
    继续阅读 »


    作者:字节跳动终端技术 —— 赵瑞


    先说重点



    背景


    字节跳动已有累计超过 70 款 App 使用了 Flutter 技术,公司内有超过 600 位 Flutter 开发者。在这一数字背后,有一条完整的 Flutter 基础设施链路作为支撑。


    UME 是由字节跳动 Flutter Infra 团队出品的 Flutter 应用内调试工具,目的是在脱离 Flutter IDE 与 DevTools 的情况下,提供应用内的调试功能。


    在字节跳动,UME 内部版已打磨了近一年时间,服务了近二十个 App,众多插件功能广受开发者好评。本次发布的开源版 UME 提供了 10 个不同功能的调试插件,覆盖 UI 检查、性能工具、代码查看、日志查看等众多功能。无论是设计师、产品经理、研发工程师或质量工程师,都能直接从应用内获取有用信息,从而提升整个团队的研发、调试与验收效率。


    功能介绍


    UI 插件包



















    Widget 信息 Widget 详情
    颜色吸管 对齐标尺

    UI 检查插件包,提供了通过点选 widget 获取 Widget 基本信息、代码所在目录、Widget 层级结构、RenderObject 的 build 链与描述的能力,颜色吸管与对齐标尺在视觉验收环节提供有力帮助。


    代码查看


    代码查看


    代码查看插件,默认基于 WidgetInspectorService 提取 creationLocation, 拿到当前页面的 library,再通过 VM Service 获取对应代码内容。


    允许用户输入关键字,通过遍历 scriptList 对 library 进行模糊匹配,实现对任意代码内容的查看能力。


    日志展示


    日志展示


    通过重定向 foundation 包中的 debugPrint,实现对日志输出函数的插桩处理,并记录日志输出时间等额外信息,通过统一面板提供筛选、导出等功能。


    性能插件包















    性能浮层 内存信息

    性能插件包将 Flutter 官方提供的性能浮层引入,实现脱离 DevTools 查看性能浮层的能力;内存信息方面提供了当前 VM 对象实例数量与内存占用大小等信息。


    设备信息插件包















    设备信息 CPU 信息

    设备信息插件展示了 device_info Plugin 提供的信息;CPU 信息插件基于 system_info Plugin,直接从 Dart 层读取系统基础信息。


    开发自定义插件


    除了上述的 UME 内置插件外,开发者可以基于 UME 提供的统一插件管理与基础服务,开发适合自己业务的插件包。


    实现方式也非常简单,只需要实现 Pluggable 虚类中的方法,提供插件必要信息即可,代码示例如下图。


    自定义插件


    开发者可以参考开源仓库中的 custom_plugin_example 示例,以及 kits 目录下的所有插件包代码,来了解如何实现一个自定义插件包。


    访问基础服务


    为简化插件开发,提高代码复用性,UME 封装了 Inspector、VM Service 等作为基础服务,插件可方便地拓展能力。


    VM Service mixin


    除此之外,UME 还提供了 FloatingWidget 通用浮窗容器、StoreMixin 存储能力等,供插件使用。


    欢迎参与开源贡献与共建


    由于很多功能依赖引擎及工具链的改动,因此开源版的 UME 相比于公司内部版本在功能上进行了很多精简。但开发团队也在不断寻求解决方案,避免修改引擎,或将改动合入官方仓库,将更多实用功能引入开源版 UME。


    我们鼓励广大开发者,参与到 UME 的开发与生态建设上。比如开发更多实用的插件并贡献给社区,在 GitHub Issues 上提交功能建议、问题反馈,或修复问题并提交 Pull Request。


    欢迎各位开发者加入字节跳动 Flutter 技术交流群参与技术交流与讨论。


    关于字节终端技术团队


    字节跳动终端技术团队(Client Infrastructure)是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、瓜瓜龙等,在移动端、Web、Desktop等各终端都有深入研究。

    收起阅读 »

    自如客APP裸眼3D效果的实现

    3d
    背景 移动端界面设计如此火热的今天,各类大厂的设计规范和设计语言已经非常的成熟,我们想做一些在这套成熟的设计规范之外的尝试和创新,所以有别于传统的banner交互形式成为了我们的发力点。 设计理念 由于app版面空间有限,除了功能导向、阅读习惯和设计美观...
    继续阅读 »

    我的影片 1.2021-07-26 19_41_36.gif


    背景


    移动端界面设计如此火热的今天,各类大厂的设计规范和设计语言已经非常的成熟,我们想做一些在这套成熟的设计规范之外的尝试和创新,所以有别于传统的banner交互形式成为了我们的发力点。


    设计理念


    由于app版面空间有限,除了功能导向、阅读习惯和设计美观外,自如想在既定的框下,做一下不同的设计尝试,哪怕这种尝试只能提升用户1%的观感。可能租了几年自如的房子,用了几年自如客app,你可能也不会注意到一些小的细节。但如果哪天,作为用户的你突然发现了这个隐藏的“彩蛋”,看到了自如在这些小细节上的用心,我相信那天你将会对自如这个品牌有更深层次的认识和理解。


    裸眼3D技术一般都是应用在裸眼3D大屏、全息投影等等比较常见的场景中,在APP的banner上应用,的确也是一次全新的尝试。我们通过借助移动设备上的传感器、以及自身的屏显清晰度、画面呈现,将2D影像转化为景深效果,以呈现出不用"3D"眼镜就可看到的3D效果。


    实现方式


    以下以Android为例,介绍一下该效果的实现方式。


    分层


    自如客app的banner其实一直在创新当中,有专门注意过的同学可能知道,在裸眼3D效果之前,自如客app其实就已经实现了分层,当时为了实现更加自然和精致的切换效果:在每个banner滑入滑出的时候,底部其实会在原地进行渐显渐隐,内容会跟随手势滑入滑出。此次为了实现3D效果,我们在以前分层的基础上加了一层中景,将原有的前景拆分为前景和中景。


    image.png


    上图的sl_bg为背景,pv_middle为中景,sl为前景


    由于切换的交互,实际上banner使用了两个viewpager进行了联动。背景在最底层的viewpager里面,中景和前景在另外一个viewpager里。


    跟手位移


    打开自如客app后,用户操作设备可以明显感受到画面的错位移动,造成视觉上的景深效果。这种错位移动其实就是借助设备本身的传感器来实现的,具体实现方式是我们让中景始终保持不动,同时从设备传感器获取当前设备对应的倾斜角,根据倾斜角计算出背景和前景的移动距离,然后执行背景和前景移动的动作。如下图所示:


    image.png


    为了使用的方便,我们封装了一个SensorLayout,专门用于根据设备的倾斜角执行内容的位移; SensorLayout内部的主要实现:


    注册对应的传感器


    mSensorManager = (SensorManager) getContext().getSystemService(Context.SENSOR_SERVICE);
    // 重力传感器
    mAcceleSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
    // 地磁场传感器
    mMagneticSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);

    mSensorManager.registerListener(this, mAcceleSensor, SensorManager.SENSOR_DELAY_GAME);
    mSensorManager.registerListener(this, mMagneticSensor, SensorManager.SENSOR_DELAY_GAME);

    计算偏转角度


    if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
    mAcceleValues = event.values;
    }
    if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
    mMageneticValues = event.values;
    }

    float[] values = new float[3];
    float[] R = new float[9];
    SensorManager.getRotationMatrix(R, null, mAcceleValues, mMageneticValues);
    SensorManager.getOrientation(R, values);
    // x轴的偏转角度
    values[1] = (float) Math.toDegrees(values[1]);
    // y轴的偏转角度
    values[2] = (float) Math.toDegrees(values[2]);


    通过重力传感器和地磁场传感器,获取设备的偏转角度


    根据偏转角度执行滑动


    if (mDegreeY <= 0 && mDegreeY > mDegreeYMin) {
    hasChangeX = true;
    scrollX = (int) (mDegreeY / Math.abs(mDegreeYMin) * mXMoveDistance*mDirection);
    } else if (mDegreeY > 0 && mDegreeY < mDegreeYMax) {
    hasChangeX = true;
    scrollX = (int) (mDegreeY / Math.abs(mDegreeYMax) * mXMoveDistance*mDirection);
    }
    if (mDegreeX <= 0 && mDegreeX > mDegreeXMin) {
    hasChangeY = true;
    scrollY = (int) (mDegreeX / Math.abs(mDegreeXMin) * mYMoveDistance*mDirection);
    } else if (mDegreeX > 0 && mDegreeX < mDegreeXMax) {
    hasChangeY = true;
    scrollY = (int) (mDegreeX / Math.abs(mDegreeXMax) * mYMoveDistance*mDirection);
    }
    smoothScrollTo(hasChangeX ? scrollX : mScroller.getFinalX(), hasChangeY ? scrollY : mScroller.getFinalY());

    mDegreeX即为第二部中获取的偏转角度,mDegreeXMin和mDegreeXMax为X轴可发生偏转位移的角度最大值和最小值,mYMoveDistance即为Y轴上的最大偏移距离(围绕X轴发生旋转,视图会沿Y轴上发生位移);Y轴上的偏转同理;就算好X轴和Y轴的偏移距离后,使用scroller进行滑动;


    实现总结


    读到这里,相信大家已经基本了解了这套banner的实现方案。Android端在布局上进行了分层,中景位置不变,借助重力传感器和地磁场传感器获取偏转角度,根据角度使背景和前景进行错位移动。iOS端的实现原理也基本一致,不再赘述。



    本文作者:自如大前端研发中心-黄进




    收起阅读 »

    带你了解SSO登录过程

    什么是单点登录? 单点登录(Single Sign On),简称为SSO,是比较流行的企业业务整合的解决方案之一。 SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。 上图为sso的登录方式,对比传统登录方式,sso只做...
    继续阅读 »

    什么是单点登录?



    单点登录(Single Sign On),简称为SSO,是比较流行的企业业务整合的解决方案之一。 SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。




    上图为sso的登录方式,对比传统登录方式,sso只做一次身份验证,而传统需要做多次登录。下图为传统登录方式。



    登录类型



    1. 无登录状态。需要用户登录。

    2. 已登录app1,再次登录app1。(token有效)无需用户登录

    3. 已登录app1,登录app2。(有登录状态)无需用户登录


    登录原理图


    1. 无登录状态登录图,入下图:



    2. 再次登录app1



    3. 登录app2, 由于app1等中,中心服务sso已经生成了登录状态TGC,app2就不需要扫码登录。



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

    收起阅读 »

    JS 解决超出精度数字问题

    一、js 最大安全数字是 Math.pow(2,53) - 1,超出这个数字相加会出现精度丢失问题,可通过将数字转换为字符串操作的思路处理,如下: // js 最大安全数字: Math.pow(2, 53)-1 let a = '12345644456545...
    继续阅读 »

    一、js 最大安全数字是 Math.pow(2,53) - 1,超出这个数字相加会出现精度丢失问题,可通过将数字转换为字符串操作的思路处理,如下:


    // js 最大安全数字: Math.pow(2, 53)-1

    let a = '123456444565456.889'
    let b = '121231456.32'
    // a + b = '123456565796913.209'

    function addTwo(a, b) {
    //1.比较两个数长度 然后短的一方前面补0
    if (a.length > b.length) {
    let arr = Array(a.length - b.length).fill(0);
    b = arr.join('') + b
    } else if (a.length < b.length) {
    let arr = Array(b.length - a.length).fill(0);
    a = arr.join('') + a
    }

    //2.反转两个数 (这里是因为人习惯从左往右加 而数字相加是从右到左 因此反转一下比较好理解)
    a = a.split('').reverse();
    b = b.split('').reverse();

    //3.循环两个数组 并进行相加 如果和大于10 则 sign = 1,当前位置的值为(和)
    let sign = 0;//标记 是否进位
    let newVal = [];//用于存储最后的结果
    for (let j = 0; j < a.length; j++) {
    let val = a[j] / 1 + b[j] / 1 + sign; //除1是保证都为数字 这里也可以用Number()
    if (val >= 10) {
    sign = 1;
    newVal.unshift(val % 10) //这里用unshift而不是push是因为可以省了使用reverse
    } else {
    sign = 0;
    newVal.unshift(val)
    }
    }

    // 最后一次相加需要向前补充一位数字 ‘1’
    return sign && newVal.unshift(sign) && newVal.join('') || newVal.join('')
    }


    // 参考其他朋友的精简写法
    function addTwo(a,b) {
    let temp = 0
    let res = ''
    a = a.split('')
    b = b.split('')
    while(a.length || b.length || temp) {
    temp += Number(a.pop() || 0) + Number(b.pop() || 0)
    res = (temp) + res
    temp = temp > 9
    }
    return res.replace(/^0+/g, '')
    }

    二、当涉及到带有小数部分相加时,对上面方法进行一次封装,完整实现如下:


    let a = '123456444565456.889'
    let b = '121231456.32'
    // a + b = '123456565796913.209'

    function addTwo(a = '0',b = '0', isHasDecimal=false) {
    //1.比较两个数长度 然后短的一方前面补0
    if (a.length > b.length) {
    let arr = Array(a.length - b.length).fill(0);
    b = isHasDecimal && (b + arr.join('')) || arr.join('') + b
    } else if (a.length < b.length) {
    let arr = Array(b.length - a.length).fill(0);
    a = isHasDecimal && (a + arr.join('')) || arr.join('') + a
    }

    //2.反转两个数 (这里是因为人习惯从左往右加 而数字相加是从右到左 因此反转一下比较好理解)
    a = a.split('').reverse();
    b = b.split('').reverse();


    //3.循环两个数组 并进行相加 如果和大于10 则 sign = 1,当前位置的值为(和)
    let sign = 0;//标记 是否进位
    let newVal = [];//用于存储最后的结果
    for (let j = 0; j < a.length; j++) {
    let val = a[j] / 1 + b[j] / 1 + sign; //除1是保证都为数字 这里也可以用Number()
    if (val >= 10) {
    sign = 1;
    newVal.unshift(val % 10) //这里用unshift而不是push是因为可以省了使用reverse
    } else {
    sign = 0;
    newVal.unshift(val)
    }
    }

    // 最后一次相加需要向前补充一位数字 ‘1’
    return sign && newVal.unshift(sign) && newVal.join('') || newVal.join('')
    }

    function add(a,b) {
    let num1 = String(a).split('.')
    let num2 = String(b).split('.')
    let intSum = addTwo(num1[0], num2[0])
    let res = intSum

    if (num1.length>1 || num2.length > 1) {
    let decimalSum = addTwo(num1[1], num2[1], true)

    if (decimalSum.length > (num1[1]||'0').length && decimalSum.length > (num2[1]||'0').length) {
    intSum = addTwo(intSum, decimalSum[0])
    decimalSum = decimalSum.slice(1)
    res = `${intSum}.${decimalSum}`
    } else {
    res = `${intSum}.${decimalSum}`
    }
    }
    return res
    }
    console.log(add(a, b)) // 123456565796913.209
    // console.log(add('325', '988')) // 1313

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

    收起阅读 »

    文件下载,搞懂这9种场景就够了(下)

    六、附件形式下载在服务端下载的场景中,附件形式下载是一种比较常见的场景。在该场景下,我们通过设置 Content-Disposition 响应头来指示响应的内容以何种形式展示,是以内联(inline)的形式,还是以附件(attachment...
    继续阅读 »

    六、附件形式下载

    在服务端下载的场景中,附件形式下载是一种比较常见的场景。在该场景下,我们通过设置 Content-Disposition 响应头来指示响应的内容以何种形式展示,是以内联(inline)的形式,还是以附件(attachment)的形式下载并保存到本地。

    Content-Disposition: inline
    Content-Disposition: attachment
    Content-Disposition: attachment; filename="mouth.png"

    而在 HTTP 表单的场景下, Content-Disposition 也可以作为 multipart body 中的消息头:

    Content-Disposition: form-data
    Content-Disposition: form-data; name="fieldName"
    Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"

    第 1 个参数总是固定不变的 form-data;附加的参数不区分大小写,并且拥有参数值,参数名与参数值用等号(=)连接,参数值用双引号括起来。参数之间用分号(;)分隔。

    了解完 Content-Disposition 的作用之后,我们来看一下如何实现以附件形式下载的功能。Koa 是一个简单易用的 Web 框架,它的特点是优雅、简洁、轻量、自由度高。所以我们选择它来搭建文件服务,并使用 @koa/router 中间件来处理路由:

    // attachment/file-server.js
    const fs = require("fs");
    const path = require("path");
    const Koa = require("koa");
    const Router = require("@koa/router");

    const app = new Koa();
    const router = new Router();
    const PORT = 3000;
    const STATIC_PATH = path.join(__dirname, "./static/");

    // http://localhost:3000/file?filename=mouth.png
    router.get("/file", async (ctx, next) => {
    const { filename } = ctx.query;
    const filePath = STATIC_PATH + filename;
    const fStats = fs.statSync(filePath);
    ctx.set({
    "Content-Type": "application/octet-stream",
    "Content-Disposition": `attachment; filename=${filename}`,
    "Content-Length": fStats.size,
    });
    ctx.body = fs.createReadStream(filePath);
    });

    // 注册中间件
    app.use(async (ctx, next) => {
    try {
    await next();
    } catch (error) {
    // ENOENT(无此文件或目录):通常是由文件操作引起的,这表明在给定的路径上无法找到任何文件或目录
    ctx.status = error.code === "ENOENT" ? 404 : 500;
    ctx.body = error.code === "ENOENT" ? "文件不存在" : "服务器开小差";
    }
    });
    app.use(router.routes()).use(router.allowedMethods());

    app.listen(PORT, () => {
    console.log(`应用已经启动:http://localhost:${PORT}/`);
    });

    以上的代码被保存在 attachment 目录下的 file-server.js 文件中,该目录下还有一个 static 子目录用于存放静态资源。目前 static 目录下包含以下 3 个 png 文件。

    ├── file-server.js
    └── static
    ├── body.png
    ├── eyes.png
    └── mouth.png

    当你运行 node file-server.js 命令成功启动文件服务器之后,就可以通过正确的 URL 地址来下载 static 目录下的文件。比如在浏览器中打开 http://localhost:3000/file?filename=mouth.png 这个地址,你就会开始下载 mouth.png 文件。而如果指定的文件不存在的话,就会返回文件不存在。

    Koa 内核很简洁,扩展功能都是通过中间件来实现。比如常用的路由、CORS、静态资源处理等功能都是通过中间件实现。因此要想掌握 Koa 这个框架,核心是掌握它的中间件机制。若你想深入了解 Koa 的话,可以阅读 如何更好地理解中间件和洋葱模型 这篇文章。

    在编写 HTML 网页时,对于一些简单图片,通常会选择将图片内容直接内嵌在网页中,从而减少不必要的网络请求,但是图片数据是二进制数据,该怎么嵌入呢?绝大多数现代浏览器都支持一种名为 Data URLs 的特性,允许使用 Base64 对图片或其他文件的二进制数据进行编码,将其作为文本字符串嵌入网页中。所以文件也可以通过 Base64 的格式进行传输,接下来我们将介绍如何下载 Base64 格式的图片。

    附件形式下载示例:attachment

    github.com/semlinker/f…

    七、base64 格式下载

    Base64 是一种基于 64 个可打印字符来表示二进制数据的表示方法。由于 2⁶ = 64 ,所以每 6 个比特为一个单元,对应某个可打印字符。3 个字节有 24 个比特,对应于 4 个 base64 单元,即 3 个字节可由 4 个可打印字符来表示。相应的转换过程如下图所示:

    Base64 常用在处理文本数据的场合,表示、传输、存储一些二进制数据,包括 MIME 的电子邮件及 XML 的一些复杂数据。 在 MIME 格式的电子邮件中,base64 可以用来将二进制的字节序列数据编码成 ASCII 字符序列构成的文本。使用时,在传输编码方式中指定 base64。使用的字符包括大小写拉丁字母各 26 个、数字 10 个、加号 + 和斜杠 /,共 64 个字符,等号 = 用来作为后缀用途。

    Base64 的相关内容就先介绍到这,如果你想进一步了解 Base64 的话,可以阅读 一文读懂base64编码 这篇文章。下面我们来看一下具体实现代码:

    7.1 前端代码

    html

    在以下 HTML 代码中,我们通过 select 元素来让用户选择要下载的图片。当用户切换不同的图片时,img#imgPreview 元素中显示的图片会随之发生变化。

    <h3>base64 下载示例</h3>
    <img id="imgPreview" src="./static/body.png" />
    <select id="picSelect">
    <option value="body">body.png</option>
    <option value="eyes">eyes.png</option>
    <option value="mouth">mouth.png</option>
    </select>
    <button onclick="download()">下载</button>

    js

    const picSelectEle = document.querySelector("#picSelect");
    const imgPreviewEle = document.querySelector("#imgPreview");

    picSelectEle.addEventListener("change", (event) => {
    imgPreviewEle.src = "./static/" + picSelectEle.value + ".png";
    });

    const request = axios.create({
    baseURL: "http://localhost:3000",
    timeout: 60000,
    });

    async function download() {
    const response = await request.get("/file", {
    params: {
    filename: picSelectEle.value + ".png",
    },
    });
    if (response && response.data && response.data.code === 1) {
    const fileData = response.data.data;
    const { name, type, content } = fileData;
    const imgBlob = base64ToBlob(content, type);
    saveAs(imgBlob, name);
    }
    }

    在用户选择好需要下载的图片并点击下载按钮时,就会调用以上代码中的 download 函数。在该函数内部,我们利用 axios 实例的 get 方法发起 HTTP 请求来获取指定的图片。因为返回的是 base64 格式的图片,所以在调用 FileSaver 提供的 saveAs 方法前,我们需要将 base64 字符串转换成 blob 对象,该转换是通过以下的 base64ToBlob 函数来完成,该函数的具体实现如下所示:

    function base64ToBlob(base64, mimeType) {
    let bytes = window.atob(base64);
    let ab = new ArrayBuffer(bytes.length);
    let ia = new Uint8Array(ab);
    for (let i = 0; i < bytes.length; i++) {
    ia[i] = bytes.charCodeAt(i);
    }
    return new Blob([ab], { type: mimeType });
    }

    7.2 服务端代码

    // base64/file-server.js
    const fs = require("fs");
    const path = require("path");
    const mime = require("mime");
    const Koa = require("koa");
    const cors = require("@koa/cors");
    const Router = require("@koa/router");

    const app = new Koa();
    const router = new Router();
    const PORT = 3000;
    const STATIC_PATH = path.join(__dirname, "./static/");

    router.get("/file", async (ctx, next) => {
    const { filename } = ctx.query;
    const filePath = STATIC_PATH + filename;
    const fileBuffer = fs.readFileSync(filePath);
    ctx.body = {
    code: 1,
    data: {
    name: filename,
    type: mime.getType(filename),
    content: fileBuffer.toString("base64"),
    },
    };
    });

    // 注册中间件
    app.use(async (ctx, next) => {
    try {
    await next();
    } catch (error) {
    ctx.body = {
    code: 0,
    msg: "服务器开小差",
    };
    }
    });
    app.use(cors());
    app.use(router.routes()).use(router.allowedMethods());

    app.listen(PORT, () => {
    console.log(`应用已经启动:http://localhost:${PORT}/`);
    });

    在以上代码中,对图片进行 Base64 编码的操作是定义在 /file 路由对应的路由处理器中。当该服务器接收到客户端发起的文件下载请求,比如 GET /file?filename=body.png HTTP/1.1 时,就会从 ctx.query 对象上获取 filename 参数。该参数表示文件的名称,在获取到文件的名称之后,我们就可以拼接出文件的绝对路径,然后通过 Node.js 平台提供的 fs.readFileSync 方法读取文件的内容,该方法会返回一个 Buffer 对象。在成功读取文件的内容之后,我们会继续调用 Buffer 对象的 toString 方法对文件内容进行 Base64 编码,最终所下载的图片将以 Base64 格式返回到客户端。

    base64 格式下载示例:base64

    github.com/semlinker/f…

    八、chunked 下载

    分块传输编码主要应用于如下场景,即要传输大量的数据,但是在请求在没有被处理完之前响应的长度是无法获得的。例如,当需要用从数据库中查询获得的数据生成一个大的 HTML 表格的时候,或者需要传输大量的图片的时候。

    要使用分块传输编码,则需要在响应头配置 Transfer-Encoding 字段,并设置它的值为 chunked 或 gzip, chunked

    Transfer-Encoding: chunked
    Transfer-Encoding: gzip, chunked

    响应头 Transfer-Encoding 字段的值为 chunked,表示数据以一系列分块的形式进行发送。需要注意的是 Transfer-Encoding 和 Content-Length 这两个字段是互斥的,也就是说响应报文中这两个字段不能同时出现。下面我们来看一下分块传输的编码规则:

    • 每个分块包含分块长度和数据块两个部分;
    • 分块长度使用 16 进制数字表示,以 \r\n 结尾;
    • 数据块紧跟在分块长度后面,也使用 \r\n 结尾,但数据不包含 \r\n
    • 终止块是一个常规的分块,表示块的结束。不同之处在于其长度为 0,即 0\r\n\r\n

    了解完分块传输的编码规则,我们来看如何利用分块传输编码实现文件下载。

    8.1 前端代码

    html5

    <h3>chunked 下载示例</h3>
    <button onclick="download()">下载</button>

    js

    const chunkedUrl = "http://localhost:3000/file?filename=file.txt";

    function download() {
    return fetch(chunkedUrl)
    .then(processChunkedResponse)
    .then(onChunkedResponseComplete)
    .catch(onChunkedResponseError);
    }

    function processChunkedResponse(response) {
    let text = "";
    let reader = response.body.getReader();
    let decoder = new TextDecoder();

    return readChunk();

    function readChunk() {
    return reader.read().then(appendChunks);
    }

    function appendChunks(result) {
    let chunk = decoder.decode(result.value || new Uint8Array(), {
    stream: !result.done,
    });
    console.log("已接收到的数据:", chunk);
    console.log("本次已成功接收", chunk.length, "bytes");
    text += chunk;
    console.log("目前为止共接收", text.length, "bytes\n");
    if (result.done) {
    return text;
    } else {
    return readChunk();
    }
    }
    }

    function onChunkedResponseComplete(result) {
    let blob = new Blob([result], {
    type: "text/plain;charset=utf-8",
    });
    saveAs(blob, "hello.txt");
    }

    function onChunkedResponseError(err) {
    console.error(err);
    }

    当用户点击 下载 按钮时,就会调用以上代码中的 download 函数。在该函数内部,我们会使用 Fetch API 来执行下载操作。因为服务端的数据是以一系列分块的形式进行发送,所以在浏览器端我们是通过流的形式进行接收。即通过 response.body 获取可读的 ReadableStream,然后用 ReadableStream.getReader() 创建一个读取器,最后调用 reader.read 方法来读取已返回的分块数据。

    因为 file.txt 文件的内容是普通文本,且 result.value 的值是 Uint8Array 类型的数据,所以在处理返回的分块数据时,我们使用了 TextDecoder 文本解码器。一个解码器只支持一种特定文本编码,例如 utf-8iso-8859-2koi8cp1261gbk 等等。

    如果收到的分块非 终止块result.done 的值是 false,则会继续调用 readChunk 方法来读取分块数据。而当接收到 终止块 之后,表示分块数据已传输完成。此时,result.done 属性就会返回 true。从而会自动调用 onChunkedResponseComplete 函数,在该函数内部,我们以解码后的文本作为参数来创建 Blob 对象。之后,继续使用 FileSaver 库提供的 saveAs 方法实现文件下载。

    这里我们用 Wireshark 网络包分析工具,抓了个数据包。具体如下图所示:

    从图中我们可以清楚地看到在 HTTP chunked response 下面包含了 Data chunk(数据块) 和 End of chunked encoding(终止块)。接下来,我们来看一下服务端的代码。

    8.2 服务端代码

    const fs = require("fs");
    const path = require("path");
    const Koa = require("koa");
    const cors = require("@koa/cors");
    const Router = require("@koa/router");

    const app = new Koa();
    const router = new Router();
    const PORT = 3000;

    router.get("/file", async (ctx, next) => {
    const { filename } = ctx.query;
    const filePath = path.join(__dirname, filename);
    ctx.set({
    "Content-Type": "text/plain;charset=utf-8",
    });
    ctx.body = fs.createReadStream(filePath);
    });

    // 注册中间件
    app.use(async (ctx, next) => {
    try {
    await next();
    } catch (error) {
    // ENOENT(无此文件或目录):通常是由文件操作引起的,这表明在给定的路径上无法找到任何文件或目录
    ctx.status = error.code === "ENOENT" ? 404 : 500;
    ctx.body = error.code === "ENOENT" ? "文件不存在" : "服务器开小差";
    }
    });
    app.use(cors());
    app.use(router.routes()).use(router.allowedMethods());

    app.listen(PORT, () => {
    console.log(`应用已经启动:http://localhost:${PORT}/`);
    });

    在 /file 路由处理器中,我们先通过 ctx.query 获得 filename 文件名,接着拼接出该文件的绝对路径,然后通过 Node.js 平台提供的 fs.createReadStream 方法创建可读流。最后把已创建的可读流赋值给 ctx.body 属性,从而向客户端返回图片数据。

    现在我们已经知道可以利用分块传输编码(Transfer-Encoding)实现数据的分块传输,那么有没有办法获取指定范围内的文件数据呢?对于这个问题,我们可以利用 HTTP 协议的范围请求。接下来,我们将介绍如何利用 HTTP 范围请求来下载指定范围的数据。

    chunked 下载示例:chunked

    github.com/semlinker/f…

    九、范围下载

    HTTP 协议范围请求允许服务器只发送 HTTP 消息的一部分到客户端。范围请求在传送大的媒体文件,或者与文件下载的断点续传功能搭配使用时非常有用。如果在响应中存在 Accept-Ranges 首部(并且它的值不为 “none”),那么表示该服务器支持范围请求。

    在一个 Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码。假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable 状态码,表示客户端错误。服务器允许忽略 Range 首部,从而返回整个文件,状态码用 200 。

    Range 语法:

    Range: <unit>=<range-start>-
    Range: <unit>=<range-start>-<range-end>
    Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
    Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
    • unit:范围请求所采用的单位,通常是字节(bytes)。
    • <range-start>:一个整数,表示在特定单位下,范围的起始值。
    • <range-end>:一个整数,表示在特定单位下,范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束。

    了解完 Range 语法之后,我们来看一下实际的使用示例:

    # 单一范围
    $ curl http://i.imgur.com/z4d4kWk.jpg -i -H "Range: bytes=0-1023"
    # 多重范围
    $ curl http://www.example.com -i -H "Range: bytes=0-50, 100-150"

    9.1 前端代码

    html

    <h3>范围下载示例</h3>
    <button onclick="download()">下载</button>

    js

    async function download() {
    try {
    let rangeContent = await getBinaryContent(
    "http://localhost:3000/file.txt",
    0, 100, "text"
    );
    const blob = new Blob([rangeContent], {
    type: "text/plain;charset=utf-8",
    });
    saveAs(blob, "hello.txt");
    } catch (error) {
    console.error(error);
    }
    }

    function getBinaryContent(url, start, end, responseType = "arraybuffer") {
    return new Promise((resolve, reject) => {
    try {
    let xhr = new XMLHttpRequest();
    xhr.open("GET", url, true);
    xhr.setRequestHeader("range", `bytes=${start}-${end}`);
    xhr.responseType = responseType;
    xhr.onload = function () {
    resolve(xhr.response);
    };
    xhr.send();
    } catch (err) {
    reject(new Error(err));
    }
    });
    }

    当用户点击 下载 按钮时,就会调用 download 函数。在该函数内部会通过调用 getBinaryContent 函数来发起范围请求。对应的 HTTP 请求报文如下所示:

    GET /file.txt HTTP/1.1
    Host: localhost:3000
    Connection: keep-alive
    User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36
    Accept: */*
    Accept-Encoding: identity
    Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,id;q=0.7
    Range: bytes=0-100

    而当服务器接收到该范围请求之后,会返回对应的 HTTP 响应报文:

    HTTP/1.1 206 Partial Content
    Vary: Origin
    Access-Control-Allow-Origin: null
    Accept-Ranges: bytes
    Last-Modified: Fri, 09 Jul 2021 00:17:00 GMT
    Cache-Control: max-age=0
    Content-Type: text/plain; charset=utf-8
    Date: Sat, 10 Jul 2021 02:19:39 GMT
    Connection: keep-alive
    Content-Range: bytes 0-100/2590
    Content-Length: 101

    从以上的 HTTP 响应报文中,我们见到了前面介绍的 206 状态码和 Accept-Ranges 首部。此外,通过 Content-Range 首部,我们就知道了文件的总大小。在成功获取到范围请求的响应体之后,我们就可以使用返回的内容作为参数,调用 Blob 构造函数创建对应的 Blob 对象,进而使用 FileSaver 库提供的 saveAs 方法来下载文件了。

    9.2 服务端代码

    const Koa = require("koa");
    const cors = require("@koa/cors");
    const serve = require("koa-static");
    const range = require("koa-range");

    const PORT = 3000;
    const app = new Koa();

    // 注册中间件
    app.use(cors());
    app.use(range);
    app.use(serve("."));

    app.listen(PORT, () => {
    console.log(`应用已经启动:http://localhost:${PORT}/`);
    });

    服务端的代码相对比较简单,范围请求是通过 koa-range 中间件来实现的。由于篇幅有限,阿宝哥就不展开介绍了。感兴趣的小伙伴,可以自行阅读该中间件的源码。其实范围请求还可以应用在大文件下载的场景,如果文件服务器支持范围请求的话,客户端在下载大文件的时候,就可以考虑使用大文件分块下载的方案。

    范围下载示例:range

    github.com/semlinker/f…

    十、大文件分块下载

    相信有些小伙伴已经了解大文件上传的解决方案,在上传大文件时,为了提高上传的效率,我们一般会使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后在开启多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并。

    那么对大文件下载来说,我们能否采用类似的思想呢?其实在服务端支持 Range 请求首部的条件下,我们也是可以实现大文件分块下载的功能,具体处理方案如下图所示:

    因为在 JavaScript 中如何实现大文件并发下载? 这篇文章中,阿宝哥已经详细介绍了大文件并发下载的方案,所以这里就不展开介绍了。我们只回顾一下大文件并发下载的完整流程:

    其实在大文件分块下载的场景中,我们使用了 async-pool 这个库来实现并发控制。该库提供了 ES7 和 ES6 两种不同版本的实现,代码很简洁优雅。如果你想了解 async-pool 是如何实现并发控制的,可以阅读 JavaScript 中如何实现并发控制? 这篇文章。

    大文件分块下载示例:big-file

    github.com/semlinker/f…

    十一、总结

    本文阿宝哥详细介绍了文件下载的 9 种场景,希望阅读完本文后,你对 9 种场景背后使用的技术有一定的了解。其实在传输文件的过程中,为了提高传输效率,我们可以使用 gzipdeflate 或 br 等压缩算法对文件进行压缩。由于篇幅有限,阿宝哥就不展开介绍了,如果你感兴趣的话,可以阅读 HTTP 传输大文件的几种方案 这篇文章。



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

    收起阅读 »

    文件下载,搞懂这9种场景就够了(上)

    既然掘友有要求,连标题也帮阿宝哥想好了,那我们就来整一篇文章,总结一下文件下载的场景。 一般在我们工作中,主要会涉及到 9 种文件下载的场景,每一种场景背后都使用不同的技术,其中也有很多细节需要我们额外注意。今天阿宝哥就来带大家总结一下这 9 种场景,让大家能...
    继续阅读 »





    既然掘友有要求,连标题也帮阿宝哥想好了,那我们就来整一篇文章,总结一下文件下载的场景。


    一般在我们工作中,主要会涉及到 9 种文件下载的场景,每一种场景背后都使用不同的技术,其中也有很多细节需要我们额外注意。今天阿宝哥就来带大家总结一下这 9 种场景,让大家能够轻松地应对各种下载场景。阅读本文后,你将会了解以下的内容:



    在浏览器端处理文件的时候,我们经常会用到 Blob 。比如图片本地预览、图片压缩、大文件分块上传及文件下载。在浏览器端文件下载的场景中,比如我们今天要讲到的 a 标签下载showSaveFilePicker API 下载Zip 下载 等场景中,都会使用到 Blob ,所以我们有必要在学习具体应用前,先掌握它的相关知识,这样可以帮助我们更好地了解示例代码。


    一、基础知识


    1.1 了解 Blob


    Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。在 JavaScript 中 Blob 类型的对象表示一个不可变、原始数据的类文件对象。 它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 用于数据操作。


    Blob 对象由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成:


    在 JavaScript 中你可以通过 Blob 的构造函数来创建 Blob 对象,Blob 构造函数的语法如下:


    const aBlob = new Blob(blobParts, options);

    相关的参数说明如下:



    • blobParts:它是一个由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等对象构成的数组。DOMStrings 会被编码为 UTF-8。

    • options:一个可选的对象,包含以下两个属性:

      • type —— 默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。

      • endings —— 默认值为 "transparent",用于指定包含行结束符 \n 的字符串如何被写入。 它是以下两个值中的一个: "native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变。




    1.2 了解 Blob URL


    Blob URL/Object URL 是一种伪协议,允许 Blob 和 File 对象用作图像、下载二进制数据链接等的 URL 源。在浏览器中,我们使用 URL.createObjectURL 方法来创建 Blob URL,该方法接收一个 Blob 对象,并为其创建一个唯一的 URL,其形式为 blob:<origin>/<uuid>,对应的示例如下:


    blob:http://localhost:3000/53acc2b6-f47b-450f-a390-bf0665e04e59

    浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 较短,但可以访问 Blob。生成的 URL 仅在当前文档打开的状态下才有效。它允许引用 <img><a> 中的 Blob,但如果你访问的 Blob URL 不再存在,则会从浏览器中收到 404 错误。


    上述的 Blob URL 看似很不错,但实际上它也有副作用。 虽然存储了 URL → Blob 的映射,但 Blob 本身仍驻留在内存中,浏览器无法释放它。映射在文档卸载时自动清除,因此 Blob 对象随后被释放。但是,如果应用程序寿命很长,那么 Blob 在短时间内将无法被浏览器释放。因此,如果你创建一个 Blob URL,即使不再需要该 Blob,它也会存在内存中。


    针对这个问题,你可以调用 URL.revokeObjectURL(url) 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。


    现在你已经了解了 Blob 和 Blob URL,如果你还意犹未尽,想深入理解 Blob 的话,可以阅读 你不知道的 Blob 这篇文章。下面我们开始介绍客户端文件下载的场景。


    随着 Web 技术的不断发展,浏览器的功能也越来越强大。这些年出现了很多在线 Web 设计工具,比如在线 PS、在线海报设计器或在线自定义表单设计器等。这些 Web 设计器允许用户在完成设计之后,把生成的文件保存到本地,其中有一部分设计器就是利用浏览器提供的 Web API 来实现客户端文件下载。下面阿宝哥先来介绍客户端下载中,最常见的 a 标签下载 方案。


    二、a 标签下载


    html


    <h3>a 标签下载示例</h3>
    <div>
    <img src="../images/body.png" />
    <img src="../images/eyes.png" />
    <img src="../images/mouth.png" />
    </div>
    <img id="mergedPic" src="http://via.placeholder.com/256" />
    <button onclick="merge()">图片合成</button>
    <button onclick="download()">图片下载</button>

    在以上代码中,我们通过 img 标签引用了以下 3 张素材:



    当用户点击 图片合成 按钮时,会将合成的图片显示在 img#mergedPic 容器中。在图片成功合成之后,用户可以通过点击 图片下载 按钮把已合成的图片下载到本地。对应的操作流程如下图所示:



    由上图可知,整体的操作流程相对简单。接下来,我们来看一下 图片合成图片下载 的实现逻辑。


    js


    图片合成的功能,阿宝哥是直接使用 Github 上 merge-images 这个第三方库来实现。利用该库提供的 mergeImages(images, [options]) 方法,我们可以轻松地实现图片合成的功能。调用该方法后,会返回一个 Promise 对象,当异步操作完成后,合成的图片会以 Data URLs 的格式返回。


    const mergePicEle = document.querySelector("#mergedPic");
    const images = ["/body.png", "/eyes.png", "/mouth.png"].map(
    (path) => "../images" + path
    );
    let imgDataUrl = null;

    async function merge() {
    imgDataUrl = await mergeImages(images);
    mergePicEle.src = imgDataUrl;
    }

    而图片下载的功能是借助 dataUrlToBlobsaveFile 这两个函数来实现。它们分别用于实现 Data URLs => Blob 的转换和文件的保存,具体的代码如下所示:


    function dataUrlToBlob(base64, mimeType) {
    let bytes = window.atob(base64.split(",")[1]);
    let ab = new ArrayBuffer(bytes.length);
    let ia = new Uint8Array(ab);
    for (let i = 0; i < bytes.length; i++) {
    ia[i] = bytes.charCodeAt(i);
    }
    return new Blob([ab], { type: mimeType });
    }

    // 保存文件
    function saveFile(blob, filename) {
    const a = document.createElement("a");
    a.download = filename;
    a.href = URL.createObjectURL(blob);
    a.click();
    URL.revokeObjectURL(a.href)
    }

    因为本文的主题是介绍文件下载,所以我们来重点分析 saveFile 函数。在该函数内部,我们使用了 HTMLAnchorElement.download 属性,该属性值表示下载文件的名称。如果该名称不是操作系统的有效文件名,浏览器将会对其进行调整。此外,该属性的作用是表明链接的资源将被下载,而不是显示在浏览器中。


    需要注意的是,download 属性存在兼容性问题,比如 IE 11 及以下的版本不支持该属性,具体如下图所示:



    (图片来源:caniuse.com/download)


    当设置好 a 元素的 download 属性之后,我们会调用 URL.createObjectURL 方法来创建 Object URL,并把返回的 URL 赋值给 a 元素的 href 属性。接着通过调用 a 元素的 click 方法来触发文件的下载操作,最后还会调用一次 URL.revokeObjectURL 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。


    关于 a 标签下载 的内容就介绍到这,下面我们来介绍如何使用新的 Web API —— showSaveFilePicker 实现文件下载。



    a 标签下载示例:a-tag


    github.com/semlinker/f…



    三、showSaveFilePicker API 下载


    showSaveFilePicker API 是 Window 接口中定义的方法,调用该方法后会显示允许用户选择保存路径的文件选择器。该方法的签名如下所示:



    let FileSystemFileHandle = Window.showSaveFilePicker(options);


    showSaveFilePicker 方法支持一个对象类型的可选参数,可包含以下属性:



    • excludeAcceptAllOption:布尔类型,默认值为 false。默认情况下,选择器应包含一个不应用任何文件类型过滤器的选项(由下面的 types 选项启用)。将此选项设置为 true 意味着 types 选项不可用。

    • types:数组类型,表示允许保存的文件类型列表。数组中的每一项是包含以下属性的配置对象:

      • description(可选):用于描述允许保存文件类型类别。

      • accept:是一个对象,该对象的 keyMIME 类型,值是文件扩展名列表。




    调用 showSaveFilePicker 方法之后,会返回一个 FileSystemFileHandle 对象。有了该对象,你就可以调用该对象上的方法来操作文件。比如调用该对象上的 createWritable 方法之后,就会返回 FileSystemWritableFileStream 对象,就可以把数据写入到文件中。具体的使用方式如下所示:


    async function saveFile(blob, filename) {
    try {
    const handle = await window.showSaveFilePicker({
    suggestedName: filename,
    types: [
    {
    description: "PNG file",
    accept: {
    "image/png": [".png"],
    },
    },
    {
    description: "Jpeg file",
    accept: {
    "image/jpeg": [".jpeg"],
    },
    },
    ],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
    return handle;
    } catch (err) {
    console.error(err.name, err.message);
    }
    }

    function download() {
    if (!imgDataUrl) {
    alert("请先合成图片");
    return;
    }
    const imgBlob = dataUrlToBlob(imgDataUrl, "image/png");
    saveFile(imgBlob, "face.png");
    }

    当你使用以上更新后的 saveFile 函数,来保存已合成的图片时,会显示以下保存文件选择器:



    由上图可知,相比 a 标签下载 的方式,showSaveFilePicker API 允许你选择文件的下载目录、选择文件的保存格式和更改存储的文件名称。看到这里是不是觉得 showSaveFilePicker API 功能挺强大的,不过可惜的是该 API 目前的兼容性还不是很好,具体如下图所示:



    (图片来源:caniuse.com/?search=sho…


    其实 showSaveFilePickerFile System Access API 中定义的方法,除了 showSaveFilePicker 之外,还有 showOpenFilePickershowDirectoryPicker 等方法。如果你想在实际项目中使用这些 API 的话,可以考虑使用 GoogleChromeLabs 开源的 browser-fs-access 这个库,该库可以让你在支持平台上更方便地使用 File System Access API,对于不支持的平台会自动降级使用 <input type="file"><a download> 的方式。


    可能大家对 browser-fs-access 这个库会比较陌生,但是如果换成是 FileSaver.js 这个库的话,应该就比较熟悉了。接下来,我们来介绍如何利用 FileSaver.js 这个库实现客户端文件下载。



    showSaveFilePicker API 下载示例:save-file-picker


    github.com/semlinker/f…



    四、FileSaver 下载


    FileSaver.js 是在客户端保存文件的解决方案,非常适合在客户端上生成文件的 Web 应用程序。它是 HTML5 版本的 saveAs() FileSaver 实现,支持大多数主流的浏览器,其兼容性如下图所示:



    (图片来源:github.com/eligrey/Fil…


    在引入 FileSaver.js 这个库之后,我们就可以使用它提供的 saveAs 方法来保存文件。该方法对应的签名如下所示:



    FileSaver saveAs(
    Blob/File/Url,
    optional DOMString filename,
    optional Object { autoBom }
    )


    saveAs 方法支持 3 个参数,第 1 个参数表示它支持 Blob/File/Url 三种类型,第 2 个参数表示文件名(可选),而第 3 个参数表示配置对象(可选)。如果你需要 FlieSaver.js 自动提供 Unicode 文本编码提示(参考:字节顺序标记),则需要设置 { autoBom: true}


    了解完 saveAs 方法之后,我们来举 3 个具体的使用示例:


    1. 保存文本


    let blob = new Blob(["大家好,我是阿宝哥!"], { type: "text/plain;charset=utf-8" });
    saveAs(blob, "hello.txt");

    2. 保存线上资源


    saveAs("https://httpbin.org/image", "image.jpg");

    如果下载的 URL 地址与当前站点是同域的,则将使用 a[download] 方式下载。否则,会先使用 同步的 HEAD 请求 来判断是否支持 CORS 机制,若支持的话,将进行数据下载并使用 Blob URL 实现文件下载。如果不支持 CORS 机制的话,将会尝试使用 a[download] 方式下载。


    标准的 W3C File API Blob 接口并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 Blob.js 来解决兼容性问题。



    (图片来源:caniuse.com/?search=blo…


    3. 保存 canvas 画布内容


    let canvas = document.getElementById("my-canvas");
    canvas.toBlob(function(blob) {
    saveAs(blob, "abao.png");
    });

    需要注意的是 canvas.toBlob() 方法并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 canvas-toBlob.js 来解决兼容性问题。



    (图片来源:caniuse.com/?search=toB…


    介绍完 saveAs 方法的使用示例之后,我们来更新前面示例中的 download 方法:


    function download() {
    if (!imgDataUrl) {
    alert("请先合成图片");
    return;
    }
    const imgBlob = dataUrlToBlob(imgDataUrl, "image/png");
    saveAs(imgBlob, "face.png");
    }

    很明显,使用 saveAs 方法之后,下载已合成的图片就很简单了。如果你对 FileSaver.js 的工作原理感兴趣的话,可以阅读 聊一聊 15.5K 的 FileSaver,是如何工作的? 这篇文章。前面介绍的场景都是直接下载单个文件,其实我们也可以在客户端同时下载多个文件,然后把已下载的文件压缩成 Zip 包并下载到本地。



    FileSaver 下载示例:file-saver


    github.com/semlinker/f…



    五、Zip 下载


    文件上传,搞懂这8种场景就够了 这篇文章中,阿宝哥介绍了如何利用 JSZip 这个库提供的 API,把待上传目录下的所有文件压缩成 ZIP 文件,然后再把生成的 ZIP 文件上传到服务器。同样,利用 JSZip 这个库,我们可以实现在客户端同时下载多个文件,然后把已下载的文件压缩成 Zip 包,并下载到本地的功能。对应的操作流程如下图所示:



    在以上 Gif 图中,阿宝哥演示了把 3 张素材图,打包成 Zip 文件并下载到本地的过程。接下来,我们来介绍如何使用 JSZip 这个库实现以上的功能。


    html


    <h3>Zip 下载示例</h3>
    <div>
    <img src="../images/body.png" />
    <img src="../images/eyes.png" />
    <img src="../images/mouth.png" />
    </div>
    <button onclick="download()">打包下载</button>

    js


    const images = ["body.png", "eyes.png", "mouth.png"];
    const imageUrls = images.map((name) => "../images/" + name);

    async function download() {
    let zip = new JSZip();
    Promise.all(imageUrls.map(getFileContent)).then((contents) => {
    contents.forEach((content, i) => {
    zip.file(images[i], content);
    });
    zip.generateAsync({ type: "blob" }).then(function (blob) {
    saveAs(blob, "material.zip");
    });
    });
    }

    // 从指定的url上下载文件内容
    function getFileContent(fileUrl) {
    return new JSZip.external.Promise(function (resolve, reject) {
    // 调用jszip-utils库提供的getBinaryContent方法获取文件内容
    JSZipUtils.getBinaryContent(fileUrl, function (err, data) {
    if (err) {
    reject(err);
    } else {
    resolve(data);
    }
    });
    });
    }

    在以上代码中,当用户点击 打包下载 按钮时,就会调用 download 函数。在该函数内部,会先调用 JSZip 构造函数创建 JSZip 对象,然后使用 Promise.all 函数来确保所有的文件都下载完成后,再调用 file(name, data [,options]) 方法,把已下载的文件添加到前面创建的 JSZip 对象中。最后通过 zip.generateAsync 函数来生成 Zip 文件并使用 FileSaver.js 提供的 saveAs 方法保存 Zip 文件。



    Zip 下载示例:Zip


    github.com/semlinker/f…





    收起阅读 »

    我给鸿星尔克写了一个720°看鞋展厅

    最近因为鸿星尔克给河南捐了5000万物资,真的是看哭了很多的网友,普通一家公司捐款5000万可能不会有这样的共情,但是看了鸿星尔克的背景之后,发现真的是令人心酸。鸿星尔克2020年的营收是28亿,但是利润却是亏损2个亿,甚至连微博的官方账号都舍不得开会员,在这...
    继续阅读 »


    最近因为鸿星尔克给河南捐了5000万物资,真的是看哭了很多的网友,普通一家公司捐款5000万可能不会有这样的共情,但是看了鸿星尔克的背景之后,发现真的是令人心酸。鸿星尔克2020年的营收是28亿,但是利润却是亏损2个亿,甚至连微博的官方账号都舍不得开会员,在这种情况下,还豪气地捐赠5000万,真的是破防了。



    网友还称鸿星尔克,特别像是老一辈人省吃俭用一分一毛攒起来的存款,小心翼翼存在铁盒里。一听说祖国需要,立马拿出铁盒子,哗~全导给你。让上最贵的鞋,拿出了双 249 的。


    然后我去鸿星尔克的官网看了看他家的鞋子。



    好家伙,等了55秒,终于把网站打开了。。。(看来真的是年久失修了,太令人心酸了。作为一个前端看到这一幕真的疯了...)


    恰逢周末,我就去了离我最近的鸿星尔克看了看。买了一双 136 的鞋子(是真的便宜,最关键的还是舒服)。




    买回家后心里想着,像毒APP上面那些阿迪、耐克的都有线上 360° 查看,就想着能不能给鸿星尔克也做一个呢,算作为一个技术人员为它出的一份绵薄之力。


    行动


    有了这个想法后,我就立马开始行动了。然后我大致总结了以下几个步骤:


    1.建模


    2.使用 Thee.js 创建场景


    3.导入模型


    4.加入 Three.js 控制器


    由于之前学习了一些 Three.js 的相关知识,因此对于有了模型后的展示还是比较有底的,因此其中最麻烦的就是建模了,因为我们需要把一个3维的东西,放到电脑中。对于2维的物体,想要放到电脑上,我们都知道,非常简单,就是使用相机拍摄一下就好了,但是想要在电脑中查看3维的物体却不一样,它多了一个维度,增加的量确实成倍的增长,于是开始查阅各种资料来看如何能够建立一个物体的模型。



    查了一堆资料,想要建立一个鞋子模型,总结起来共有两种模式。


    1.摄影绘图法(photogrammetry):通过拍摄照片,通过纯算法转化成3d模型,在图形学中也称为单目重建 。


    2.雷达扫描(Lidar scan):是通过激光雷达扫描,何同学的最新一期视频中也提到了这种方式扫描出点云。


    放上一个我总结的大纲,大部分都是国外的网站/工具。



    一开始搜索结果中,绝大多数人都在提 123D Catch,并且也看了很多视频,说它建立模型快速且逼真,但是再进一步的探索中,发现它貌似在2017年的时候业务就进行了合并进行了整合。整合后的 ReMake 需要付费,处于成本考虑我就没有继续了。(毕竟只是demo尝试)



    后面发现一款叫做 Polycam 的软件,成品效果非常好。



    但是当我选择使用的时候,发现它需要激光雷达扫描仪(LiDAR),必须要 iphone 12 pro 以上的机型才能使用。



    最终我选择了 Reality Capture 来创建模型,他是可以通过多张图片来合成一个模型的方式,看了一些b站的视频,感觉它的呈像效果也不错,不过它只支持 windows,且运行内存需要8g,这个时候我搬出了我7年前的windows电脑... 发现没想到它还能服役,也是惊喜。


    建模


    下面就开始正式的内容,主角就是我这次买的鞋子(开头放的那双)



    然后我们开始拍摄,首先我环绕着鞋子随意拍摄了一组照片,但是发现这个模型真的差强人意...



    后面我也采用了白幕的形式,加了一层背景,后面发现还是不行,应用更多是识别到了后面的背景数字。



    最后... 还是在楠溪的帮助下,将背景图P成了白色。



    皇天不负有心人,最终的效果还不错,基本上的点云模型已经出来了。(这感觉还不错,有种电影里的黑科技的感觉)



    下面是模型的样子,已经是我花了一天的时间训练出的最好的模型了(但是还是有一些轻微的粗糙)



    为了尽可能的让模型看起来完美,中间一共花了一天的时间来测试模型,因为拍摄的角度以及非常影响模型的生成,我一共拍了大约1G的图片,大约500张图片(由于前期不懂如何调整模型,因此尝试了大量的方法。)




    有了模型后,我们就可以将它展示在互联网上啦,这里采用了 Three.js(由于这里考虑到很多人不是这块领域相关的人员,因此会讲的比较基础,大佬们请见谅。)


    构建应用


    主要由三部分组成(构建场景、模型加载、添加控制器)


    1.构建3d场景


    首先我们先加载 Three.js


    <script type="module">
    import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.129.0/build/three.module.js';
    </script>

    然后创建一个WebGL渲染器


    const container = document.createElement( 'div' );
    document.body.appendChild( container );

    let renderer = new THREE.WebGLRenderer( { antialias: true } );
    container.appendChild( renderer.domElement );

    再将添加一个场景和照相机


    let scene = new THREE.Scene();

    相机语法PerspectiveCamera(fov, aspect, near, far)



    // 设置一个透视摄像机
    camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.25, 1000 );
    // 设置相机的位置
    camera.position.set( 0, 1.5, -30.0 );

    将场景和相机添加到 WebGL渲染器中。


    renderer.render( scene, camera );

    2.模型加载


    由于我们的导出的模型是 OBJ 格式的,体积非常大,我先后给它压成了 gltf、glb 的格式,Three.js 已经帮我们写好了GLTF 的loader,我们直接使用即可。


    // 加载模型
    const gltfloader = new GLTFLoader();
    const draco = new DRACOLoader();
    draco.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
    gltfloader.setDRACOLoader(draco);
    gltfloader.setPath('assets/obj4/');
    gltfloader.load('er4-1.glb', function (gltf) {
    gltf.scene.scale.set(0.2, 0.2, 0.2); //设置缩放
    gltf.scene.rotation.set(-Math.PI / 2, 0, 0) // 设置角度
    const Orbit = new THREE.Object3D();
    Orbit.add(gltf.scene);
    Orbit.rotation.set(0, Math.PI / 2, 0);

    scene.add(Orbit);
    render();
    });

    但是通过以上代码打开我们的页面会是一片漆黑,这个是因为我们的还没有添加光照。于是我们继续来添加一束光,来照亮我们的鞋子。


    // 设置灯光
    const directionalLight = new THREE.AmbientLight(0xffffff, 4);
    scene.add(directionalLight);
    directionalLight.position.set(2, 5, 5);


    现在能够清晰地看到我们的鞋子了,仿佛黑暗中看到了光明,但是这时候无法通过鼠标或者手势控制的,需要用到我们 Three.js 的控制器来帮助我们控制我们的模型角度。


    3.添加控制器


    const controls = new OrbitControls( camera, renderer.domElement );
    controls.addEventListener('change', render );
    controls.minDistance = 2; // 限制缩放
    controls.maxDistance = 10;
    controls.target.set( 0, 0, 0 ); // 旋转中心点
    controls.update()

    这个时候我们就能从各个角度看我们的鞋子啦。



    大功告成!


    在线体验地址: resume.mdedit.online/erke/


    开源地址(包含了工具、运行步骤以及实际demo):github.com/hua1995116/…


    后续规划


    由于时间有限(花了一整天周末的时间),还是没有得到一个非常完美的模型,后续也会继续探索这块的实现,再后续将探索是否能实现一条自动化的方式,从拍摄到模型的展示,以及其实我们有了模型后,离AR试鞋也不远了,如果你有兴趣或者有更好的想法建议,欢迎和我交流。


    最后非常感谢楠溪,放下了原本计划的一些事情来帮助一起拍摄加后期处理,以及陪我处理了一整天的模型。(条件有限的拍摄真的太艰难了。)


    还有祝鸿星尔克能够成为长久的企业,保持创新,做出更多更好的运动服装,让此刻的全民青睐的状态保持下去。


    附录


    得出的几个拍摄技巧,也是官方提供的。


    1.不要限制图像数量,RealityCapture可以处理任意张图片。


    2.使用高分辨率的图片。


    3.场景表面中的每个点应该在至少两个高质量的图像中清晰可见。


    4.拍照时以圆形方式围绕物体移动。


    5.移动的角度不要超过30度以上。


    6.从拍摄整个对象的照片,移动它然后关注细节,保证大小都差不多。


    7.完整的环绕。(不要绕半圈就结束了)


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

    收起阅读 »

    1分钟教你App点击秒开技能

    1分钟教你App点击秒开技能背景刚开始开发应用,不少人没有注意到点击桌面图标打开App有短暂的白屏或者黑屏的情况,短暂的白屏或者黑屏多多少少会影响用户的体验。其实只要我们简单设置一下,你的App就没有了白屏或者黑屏,实现秒开的效果。哪里不会点哪里,So eas...
    继续阅读 »

    1分钟教你App点击秒开技能

    背景

    刚开始开发应用,不少人没有注意到点击桌面图标打开App有短暂的白屏或者黑屏的情况,短暂的白屏或者黑屏多多少少会影响用户的体验。其实只要我们简单设置一下,你的App就没有了白屏或者黑屏,实现秒开的效果。哪里不会点哪里,So easy...

    步骤一:设置启动页主题

    //在style.xml添加一个启动页主题

    步骤二:给启动页设置主题


    android:name=".LauncherActivity"
    android:theme="@style/LauncherTheme"
    android:screenOrientation="portrait">
    ...


    步骤三:设置启动页主题windowBackground样式

    drawable/bg_splash.xml

    注意:启动页的layout.xml也需要用同一个背景图

    android:background="@drawable/bg_splash"

    "1.0" encoding="utf-8"?>
    xmlns:android="http://schemas.android.com/apk/res/android">
    android:drawable="#ffffff">










    android:bottom="40dp">
    android:gravity="bottom|clip_vertical"
    android:src="@drawable/launcher_bottom"/>



    步骤四:恢复默认主题

    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setTheme(R.style.AppTheme);//恢复默认主题样式
    setContentView(R.layout.activity_main);
    }

    结语

    效果图就不放了,这种实现方式是市面比较流行的做法,底部LOGO+全白的背景颜色,一般LOGO上面可以添加广告。冷启动白屏优化就是这么简单。如果你们有更好的秒开启动方案,可以留言。共同学习进步!

    收起阅读 »

    Android 禁止截屏、录屏 — 解决PopupWindow无法禁止录屏问题

    项目开发中,为了用户信息的安全,会有禁止页面被截屏、录屏的需求。这类资料,在网上有很多,一般都是通过设置Activity的Flag解决,如://禁止页面被截屏、录屏 getWindow().addFlags(WindowManager.LayoutParams...
    继续阅读 »

    项目开发中,为了用户信息的安全,会有禁止页面被截屏、录屏的需求。

    这类资料,在网上有很多,一般都是通过设置Activity的Flag解决,如:

    //禁止页面被截屏、录屏
    getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);

    这种设置可解决一般的防截屏、录屏的需求。
    如果页面中有弹出Popupwindow,在录屏视频中的效果是:

    非Popupwindow区域为黑色 但Popupwindow区域仍然是可以看到的

    如下面两张Gif图所示:

    未设置FLAG_SECURE,录屏的效果,如下图(git图片中间的水印忽略):

    普通界面录屏效果.gif

    设置了FLAG_SECURE之后,录屏的效果,如下图(git图片中间的水印忽略):
    界面仅设置了FLAG_SECURE.gif(图片中间的水印忽略)

    原因分析

    看到了上面的效果,我们可能会有疑问PopupWindow不像Dialog有自己的window对象,而是使用WindowManager.addView方法将View显示在Activity窗体上的。那么,Activity已经设置了FLAG_SECURE,为什么录屏时还能看到PopupWindow?

    我们先通过getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);来分析下源码:

    1、Window.java

    //window布局参数
    private final WindowManager.LayoutParams mWindowAttributes =
    new WindowManager.LayoutParams();

    //添加标识
    public void addFlags(int flags) {
    setFlags(flags, flags);
    }

    //通过mWindowAttributes设置标识
    public void setFlags(int flags, int mask) {
    final WindowManager.LayoutParams attrs = getAttributes();
    attrs.flags = (attrs.flags&~mask) | (flags&mask);
    mForcedWindowFlags |= mask;
    dispatchWindowAttributesChanged(attrs);
    }

    //获得布局参数对象,即mWindowAttributes
    public final WindowManager.LayoutParams getAttributes() {
    return mWindowAttributes;
    }

    通过源码可以看到,设置window属性的源码非常简单,即:通过window里的布局参数对象mWindowAttributes设置标识即可。

    2、PopupWindow.java

    //显示PopupWindow
    public void showAtLocation(View parent, int gravity, int x, int y) {
    mParentRootView = new WeakReference<>(parent.getRootView());
    showAtLocation(parent.getWindowToken(), gravity, x, y);
    }

    //显示PopupWindow
    public void showAtLocation(IBinder token, int gravity, int x, int y) {
    if (isShowing() || mContentView == null) {
    return;
    }

    TransitionManager.endTransitions(mDecorView);

    detachFromAnchor();

    mIsShowing = true;
    mIsDropdown = false;
    mGravity = gravity;

    //创建Window布局参数对象
    final WindowManager.LayoutParams p =createPopupLayoutParams(token);
    preparePopup(p);

    p.x = x;
    p.y = y;

    invokePopup(p);
    }

    //创建Window布局参数对象
    protected final WindowManager.LayoutParams createPopupLayoutParams(IBinder token) {
    final WindowManager.LayoutParams p = new WindowManager.LayoutParams();
    p.gravity = computeGravity();
    p.flags = computeFlags(p.flags);
    p.type = mWindowLayoutType;
    p.token = token;
    p.softInputMode = mSoftInputMode;
    p.windowAnimations = computeAnimationResource();
    if (mBackground != null) {
    p.format = mBackground.getOpacity();
    } else {
    p.format = PixelFormat.TRANSLUCENT;
    }
    if (mHeightMode < 0) {
    p.height = mLastHeight = mHeightMode;
    } else {
    p.height = mLastHeight = mHeight;
    }
    if (mWidthMode < 0) {
    p.width = mLastWidth = mWidthMode;
    } else {
    p.width = mLastWidth = mWidth;
    }
    p.privateFlags = PRIVATE_FLAG_WILL_NOT_REPLACE_ON_RELAUNCH
    | PRIVATE_FLAG_LAYOUT_CHILD_WINDOW_IN_PARENT_FRAME;
    p.setTitle("PopupWindow:" + Integer.toHexString(hashCode()));
    return p;
    }

    //将PopupWindow添加到Window上
    private void invokePopup(WindowManager.LayoutParams p) {
    if (mContext != null) {
    p.packageName = mContext.getPackageName();
    }

    final PopupDecorView decorView = mDecorView;
    decorView.setFitsSystemWindows(mLayoutInsetDecor);

    setLayoutDirectionFromAnchor();

    mWindowManager.addView(decorView, p);

    if (mEnterTransition != null) {
    decorView.requestEnterTransition(mEnterTransition);
    }
    }

    通过PopupWindow的源码分析,我们不难看出,在调用showAtLocation时,会单独创建一个WindowManager.LayoutParams布局参数对象,用于显示PopupWindow,而该布局参数对象上并未设置任何防止截屏Flag。

    如何解决

    原因既然找到了,那么如何处理呢?
    再回头分析下Window的关键代码:

    //通过mWindowAttributes设置标识
    public void setFlags(int flags, int mask) {
    final WindowManager.LayoutParams attrs = getAttributes();
    attrs.flags = (attrs.flags&~mask) | (flags&mask);
    mForcedWindowFlags |= mask;
    dispatchWindowAttributesChanged(attrs);
    }

    其实只需要获得WindowManager.LayoutParams对象,再设置上flag即可。
    但是PopupWindow并没有像Activity一样有直接获得window的方法,更别说设置Flag了。我们再分析下PopupWindow的源码:

    //将PopupWindow添加到Window上
    private void invokePopup(WindowManager.LayoutParams p) {
    if (mContext != null) {
    p.packageName = mContext.getPackageName();
    }

    final PopupDecorView decorView = mDecorView;
    decorView.setFitsSystemWindows(mLayoutInsetDecor);

    setLayoutDirectionFromAnchor();

    //添加View
    mWindowManager.addView(decorView, p);

    if (mEnterTransition != null) {
    decorView.requestEnterTransition(mEnterTransition);
    }
    }

    我们调用showAtLocation,最终都会执行mWindowManager.addView(decorView, p);
    那么是否可以在addView之前获取到WindowManager.LayoutParams呢?

    答案很明显,默认是不可以的。因为PopupWindow并没有公开获取WindowManager.LayoutParams的方法,而且mWindowManager也是私有的。

    如何才能解决呢?
    我们可以通过hook的方式解决这个问题。我们先使用动态代理拦截PopupWindow类的addView方法,拿到WindowManager.LayoutParams对象,设置对应Flag,再反射获得mWindowManager对象去执行addView方法。

    风险分析:

    不过,通过hook的方式也有一定的风险,因为mWindowManager是私有对象,不像Public的API,谷歌后续升级Android版本不会考虑其兼容性,所以有可能后续Android版本中改了其名称,那么我们通过反射获得mWindowManager对象不就有问题了。不过从历代版本的Android源码去看,mWindowManager被改的几率不大,所以hook也是可以用的,我们尽量写代码时考虑上这种风险,避免以后出问题。

    public class PopupWindow {
    ......
    private WindowManager mWindowManager;
    ......
    }

    而addView方法是ViewManger接口的公共方法,我们可以放心使用。

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

    功能实现

    考虑到hook的可维护性和扩展性,我们将相关代码封装成一个独立的工具类吧。

    package com.ccc.ddd.testpopupwindow.utils;

    import android.os.Handler;
    import android.view.WindowManager;
    import android.widget.PopupWindow;

    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Method;
    import java.lang.reflect.Proxy;

    public class PopNoRecordProxy implements InvocationHandler {
    private Object mWindowManager;//PopupWindow类的mWindowManager对象

    public static PopNoRecordProxy instance() {
    return new PopNoRecordProxy();
    }

    public void noScreenRecord(PopupWindow popupWindow) {
    if (popupWindow == null) {
    return;
    }
    try {
    //通过反射获得PopupWindow类的私有对象:mWindowManager
    Field windowManagerField = PopupWindow.class.getDeclaredField("mWindowManager");
    windowManagerField.setAccessible(true);
    mWindowManager = windowManagerField.get(popupWindow);
    if(mWindowManager == null){
    return;
    }
    //创建WindowManager的动态代理对象proxy
    Object proxy = Proxy.newProxyInstance(Handler.class.getClassLoader(), new Class[]{WindowManager.class}, this);

    //注入动态代理对象proxy(即:mWindowManager对象由proxy对象来代理)
    windowManagerField.set(popupWindow, proxy);
    } catch (IllegalAccessException e) {
    e.printStackTrace();
    } catch (NoSuchFieldException e) {
    e.printStackTrace();
    }
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
    //拦截方法mWindowManager.addView(View view, ViewGroup.LayoutParams params);
    if (method != null && method.getName() != null && method.getName().equals("addView")
    && args != null && args.length == 2) {
    //获取WindowManager.LayoutParams,即:ViewGroup.LayoutParams
    WindowManager.LayoutParams params = (WindowManager.LayoutParams) args[1];
    //禁止录屏
    setNoScreenRecord(params);
    }
    } catch (Exception ex) {
    ex.printStackTrace();
    }
    return method.invoke(mWindowManager, args);
    }

    /**
    * 禁止录屏
    */

    private void setNoScreenRecord(WindowManager.LayoutParams params) {
    setFlags(params, WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
    }

    /**
    * 允许录屏
    */

    private void setAllowScreenRecord(WindowManager.LayoutParams params) {
    setFlags(params, 0, WindowManager.LayoutParams.FLAG_SECURE);
    }

    /**
    * 设置WindowManager.LayoutParams flag属性(参考系统类Window.setFlags(int flags, int mask))
    *
    * @param params WindowManager.LayoutParams
    * @param flags The new window flags (see WindowManager.LayoutParams).
    * @param mask Which of the window flag bits to modify.
    */

    private void setFlags(WindowManager.LayoutParams params, int flags, int mask) {
    try {
    if (params == null) {
    return;
    }
    params.flags = (params.flags & ~mask) | (flags & mask);
    } catch (Exception ex) {
    ex.printStackTrace();
    }
    }
    }

    Popwindow禁止录屏工具类的使用,代码示例:

        //创建PopupWindow
    //正常项目中,该方法可改成工厂类
    //正常项目中,也可自定义PopupWindow,在其类中设置禁止录屏
    private PopupWindow createPopupWindow(View view, int width, int height) {
    PopupWindow popupWindow = new PopupWindow(view, width, height);
    //PopupWindow禁止录屏
    PopNoRecordProxy.instance().noScreenRecord(popupWindow);
    return popupWindow;
    }

    //显示Popupwindow
    private void showPm() {
    View view = LayoutInflater.from(this).inflate(R.layout.pm1, null);
    PopupWindow pw = createPopupWindow(view,ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    pw1.setFocusable(false);
    pw1.showAtLocation(this.getWindow().getDecorView(), Gravity.BOTTOM | Gravity.RIGHT, PopConst.PopOffsetX, PopConst.PopOffsetY);
    }

    录屏效果图:
    录屏效果图.gif

    收起阅读 »

    学会黑科技,一招搞定 iOS 14.2 的 libffi crash

    作者:字节移动技术 —— 谢俊逸苹果升级 14.2,全球 iOS 遭了秧。libffi 在 iOS14.2 上发生了 crash, 我司的许多 App 深受困扰,有许多基础库都是用了 libffi。经过定位,发现是 vmremap 导致的 code sign ...
    继续阅读 »

    作者:字节移动技术 —— 谢俊逸

    苹果升级 14.2,全球 iOS 遭了秧。libffi 在 iOS14.2 上发生了 crash, 我司的许多 App 深受困扰,有许多基础库都是用了 libffi。


    经过定位,发现是 vmremap 导致的 code sign error。我们通过使用静态 trampoline 的方式让 libffi 不需要使用 vmremap,解决了这个问题。这里就介绍一下相关的实现原理。

    libffi 是什么

    高层语言的编译器生成遵循某些约定的代码。这些公约部分是单独汇编工作所必需的。“调用约定”本质上是编译器对函数入口处将在哪里找到函数参数的假设的一组假设。“调用约定”还指定函数的返回值在哪里找到。

    一些程序在编译时可能不知道要传递给函数的参数。例如,在运行时,解释器可能会被告知用于调用给定函数的参数的数量和类型。Libffi 可用于此类程序,以提供从解释器程序到编译代码的桥梁。

    libffi 库为各种调用约定提供了一个便携式、高级的编程接口。这允许程序员在运行时调用调用接口描述指定的任何函数。

    ffi 的使用

    简单的找了一个使用 ffi 的库看一下他的调用接口

    ffi_type *returnType = st_ffiTypeWithType(self.signature.returnType);
    NSAssert(returnType, @"can't find a ffi_type of %@", self.signature.returnType);

    NSUInteger argumentCount = self->_argsCount;
    _args = malloc(sizeof(ffi_type *) * argumentCount) ;

    for (int i = 0; i < argumentCount; i++) {
      ffi_type* current_ffi_type = st_ffiTypeWithType(self.signature.argumentTypes[i]);
      NSAssert(current_ffi_type, @"can't find a ffi_type of %@", self.signature.argumentTypes[i]);
      _args[i] = current_ffi_type;
    }

    // 创建 ffi 跳板用到的 closure
    _closure = ffi_closure_alloc(sizeof(ffi_closure), (void **)&xxx_func_ptr);

    // 创建 cif,调用函数用到的参数和返回值的类型信息, 之后在调用时会结合call convention 处理参数和返回值
    if(ffi_prep_cif(&_cif, FFI_DEFAULT_ABI, (unsigned int)argumentCount, returnType, _args) == FFI_OK) {

            // closure 写入 跳板数据页
      if (ffi_prep_closure_loc(_closure, &_cif, _st_ffi_function, (__bridge void *)(self), xxx_func_ptr) != FFI_OK) {
        NSAssert(NO, @"genarate IMP failed");
      }
    else {
      NSAssert(NO, @"");
    }

    看完这段代码,大概能理解 ffi 的操作。

    1. 提供给外界一个指针(指向 trampoline entry)
    2. 创建一个 closure, 将调用相关的参数返回值信息放到 closure 里
    3. 将 closure 写入到 trampoline 对应的 trampoline data entry 处

    之后我们调用 trampoline entry func ptr 时,

    1. 会找到 写入到 trampoline 对应的 trampoline data entry 处的 closure 数据
    2. 根据 closure 提供的调用参数和返回值信息,结合调用约定,操作寄存器和栈,写入参数 进行函数调用,获取返回值。

    那 ffi 是怎么找到 trampoline 对应的 trampoline data entry 处的 closure 数据 呢?

    我们从 ffi 分配 trampoline 开始说起:

    static ffi_trampoline_table *
    ffi_remap_trampoline_table_alloc (void)
    {
    .....
      /* Allocate two pages -- a config page and a placeholder page */
      config_page = 0x0;
      kt = vm_allocate (mach_task_self (), &config_page, PAGE_MAX_SIZE * 2,
                        VM_FLAGS_ANYWHERE);
      if (kt != KERN_SUCCESS)
          return NULL;

      /* Allocate two pages -- a config page and a placeholder page */
      //bdffc_closure_trampoline_table_page

      /* Remap the trampoline table on top of the placeholder page */
      trampoline_page = config_page + PAGE_MAX_SIZE;
      trampoline_page_template = (vm_address_t)&ffi_closure_remap_trampoline_table_page;
    #ifdef __arm__
      /* bdffc_closure_trampoline_table_page can be thumb-biased on some ARM archs */
      trampoline_page_template &= ~1UL;
    #endif
      kt = vm_remap (mach_task_self (), &trampoline_page, PAGE_MAX_SIZE, 0x0,
                     VM_FLAGS_OVERWRITE, mach_task_self (), trampoline_page_template,
                     FALSE, &cur_prot, &max_prot, VM_INHERIT_SHARE);
      if (kt != KERN_SUCCESS)
      {
          vm_deallocate (mach_task_self (), config_page, PAGE_MAX_SIZE * 2);
          return NULL;
      }


      /* We have valid trampoline and config pages */
      table = calloc (1sizeof (ffi_trampoline_table));
      table->free_count = FFI_REMAP_TRAMPOLINE_COUNT/2;
      table->config_page = config_page;
      table->trampoline_page = trampoline_page;

    ......
      return table;
    }

    首先 ffi 在创建 trampoline 时,会分配两个连续的 page

    trampoline page 会 remap 到我们事先在代码中汇编写的 ffi_closure_remap_trampoline_table_page。

    其结构如图所示:



    当我们 ffi_prep_closure_loc(_closure, &_cif, _st_ffi_function, (__bridge void *)(self), entry1)) 写入 closure 数据时, 会写入到 entry1 对应的 closuer1。

    ffi_status
    ffi_prep_closure_loc (ffi_closure *closure,
                          ffi_cif* cif,
                          void (*fun)(ffi_cif*,void*,void**,void*),
                          void *user_data,
                          void *codeloc)

    {
    ......
      if (cif->flags & AARCH64_FLAG_ARG_V)
          start = ffi_closure_SYSV_V; // ffi 对 closure的处理函数
      else
          start = ffi_closure_SYSV;

      void **config = (void**)((uint8_t *)codeloc - PAGE_MAX_SIZE);
      config[0] = closure;
      config[1] = start;
    ......
    }

    这是怎么对应到的呢? closure1 和 entry1 距离其所属 Page 的 offset 是一致的,通过 offset,成功建立 trampoline entry 和 trampoline closure 的对应关系。

    现在我们知道这个关系,我们通过代码看一下到底在程序运行的时候 是怎么找到 closure 的。

    这四条指令是我们 trampoline entry 的代码实现,就是 ffi 返回的 xxx_func_ptr

    adr x16, -PAGE_MAX_SIZE
    ldp x17, x16, [x16]
    br x16
    nop

    通过 .rept 我们创建 PAGE_MAX_SIZE / FFI_TRAMPOLINE_SIZE 个跳板,刚好一个页的大小

    # 动态remap的 page
    .align PAGE_MAX_SHIFT
    CNAME(ffi_closure_remap_trampoline_table_page):
    .rept PAGE_MAX_SIZE / FFI_TRAMPOLINE_SIZE
    # 这是我们的 trampoline entry, 就是ffi生成的函数指针
    adr x16, -PAGE_MAX_SIZE // 将pc地址减去PAGE_MAX_SIZE, 找到 trampoine data entry
    ldp x17, x16, [x16] // 加载我们写入的 closure, start 到 x17, x16
    br x16 // 跳转到 start 函数
    nop /* each entry in the trampoline config page is 2*sizeof(void*) so the trampoline itself cannot be smaller that 16 bytes */
    .endr

    通过 pc 地址减去 PAGE_MAX_SIZE 就找到对应的 trampoline data entry 了。

    静态跳板的实现

    由于代码段和数据段在不同的内存区域。

    我们此时不能通过 像 vmremap 一样分配两个连续的 PAGE,在寻找 trampoline data entry 只是简单的-PAGE_MAX_SIZE 找到对应关系,需要稍微麻烦点的处理。

    主要是通过 adrp 找到_ffi_static_trampoline_data_page1 和 _ffi_static_trampoline_page1的起始地址,用 pc-_ffi_static_trampoline_page1的起始地址计算 offset,找到 trampoline data entry。

    # 静态分配的page
    #ifdef __MACH__
    #include <mach/machine/vm_param.h>

    .align 14
    .data
    .global _ffi_static_trampoline_data_page1
    _ffi_static_trampoline_data_page1:
    .space PAGE_MAX_SIZE*5
    .align PAGE_MAX_SHIFT
    .text
    CNAME(_ffi_static_trampoline_page1):

    _ffi_local_forwarding_bridge:
    adrp x17, ffi_closure_static_trampoline_table_page_start@PAGE;// text page
    sub x16, x16, x17;// offset
    adrp x17, _ffi_static_trampoline_data_page1@PAGE;// data page
    add x16, x16, x17;// data address
    ldp x17, x16, [x16];// x17 closure x16 start
    br x16
    nop
    nop
    .align PAGE_MAX_SHIFT
    CNAME(ffi_closure_static_trampoline_table_page):

    #这个label 用来adrp@PAGE 计算 trampoline 到 trampoline page的offset
    #留了5个用来调试。
    # 我们static trampoline 两条指令就够了,这里使用4个,和remap的保持一致
    ffi_closure_static_trampoline_table_page_start:
    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    // 5 * 4
    .rept (PAGE_MAX_SIZE*5-5*4) / FFI_TRAMPOLINE_SIZE
    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop
    .endr

    .globl CNAME(ffi_closure_static_trampoline_table_page)
    FFI_HIDDEN(CNAME(ffi_closure_static_trampoline_table_page))
    #ifdef __ELF__
    .type CNAME(ffi_closure_static_trampoline_table_page), #function
    .size CNAME(ffi_closure_static_trampoline_table_page), . - CNAME(ffi_closure_static_trampoline_table_page)
    #endif
    #endif


    摘自字节跳动技术团队:https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247488493&idx=1&sn=e86780883d5c0cf3bb34a59ec753b4f3&chksm=e9d0d80fdea751196c807991cd46f5928f6828fe268268872ec3582b4fdcad086e1cebcab2d5&cur_album_id=1590407423234719749&scene=189#rd




    收起阅读 »

    抖音iOS最复杂功能的重构之路--播放器交互区重构实践分享

    背景介绍本文以抖音中最为复杂的功能,也是最重要的功能之一的交互区为例,和大家分享一下此次重构过程中的思考和方法,主要侧重在架构、结构方面。交互区简介交互区是指播放页面中可以操作的区域,简单理解就是除视频播放器外附着的功能,如下图红色区域中的作者名称、描述文案、...
    继续阅读 »

    背景介绍

    本文以抖音中最为复杂的功能,也是最重要的功能之一的交互区为例,和大家分享一下此次重构过程中的思考和方法,主要侧重在架构、结构方面。

    交互区简介

    交互区是指播放页面中可以操作的区域,简单理解就是除视频播放器外附着的功能,如下图红色区域中的作者名称、描述文案、头像、点赞、评论、分享按钮、蒙层、弹出面板等等,几乎是用户看到、用到最多的功能,也是最主要的流量入口。


    发现问题

    不要急于改代码,先梳理清楚功能、问题、代码,建立全局观,找到问题根本原因。

    现状


    上图是代码量排行,排在首位的就是交互区的 ViewController,遥遥领先其他类,数据来源自研的代码量化系统,这是一个辅助业务发现架构、设计、代码问题的工具。

    可进一步查看版本变化:



    每周 1 版,在不到 1 年的时间,代码量翻倍,个别版本代码量减少,是局部在做优化,大趋势仍是快速增长。

    除此之外:

    • 可读性差:ViewController 代码量 1.8+万行,是抖音中最大的类,超过第 2 大的类一倍有余,另外交互区使用了 VIPER 结构(iOS 常用的结构:MVC、MVVM、MVP、VIPER),加上 IPER 另外 4 层,总代码规模超过了 3 万行,这样规模的代码,很难记清某个功能在哪,某个业务逻辑是什么样的,为了修改一处,需要读懂全部代码,非常不友好
    • 扩展性差:新增、修改每个功能需要改动 VIPER 结构中的 5 个类,明明业务逻辑独立的功能,却需要大量耦合已有功能,修改已有代码,甚至引起连锁问题,修一个问题,结果又出了一个新问题
    • 维护人员多:统计 commit 历史,每个月都有数个业务线、数十人提交代码,改动时相互的影响、冲突不断

    理清业务

    作者是抖音基础技术组,负责业务架构工作,交互区业务完全不了解,需要重新梳理。

    事实上已经没有一个人了解所有业务,包括产品经理,也没有一个完整的需求文档查阅,需要根据代码、功能页面、操作来梳理清楚业务逻辑,不确定的再找相关开发、产品同学,省略中间过程,总计梳理了 10+个业务线,100+子功能,梳理这些功能的目的是:

    • 按重要性分清主次,核心功能优先保障,分配更多的时间开发、测试
    • 子功能之间的布局、交互是有一定的规律的,这些规律可以指导重构的设计
    • 判断产品演化趋势,设计既要满足当下、也要有一定的前瞻性
    • 自测时需要用,避免遗漏

    理清代码

    所有业务功能、问题最终都要落在代码上,理清代码才能真正理清问题,解决也从代码中体现,梳理如下:

    • 代码量:VC 1.8 万行、总代码量超过 3 万行
    • 接口:对外暴露了超过 200 个方法、100 个属性
    • 依赖关系:VIPER 结构使用的不理想,Presenter 中直接依赖了 VC,互相耦合
    • 内聚、耦合:一个子功能,代码散落在各处,并和其他子功能产生过多耦合
    • 无用代码:大量无用的代码、不知道做什么的代码
    • View 层级:所有的子功能 View 都放在 VC 的直接子 View 中,也就是说 VC 有 100+个 subView,实际仅需要显示 10 个左右的子功能,其他的通过设置了 hidden 隐藏,但是创建并参与布局,会严重消耗性能
    • ABTest(分组对照试验):有几十个 ABTest,最长时间可以追溯到数年前,这些 ABTest 在自测、测试都难以全面覆盖

    简单概括就是,需要完整的读完代码,重点是类之间的依赖关系,可以画类图结合着理解。

    每一行代码都是有原因的,即便感觉没用,删一行可能就是一个线上事故。

    趋势

    抖音产品特性决定,视频播放页面占据绝大部分流量,各业务线都想要播放页面的导流,随着业务发展,不断向多样性、复杂性演化。

    从播放页面的形态上看,已经经过多次探索、尝试,目前的播放页面模式相对稳定,业务主要以导流形式的入口扩展。

    曾经尝试过的方式

    ViewController 拆分 Category

    将 ViewController 拆分为多个 Category,按 View 构造、布局、更新、业务线逻辑将代码拆分到 Category。这个方式可以解决部分问题,但有限,当功能非常复杂时就无法很好的支撑了,主要问题有:

    • 拆分了 ViewController,但是 IPER 层没有拆分,拆分的不彻底,职责还是相互耦合
    • Category 之间互相访问需要的属性、内部方法时,需要暴露在头文件中,而这些是应该隐藏的
    • 无法支持批量调用,如 ViewDidLoad 时机,需要各个 Category 方法定义不同方法(同名会被覆盖),逐个调用

    左侧和底部的子功能放在一个 UIStackView 中

    这个思路方向大体正确了,但是在尝试大半年后失败,删掉了代码。

    正确的点在于:抽象了子功能之间的关系,利用 UIStackView 做布局。

    失败的点在于:

    • 局部重构:仅仅是局部重构,没有深入的分析整体功能、逻辑,没有彻底解决问题,Masonry 布局代码和 UIStackView 使用方式都放在 ViewController 中,不同功能的 view 很容易耦合,劣化依然存在,很快又然难以维护,这类似破窗效应
    • 实施方案不完善:布局需要实现 2 套代码,开发、测试同学非常容易忽略,线上经常出问题
    • UIStackView crash:概率性 crash,崩在系统库中,大半年时间也没有找到原因

    其他

    还有一些提出 MVP、MVVM 等结构的方案,有的浅尝辄止、有的通过不了技术评审、有的不了了之。

    关键问题

    上面仅列举部分问题,如果按人头收集,那将数不胜数,但这些基本都是表象问题,找到问题的本质、原因,解决关键问题,才能彻底解决问题,很多表象问题也会被顺带解决。

    经常提到的内聚、耦合、封装、分层等等思想感觉很好,用时却又没有真正解决问题,下面扩展两点,辅助分析、解决问题:

    • 复杂度
    • “变量”与“常量”

    复杂度

    复杂功能难以维护的原因的是因为复杂。

    是的,很直接,相对的,设计、重构等手法都是让事情变得简单,但变简单的过程并不简单,从 2 个角度切入来拆解:

    • 关系

    :量是显性的,功能不断增加,相应的需要更多人来开发、维护,需要写更多代码,也就越来越难维护,这些是显而易见的。

    关系:关系是隐性的,功能之间产生耦合即为发生关系,假设 2 个功能之间有依赖,关系数量记为 1,那 3 者之间关系数量为 3,4 者之间关系数量为 6,这是一个指数增加的,当数量足够大时,复杂度会很夸张,关系并不容易看出来,因此很容易产生让人意想不到的变化。

    功能的数量大体可以认为是随产品人数线性增长的,即复杂度也是线性增长,随着开发人数同步增长是可以继续维护的。如果关系数量指数级增长,那么很快就无法维护了。



    “变量”与“常量”

    “变量”是指相比上几个版本,哪些代码变了,与之对应的“常量”即哪些代码没变,目的是:

    从过去的变化中找到规律,以适应未来的变化。

    平常提到的封装、内聚、解耦等概念,都是静态的,即某一个时间点合理,不意味着未来也合理,期望改进可以在更长的时间范围内合理,称之为动态,找到代码中的“变量”与“常量”是比较有效的手段,相应的代码也有不同的优化趋向:

    • 对于“变量”,需要保证职责内聚、单一,易扩展
    • 对于“常量”,需要封装,减少干扰,对使用者透明

    回到交互区重构场景,发现新加的子功能,基本都加在固定的 3 个区域中,布局是上下撑开,这里的变指的就是新加的子功能,不变指的是加的位置和其他子功能的位置关系、逻辑关系,那么变化的部分,可以提供一个灵活的扩展机制来支持,不变的部分中,业务无关的下沉为底层框架,业务相关的封装为独立模块,这样整体的结构也就出来了。

    “变量”与“常量”同样可以检验重构效果,比如模块间常常通过抽象出的协议进行通信,如果通信方法都是具体业务的,那每个同学都可能往里添加各自的方法,这个“变量”就会失去控制,难以维护。

    设计方案

    梳理问题的过程中,已经在不断的在思考什么样的方式可以解决问题,大致雏形已经有了,这部分更多的是将设计方案系统化。

    思路

    • 通过上述梳理功能发现 UI 设计和产品的规律:
      • 整体可分为 3 个区域,左侧、右侧、底部,每个子功能都可以归到 3 个区域中,按需显示,数据驱动
      • 左侧区域中的作者名称、描述、音乐信息是自底向上挨个排列
      • 右侧主要是按钮类型,头像、点赞、评论,排列方式和左侧规律相同
      • 底部可能有个警告、热点,只显示 1 个或者不显示
    • 为了统一概念,将 3 个区域定义为容器、容器中放置的子功能定义为元素,容器边界和能力可以放宽一些,支持弱类型实例化,这样就能支持物理隔离元素代码,形成一个可插拔的机制。
    • 元素将 View、布局、业务逻辑代码都内聚在一起,元素和交互区、元素和元素之间不直接依赖,职责内聚,便于维护。
    • 众多的接口可以抽象归类,大体可分为 UI 生命周期调用、播放器生命周期调用,将业务性的接口抽象,分发到具体的元素中处理逻辑。

    架构设计

    下图是期望达到的最终目标形态,实施过程会分为多步,确定最终形态,避免实施时偏离目标




    整体指导原则:简单、适用、可演化。

    • SDK 层:抽象出和业务完全无关的 SDK 层,SDK 负责管理 Element、Element 间通信
    • 业务框架层:将通用业务、共性代码等低频率修改代码独立出来,形成框架层,这层代码是可由专人维护,业务线同学无法修改
    • 业务扩展层:各业务线具体的子功能在此层实现,提供灵活的注册、插拔能力,Element 间无耦合,代码影响限定在 Element 内部

    SDK 层

    Container

    所有的 Element 都通过 Container 来管理,包括 2 部分:

    • 对 Element 的创建、持有
    • 持有了一个 UIStackView,Element 创建的 View 都加入到此 UIStackView 中

    使用 UIStackView 是为了实现自底向上的流式布局。

    Element

    子功能的 UI、逻辑、操作等所有代码封装的集合体,定义为 Element,借鉴了网页中的 Element 概念,对外的行为可抽象为:

    • View:最终显示的 View,lazy 的形式构造
    • 布局:自适应撑开,Container 中的 UIStackView 可以支持
    • 事件:通用的事件,处理 handler 即可,view 内部也可自行添加事件
    • 更新:传入模型,内部根据模型内容,赋值到 view 中

    View

    View 在 BaseElement 中的定义如下:

    @interface BaseElement : NSObject <BaseElementProtocol>

    @property (nonatomic, strong, nullable) UIView *view;
    @property (nonatomic, assign) BOOL appear;

    - (void)viewDidLoad;

    @end
    • BaseElement 是抽象基类,公开 view 属性形式上看 view 属性、viewDidLoad 方法,和 UIViewController 使用方式的非常类似,设计意图是想靠向 UIViewController,以便让大家更快的接受和理解
    • appear 表示 element 是否显示,appear 为 YES 时,view 被自动创建,viewDidLoad 方法被调用,相关的子 view、布局等业务代码在 viewDidLoad 方法中复写,和 UIViewController 使用类似
    • appear 和 hidden 的区别在于,hidden 只是视觉看不到了,内存并没有释放掉,而低频次使用的 view 没必要常驻内存,因此 appear 为 NO 时,会移除 view 并释放内存

    布局

    • UIStackView 的 axis 设置了 UILayoutConstraintAxisVertical,布局时自底向上的流式排列
    • 容器内的元素自下向上布局,最底部的元素参照容器底部约束,依次布局,容器高度参照最上面的元素位置
    • 元素内部自动撑开,可直接设置固定高度,也可以用 autolayout 撑开

    事件

    @protocol BaseElementProtocol <NSObject>
    @optional
    - (void)tapHandler:(UITapGestureRecognizer *)sender;

    @end
    • 实现协议方法,自动添加手势,支持点击事件
    • 也可以自行添加事件,如按钮,使用原生的 addTarget 点击体验更好

    更新

    data 属性赋值,触发更新,通过 setter 形式实现。

    @property (nonatomic, strong, nullable) id data;

    赋值时会调用 setData 方法。

    - (void)setData:(id)data {
        _data = data;
        [self processAppear:self.appear];
    }

    赋值时,processAppear 方法会根据 appear 状态更新 View 的状态,决定创建或销毁 View。

    数据流图

    Element 的生命周期、更新时的数据流向示意图,这里就不细讲了。



    图中是实际需要支持的业务场景,目前是 ABTest 阶段,老代码实现方式主要问题:

    • 对每处 view 都用 GET_AB_TEST_CASE(videoPlayerInteractionOptimization)判断处理了,代码中共有 32 处判断
    • 每个 View 使用 Transform 动画隐藏

    这个实现方式非常分散,加新 view 时很容易被遗漏,Element 支持更优的方式:

    • 左侧所有子功能都在一个容器中,因此隐藏容器即可,不需要操作每个子功能
    • 右侧单独隐藏头像、音乐单独处理即可


    扩展性

    Element 之间无依赖,可以做到每个 Element 物理隔离,代码放在各自的业务组件中,业务组件依赖交互区业务框架层即可,独立的 Element 通过 runtime 形式,使用注册的方式提供给交互区,框架会将字符串的类实例化,让其正常工作。

    [self.container addElementByClassName:@"PlayInteractionAuthorElement"];
    [self.container addElementByClassName:@"PlayInteractionRateElement"];
    [self.container addElementByClassName:@"PlayInteractionDescriptionElement"];

    业务框架层

    容器管理

    SDK 中仅提供了容器的抽象定义和实现,在业务场景中,需要结合具体业务场景,进一步定义容器的范围和职责。

    上面梳理了功能中将整个页面分为左侧、右侧、底部 3 个区域,那么这 3 个区域就是相应的容器,所有子功能都可以归到这 3 个容器中,如下图:

    协议

    Feed 是用 UITableView 实现,Cell 中除了交互区外只有播放器,因此所有的外部调用都可以抽象,如下图所示。



    协议

    Feed 是用 UITableView 实现,Cell 中除了交互区外只有播放器,因此所有的外部调用都可以抽象,如下图所示。


    从概念上讲只需要 1 个交互区协议,但这里可以细分为 2 部分:

    • 页面生命周期
    • 播放器生命周期

    所有 Element 都要实现这个协议,因此在 SDK 中的 Element 基类之上,继承实现了 PlayInteractionBaseElement,这样具体 Element 中不需要实现的方法可以不写。

    @interface PlayInteractionBaseElement : BaseElement <PlayInteractionDispatcherProtocol>
    @end

    为了更清晰定义协议职责,用接口隔离的思想继续拆分,PlayInteractionDispatcherProtocol 作为统一的聚合协议。

    @protocol PlayInteractionDispatcherProtocol <PlayInteractionCycleLifeDispatcherProtocol, PlayInteractionPlayerDispatcherProtocol>

    @end

    页面生命周期协议:PlayInteractionCycleLifeDispatcherProtocol

    简单列了部分方法,这些方法都是 ViewController、TableView、Cell 对应的生命周期方法,是完全抽象的、和业务无关的,因此不会随着业务量的增加而膨胀。

    @protocol PlayInteractionCycleLifeDispatcherProtocol <NSObject>

    - (void)willDisplay;

    - (void)setHide:(BOOL)flag;

    - (void)reset;

    @end

    播放器生命周期协议:PlayInteractionPlayerDispatcherProtocol

    播放器的状态和方法,也是抽象的、和业务无关。

    @protocol PlayInteractionPlayerDispatcherProtocol <NSObject>

    @property (nonatomic, assign) PlayInteractionPlayerStatus playerStatus;

    - (void)pause;

    - (void)resume;

    - (void)videoDidActivity;

    @end

    Manager - 弹窗、蒙层

    弹窗、蒙层的 view 规律并不在容器管理之中,所以需要一套额外的管理方式,这里定义了 Manager 概念,是一个相对抽象的概念,即可以实现弹窗、蒙层等功能,也可以实现 View 无关的功能,和 Element 同样,将代码拆分开。

    @interface PlayInteractionBaseManager : NSObject <PlayInteractionDispatcherProtocol>

    - (UIView *)view;

    @end
    • PlayInteractionBaseManager 同样实现了 PlayInteractionDispatcherProtocol 协议,因此具备了所有的交互区协议调用能力
    • Manager 不提供 View 的创建能力,这里的 view 是 UIViewController 的 view 引用,比如需要加蒙层,那么加到 manager 的 view 中就相当于加到 UIViewController 的 view 中
    • 弹窗、蒙层通过此种方式实现,Manager 并不负责弹窗、蒙层间的互斥、优先级逻辑处理,需要单独的机制去做

    方法派发

    业务框架层中定义的协议,需要框架层调用,SDK 层是感知不到的,由于 Element、Manager 众多,需要一个机制来封装批量调用过程,如下图所示:

    分层结构

    旧交互区使用了 VIPER 范式,抖音里整体使用的 MVVM,多套范式会增加学习、维护成本,并且使用 Element 开发时,VIPER 层级过多,因此考虑统一为 MVVM。

    VIPER 整体分层结构


    MVVM 整体分层结构


    在 MVVM 结构中,Element 职责和 ViewController 概念很接近,也可以理解为更纯粹、更专用的的 ViewController。

    经过 Element 拆分后,每个子功能已经内聚在一起,代码量是有限的,可以比较好的支撑业务开发。

    Element 结合 MVVM 结构





    • Element:如果是特别简单的元素,那么只提供 Element 的实现即可,Element 层负责基本的实现和跳转
    • ViewModel:部分元素逻辑比较复杂,需要将逻辑抽离出来,作为 ViewModel,对应目前的 Presentor 层
    • Tracker:埋点工具,埋点也可以写在 VM 中,对应目前的 Interactor
    • Model:绝大多数使用主 Model 即可

    业务层

    业务层中存放的是 Element 实现,主要有两种类型:

    • 通用业务:如作者信息、描述、头像、点赞、评论等通用的功能
    • 子业务线业务:十几条子业务线,不一一列举

    通用业务 Element 和交互区代码放在一起,子业务线 Element 放在业务线中,代码物理隔离后,职责会更明确,但是这也带来一个问题,当框架调整时,需要改多个仓库,并且可能修改遗漏,所以重构初期可以先放一起,稳定后再迁出去。

    过度设计误区

    设计往往会走两个极端,没有设计、过度设计。

    所谓没有设计是在现有的架构、模式下,没有额外思考过差异、特点,照搬使用。

    过渡设计往往是在吃了没有设计的亏后,成了惊弓之鸟,看什么都要搞一堆配置、组合、扩展的设计,简单的反而搞复杂了,过犹不及。

    设计是在质量、成本、时间等因素之间做出权衡的艺术。

    实施方案

    业务开发不能停,一边开发、一边重构,相当于在高速公路上不停车换轮胎,需要有足够的预案、备案,才能保证设计方案顺利落地。

    改动评估

    先估算一下修改规模、周期:

    • 代码修改量:近 4 万行
    • 时间:半年

    改动巨大、时间很长,风险是难以控制的,每个版本都有大量业务需求,需要改大量的代码,在重构的同时,如果重构的代码和新需求代码冲突,是非常难解的,因此考虑分期。

    上面已经多次说到功能的重要性,需要考虑重构后,功能是否正常,如果出了问题如何处理、如何证明重构后的功能和之前是一致的,对产品数据无影响。

    实施策略

    基本思路是实现一个新页面,通过 ABTest 来切换,核心指标无明显负向则放量,全量后删除旧代码,示意图如下:


    共分为三期:

    • 一期改造内容如上图红色所示:抽取协议,面向协议编程,不依赖具体类,改造旧 VC,实现协议,将协议之外暴露的方法、属性收敛到内部
    • 二期改造内容如蓝色所示:新建个新 VC,新 VC 和旧 VC 在功能上是完全一致,实现协议,通过 ABTest 来控制使用方拿到的是旧 VC 还是新 VC
    • 三期内容:删掉旧 VC、ABTest,协议、新 VC 保留,完成替换工作

    其中二期是重点,占用时间最多,此阶段需要同时维护新旧两套页面,开发、测试工作量翻倍,因此要尽可能的缩短二期时间,不要着急改代码,可以将一期做完善了、各方面的设计准备好再开始。

    ABTest

    2 个目的:

    • 利用 ABTest 作为开关,可以灵活的切换新旧页面
    • 用数据证明新旧页面是一致的,从业务功能上来说,二者完全一致,但实际情况是否符合预期,需要用留存、播放、渗透率等核心指标证明

    两套页面的开发方式

    在二期中,两套页面 ABTest 切换方式是有成本的,需求开发两套、测试两遍,虽然部分代码可共用,但成本还是大大增加,因此需要将这个阶段尽可能缩短。

    另外开发、测试两套,不容易发现问题,而一旦出问题,即便能用 ABTest 灵活切换,但修复问题、重新上线、ABTest 数据有结论,也需要非常长的周期。

    如果每个版本都出问题,那将会是上线、发现问题,重新修复再上线,又发现了新问题,无限循环,可能一直无法全量。

    图片

    如上图所示,版本单周迭代,发现问题跟下周修复,那么需要经过灰度、上线灰度(AppStore 的灰度放量)、ABTest 验证(AB 数据稳定要 2 周),总计要 6 周的时间。

    让每个同学理解整体运作机制、成本,有助于统一目标,缩短此阶段周期。

    删掉旧代码

    架构设计上准备充足,删掉旧代码非常简单,删掉旧文件、ABTest 即可,事实上也是如此,1 天内就完成了。

    代码后入后,有些长尾的事情会持续 2、3 个版本,例如有些分支,已经修改了删掉的代码,因为文件已经不存在了,只要修改,必定会冲突,合之前,需要 git merge 一下源分支,将有冲突的老页面再删掉。

    防崩溃兜底

    面向协议开发两套页面,如果增加一个功能时,新页面遗漏了某个方法的话,期望可以不崩溃。利用 Objective-C 语言消息转发可以实现这特性,在 forwardingTargetForSelector 方法中判断方法是否存在,如果不存在,添加一个兜底方法即可,用来处理即可。


    - (id)forwardingTargetForSelector:(SEL)aSelector {
      Class clazz = NSClassFromString(@"TestObject");
      if (![self isExistSelector:aSelector inClass:clazz]) {
        class_addMethod(clazz, aSelector, [self safeImplementation:aSelector], [NSStringFromSelector(aSelector) UTF8String]);
      }

      Class Protector = [clazz class];
      id instance = [[Protector alloc] init];
      return instance;
    }

    - (BOOL)isExistSelector:(SEL)aSelector inClass:(Class)clazz {
      BOOL isExist = NO;
      unsigned int methodCount = 0;
      Method *methods = class_copyMethodList(clazz, &methodCount);
      NSString *aSelectorName = NSStringFromSelector(aSelector);
      for (int i = 0; i < methodCount; i++) {
        Method method = methods[i];
        SEL selector = method_getName(method);
        NSString *selectorName = NSStringFromSelector(selector);
        if ([selectorName isEqualToString: aSelectorName]) {
          isExist = YES;
          break;
        }
      }
      return isExist;
    }

    - (IMP)safeImplementation:(SEL)aSelector {
      IMP imp = imp_implementationWithBlock(^(){
        // log
      });
      return imp;
    }

    线上兜底降低影响范围,内测提示尽早发现,在开发、内测阶段时可以用比较强的交互手段提示,如 toast、弹窗等,另外可以接打点上报统计。

    防劣化

    需要明确的规则、机制防劣化,并持续投入精力维护。

    不是每个人都能理解设计意图,不同职责的代码放在应该放的位置,比如业务无关的代码,应该下沉到框架层,降低被破坏的概率,紧密的开发节奏,即便简单的 if else 也容易写出问题,例如再加 1 个条件,几乎都会再写 1 个 if,直至写了几十个后,发现写不下去了,再推倒重构,期望重构一次后,可以保持得尽可能久一些。

    更严重的是在重构过程中,代码就可能劣化,如果问题出现的速度超过解决的速度,那么将会一直疲于救火,永远无法彻底解决。



    新方案中,业务逻辑都放在了 Element 中,ViewController、容器中剩下通用的代码,这部分代码业务同学是没必要去修改,不理解整体也容易改出问题,因此这部分代码由专人来维护,各业务同学有需要改框架层代码的需求,专人来修改。

    各 Element 按照业务线划分为独立文件,自己维护的文件可以加 reviewer 或文件变更通知,也可以迁到业务仓库中,进行物理隔离。

    日志 & 问题排查

    稳定复现的问题,比较容易排查和解决,但概率性的问题,尤其是 iOS 系统问题引起的概率性问题,比较难排查,即便猜测可能引起问题的原因,修改后,也难以自测验证,只能上线再观察。

    关键信息提前加日志记录,如用户反馈某个视频有问题,那么需要根据日志,找到相应的 model、Element、View、布局、约束等信息。

    信息同步

    改动过广,需要及时周知业务线的开发、测试、产品同学,几个方式:

    • 拉群通知
    • 周会、周报

    开发同学最关注的点是什么时候放量、什么时候全量、什么时候可以删掉老代码,不用维护 2 套代码。

    其次是改动,框架在不够稳定时,是需要经常改的,如果改动,需要相应受影响的功能的维护同学验证,以及确认测试是否介入。

    产品同学也要周知,虽然产品不关注怎么做,但是一旦出问题,没有周知,很麻烦。

    保证质量

    最重要的是及时发现问题,这是避免或者减少影响的前提条件。

    常规的 RD 自测、QA 功能测试、集成测试等是必备的,这里不多说,主要探讨其他哪些手段可以更加及时的发现问题。

    新开发的需求,需要开发新、老页面两套代码,同样,也要测试两次,虽然多次强调,但涉及到多个业务线、跨团队、跨职责、时间线长,很容易遗漏,而新页面 ABTest 放量很小,一旦出问题,很难被发现,因此对线上和测试用户区分处理:

    • 线上、线下流量策略:线上 AppStore 渠道 ABTest 按数据分析师设计放量;内测、灰度等线下渠道放量 50%,新旧两套各占一半,内测、灰度人员还是有一定规模的,如果是明显的问题,比较容易发现的
    • ABTest 产品指标对照:灰度、线上数据都是有参考价值的,按照 ABTest 数据量,粗评一下是否有问题,如果有明显问题,可及时深入排查
    • Slardar ABTest 技术指标对照:最常用的是 crash 率,对比对照组和实验组的 crash 率,看下是否有新 crash,实验组放量比较小,单独的看 crash 数量是很难发现的,也容易忽略。另外还要别的技术指标,也可以关注下
    • Slardar 技术打点告警配置:重构周期比较长,难以做到每天都盯着,关键位置加入技术打点,系统中配置告警,设置好条件,这样在出现问题时,会及时通知你
    • 单元测试:单测是保证重构的必要手段,在框架、SDK 等核心代码,都加入了单测
    • UI 自动化测试:如果有完整的验证用例,可以一定程度上帮助发现问题

    排查问题

    稳定复现的问题比较容易定位解决,两类问题比较头疼,详细讲一下:

    • ABTest 指标负向
    • 概率性出现的问题

    ABTest 指标负向

    ABTest 核心指标负向,是无法放量的,甚至要关掉实验排查问题。

    有个分享例子,分享总量、人均分享量都明显负向,大体经过这样几个排查过程:

    排查 ABTest 指标和排查 bug 类似,都是找到差异,缩小范围,最终定位代码。

    • 对比功能:从用户使用角度找差异,交互设计师、测试、开发自测都没有发现有差异
    • 对比代码:对比新老两套打点代码逻辑,尤其是进入打点的条件逻辑,没有发现差异
    • 拆分指标:很多功能都可以分享,打点平台可以按分享页面来源拆分指标,发现长按弹出面板中的分享减少,其他来源相差不大,进一步排查弹出面板出现的概率发现明显变低了,大体定位问题范围。另外值得一提的是,不喜欢不是很核心的指标,并且不喜欢变少,意味着视频质量更高,所以这点是从 ABTest 数据中难以发现的
    • 定位代码:排查面板出现条件发现,老代码中是在长按手势中,排除了个别的点赞、评论等按钮,其他位置(如果没有添加事件)都是可点的,比如点赞、评论按钮之间的空白位置,而新代码中是将右侧按钮区域、底部统一排除了,这样空白区域就不能点了,点击区域变小了,因此出现概率变小了
    • 解决问题:定位问题后,修复比较简单,还原了旧代码实现方式

    这个问题能思考的点是比较多的,重构时,看到了不好的代码,到底要不要改?

    比如上面的问题,增加了功能后,不知道是否应该排除点击,很容易被忽略,长按属于底层逻辑,具体按钮属于业务细节,底层逻辑依赖了细节是不好的,可维护性很差,但是修改后,很可能影响交互体验和产品指标,尤其是核心指标,一旦影响,没有太多探讨空间。

    具体情况具体评估,如果预估到影响了功能、交互,尽量不要改,大重构尽可能先解决核心问题,局部问题可以后续单独解决。

    下面是长按面板中的分享数据截图,明显降低,其他来源基本保持一致,就不贴图了。

    长按蒙层出现率降低 10%左右,比较自然的猜测蒙层出现率降低。

    对比 View 视图差异确认问题。



    类似的问题很多,ABTest 放量、全量过程要有充足的估时和耐心,这个过程会大大超过预期。抖音核心指标几乎都和交互区相关,众多分析师和产品都要关注,因此先理解一下分析师、产品和开发同学对 ABTest 指标负向的认知差别。

    大部分指标是正向,个别指标负向,那么会被判断为负向。

    开发同学可能想的是设计的合理性、代码的合理性,或者从整体的收益、损失角度的差值考虑,但分析师会优先考虑不出问题、别有隐患。两种方式是站在不同角度、目标考虑的,没有对错之分,事实上分析师帮忙发现了非常多的问题。目前的分析师、产品众多,每个指标都有分析师、产品负责,如果某个核心指标明显负向,找相应的分析师、产品讨论,是非常难达成一致的,即使是先放量再排查的方案也很难接受,建议自己学会看指标,尽早跟进,关键时找人帮忙推进。

    概率性出现的问题

    概率性出现的问题难点在于,很难复现,无法调试定位问题,修改后无法测试验证,需要上线后才能确定是否修复,举一个实际的例子的 iOS9 上 crash 例子,发现过程:

    • 通过 slardar=>AB 实验=>指定实验=>监控类型=>崩溃 发现的,可以看到实验组和对照组的 crash 率,其他的 OOM 等指标也可以用这个功能查看

    下面是 crash 的堆栈,crash 率比较高,大约 50%的 iOS 9 的用户会出现:



    crash 堆栈在系统库中,无法看到源码,堆栈中也无法找到相关的问题代码,无法定位问题 ,整个解决过程比较长,尝试用过的方式,供大家参考:

    • 手动复现,尝试修改,可以复现,但刷一天也复现不了几次,效率太低,对部分问题来说,判断准的话,可以比较快的解决
    • swizzle 系统崩溃的方法,日志记录最后崩溃的 View、相关 View 的层次结构,缩小排查范围
    • 自动化测试复现,可以用来验证是否修复问题,无法定位问题
    • 逆向看 UIKit 系统实现,分析崩溃原因

    逆向大体过程:

    • 下载 iOS9 Xcode & 模拟器文件
    • 提取 UIKit 动态库
    • 分析 crash 堆栈,通过 crash 最后所在的_layoutEngine、_addOrRemoveConstraints、_withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists 3 个关键方法,找到调用路径,如下图所示:

    • _withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists 中调用了 deactivateConstraints 方法,deactivateConstraints 中又调用了_addOrRemoveConstraints 方法,和 crash 堆栈中第 3 行匹配,那么问题就出在此处,为方便排查,逆向出相关方法的具体实现,大体如下:
    @implementation UIView
    - (void)_withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists:(Block)action {
        id engine = [self _layoutEngine];
        id delegate = [engine delegate];
        BOOL suspended = [delegate _isUnsatisfiableConstraintsLoggingSuspended];
        [delegate _setUnsatisfiableConstraintsLoggingSuspended:YES];
        action();
        [delegate _setUnsatisfiableConstraintsLoggingSuspended:suspended];
        if (suspended == YES) {
            return;
        }
        NSArray *constraints = [self _constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended];
        if (constraints.count != 0) {
            NSMutableArray *array = [[NSMutableArray alloc] init];
            for (NSLayoutConstraint *_cons : constraints) {
                if ([_cons isActive]) {
                    [array addObject:_cons];
                }
            }
            if (array.count != 0)  {
                [NSLayoutConstraint deactivateConstraints:array]; // NSLayoutConstraint 入口
                [NSLayoutConstraint activateConstraints:array];
            }
        }
        objc_setAssociatedObject(
                    self,
                    @selector(_constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended),
                    nil,
                    OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }

    @end

    @implementation NSLayoutConstraint
    + (void)activateConstraints:(NSArray *)_array {
        [self _addOrRemoveConstraints:_array activate:YES]; // crash堆栈中倒数第3个调用
    }
    + (void)deactivateConstraints:(NSArray *)_array {
        [self _addOrRemoveConstraints:_array activate:NO];
    }
    @end
    • 从代码逻辑和_constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended 方法的命名语义上看,此处代码主要是用来处理无法满足约束日志的,应该不会影响功能逻辑
    • 另外,分析时如果无法准确判断 crash 位置,则需要逆向真机文件,相比模拟器,真机的堆栈是准确的,通过原始 crash 堆栈偏移量找到最后的代码调用

    拿到结果

    • 开发效率:将之前 VIPER 结构的 5 个文件,拆分了大约 50 个文件,每个功能的职责都在业务线内部,添加、修改不再需要看所有的代码了,调研问卷显示开发效率提升在 20%以上
    • 开发质量:从 bug、线上故障来看,新页面问题是比较少的,而且出问题一般的都是框架的问题,修复后是可以避免批量的问题的
    • 产品收益:虽然功能一致,但因为重构设计的性能是有改进的,核心指标正向收益明显,实验开启多次,核心指标结论一致

    勇气

    最后这部分是思考良久后加上的,重构本身就是开发的一部分,再正常不过,但重构总是难以进行,有的浅尝辄止,甚至半途而废。公司严格的招聘下,能进来的都是聪明人,不缺少解决问题的智慧,缺少的是勇气,回顾这次重构和上面提到过的“曾经尝试过的方式”,也正是如此。

    代码难以维护时是比较容易发现的,优化、重构的想法也很自然,但是有两点让重构无法有效开展:

    • 什么时候开始
    • 局部重构试试

    在讨论什么时候开始前,可以先看个词,工作中有个流行词叫 ROI,大意是投入和收益比率,投入越少、收益越高越好,最好是空手套白狼,这个词指导了很多决策。

    重构无疑是个费力的事情,需要投入非常大的心力、时间,而能看到的直接收益不明显,一旦改出问题,还要承担风险,重构也很难获得其他人认可,比如在产品看来,功能完全没变,代码还能跑,为什么要现在重构,新需求还等着开发呢,有问题的代码就是这样不断的拖着,越来越严重。

    诚然,有足够的痛点时重构是收益最高的,但只是看起来,真实的收益是不变的,在这之前需要大量额外的维护成本,以及劣化后的重构成本,从长期收益看,既然要改就趁早改。决定要做比较难,说服大家更难,每个人的理解可能都不一样,对长期收益的判断也不一样,很难达成一致。

    思者众、行者寡,未知的事情大家偏向谨慎,支持继续前行的是对技术追求的勇气。

    重构最好的时间就是当下。

    局部重构,积少成多,最终整体完成,即便出问题,影响也是局部的,这是自下向上的方式,本身是没问题的,也经常使用,与之对应的是自上向下的整体重构,这里想强调的是,局部重构、整体重构只是手段,选择什么手段要看解决什么问题,如果根本问题是整体结构、架构的问题,局部重构是无法解决的。

    比如这次重构时,非常多的人都提出,能否改动小一点、谨慎一点,但是设计方案是经过分析梳理的,已经明确是结构性问题,局部重构是无法解决的,曾经那些尝试过的方式也证明了这一点。

    不能因为怕扯到蛋而忘记奔跑。



    摘自字节抖音技术团队:https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247488646&idx=1&sn=ae046434bf98c5c8cbc0d567e133206c&chksm=e9d0df64dea7567236ffb907d984f45ddb6cce10618601e10683d545ef0b1a55512df4d249ba&scene=178&cur_album_id=1590407423234719749#rd



    收起阅读 »

    抖音研发效能建设 - CocoaPods 优化实践

    背景抖音很早就接入 CocoaPods 进行依赖管理了,项目前期抖音只有几十个组件,业务代码也基本在壳工程内,CocoaPods 可以满足业务研发的需求,但是随着业务的不断迭代,代码急剧膨胀,同时抖音工程也在进行架构优化,比如工程组件化改造,组件的数量和复杂度...
    继续阅读 »

    背景

    抖音很早就接入 CocoaPods 进行依赖管理了,项目前期抖音只有几十个组件,业务代码也基本在壳工程内,CocoaPods 可以满足业务研发的需求,但是随着业务的不断迭代,代码急剧膨胀,同时抖音工程也在进行架构优化,比如工程组件化改造,组件的数量和复杂度不断增加:组件(Pod)数量增加到 400+ ,子组件(Subspec)数量增加到 1500+ ,部分复杂组件的描述文件(podspec)膨胀到 1000+ 行,这导致了依赖管理流程(主要是 Pod Install)的效率不断下降,同时也导致了 Xcode 检索和构建效率下降。

    除了效率下降外,我们也开始遇到一些 CocoaPods 潜在的稳定性问题,比如在 CI/CD 任务并发执行的环境下 Pod Install 出现大量失败,这些问题已经严重影响了我们的研发效率。在超大工程、复杂依赖、快速迭代的背景下,CocoaPods 已经不能很好地支撑我们的研发流程了。

    1. 反馈最多就是 Pod Install 慢,经常会有同学反馈 Pod Install 流程慢,涉及到决议流程慢,依赖下载慢、Pods 工程生成慢等
    2. 本地 Source 仓库没更新,经常导致找不到 Specification,Pod Install 失败
    3. 依赖组件多,循环依赖报错,但是难以找到循环链路
    4. 依赖组件多,User 工程复杂度,导致 Pod Install 后 Xcode 工程索引慢,卡顿严重
    5. 依赖组件多,工程构建出现不符合预期的失败问题,比如 Arguments Too Long
    6. 研发流程上,有部分研发同学本地误清理了 CocoaPods 缓存,导致工程编译或者链接失败
    7. 组件拆分后,新添加文件必须 Pod Install 后才可以被其他组件访问,这拖慢了研发效率

    我们开始尝试在 0 侵入、不影响现有研发流程的前提下,改造 CocoaPods 做来解决我们遇到的问题,并且取得了一些收益。在介绍我们的优化前,我们会先对 CocoaPods 做一些介绍, 我们以 CocoaPods 1.7.5 为例来做说明依赖管理的核心流程「Pod Install」

    Pod Install

    我们以一个 MVP 工程「iOSPlayground」为例子来说明,iOSPlayground 工程是怎么组织的:

    iOSPlayground.xcodeproj壳工程,包含 App Target:iOSPlayground
    iOSPlayground壳工程文件目录,包含资源、代码、Info.plist
    Podfile声明 User Target 的依赖
    Gemfile声明 CocoaPods 的版本,这里是 1.7.5

    我们在 Podfile 中为 Target「iOSPlayground」引入 SDWebImage 以及 SDWebImage 的两个 Coder,并声明这些组件的版本约束

    platform :ios, '11.0'
    project 'iOSPlayground.xcodeproj'
    target 'iOSPlayground' do
      pod 'SDWebImage''~> 5.6.0'
      pod 'SDWebImageLottieCoder''~> 0.1.0'
      pod 'SDWebImageWebPCoder''~> 0.6.1'
    end

    然后执行 Pod install 命令 bundle exec pod install,CocoaPods 开始为你构建多依赖的开发环境;整个 Pod Install 流程最核心的就是 ::Pod::Installer 类,Pod Install 命令会初始化并配置 Installer,然后执行 install! 流程,install! 流程主要包括 6 个环节


    def install!
      prepare
      resolve_dependencies # 依赖决议
      download_dependencies # 依赖下载
      validate_targets # Pods 校验
      generate_pods_project # Pods Project 生成
      if installation_options.integrate_targets?
        integrate_user_project # User Project 整合
      else
        UI.section 'Skipping User Project Integration'
      end
      perform_post_install_actions # 收尾
    end

    下面会对这 5 个流程做一些简单分析,为了简单起见,我们会忽略一些细节。


    准备阶段

    这个流程主要是在 Pod Install 前做一些环境检查,并且初始化 Pod Install 的执行环境。


    依赖分析

    这个流程的主要目标是分析决议出所有依赖的版本,这里的依赖包括 Podfile 中引入的依赖,以及依赖本身引入的依赖,为 Downloader 和 Generator 流程做准备。


    这个过程的核心是构建 Molinillo 决议的环境:准备好 Specs 仓库,分析 Podfile 和 Podfile.lock,然后进行 Molinillo 决议,决议过程是基于 DAG(有向无环图)的,可以参考下图,按照最优顺序依次进行决议直到最后决议出所有节点上依赖的版本和来源。





    Version一般是用点分割的可以比较的序列,组件会以版本的形式对外发布
    Requirement一个或者多个版本限制的组合
    SourceSpecs 仓库,组件发版的位置,用于管理多个组件多个版本的一组描述文件
    DependencyUser Target 的依赖或者依赖的依赖,由依赖名称、版本、约束、来源构成
    PodfileRuby DSL 文件,用于描述 Xcode 工程中 Targets 的依赖列表
    Podfile.lockYAML 文件,Pod Install 后生成的依赖决议结果文件
    PodspecRuby DSL 文件,用于描述 Pod,包括名称、版本、子组件、依赖列表等
    Pod Target一个组件对应一个 Pod Target
    Aggregate Target用来聚合一组 Pod Target,User Target 会依赖对应的 Aggragate Target
    $HOME/.cocoapods/repos/本地存储需要使用的 Specs 仓库

    依赖下载

    这个流程的目标是下载依赖,下载前会根据依赖分析的结果 specifications 和 sandbox_state 生成需要下载的 Pods 列表,然后串行下载所有依赖。这里只描述 Cache 开启的情况,具体流程可以参考下图:



    CocoaPods 会根据 Pod 来源选择合适的下载器,如果是 HTTP 地址,使用 CURL 进行下载;如果是 Git 地址,使用 Git 进行拉取;CocoaPods 也支持 SVN/HG/SCP 等方式。

    iOSPlayground 工程的下载流程:



    Pods 工程生成

    这个流程的目标是生成 Pods 工程,根据依赖决议的结果 Pod Targets 和 Aggregate Targets,生成 Pods 工程,并生成工程中 Pod Targets 和 Aggregate Targets 对应的 Native Targets。


    CocoaPods 提供两种 Project 的生成策略:Single Project Generator 和 Multiple Project Generator,Single Project Generator 是指只生成 Pods/Pods.xcodeproj,Native Pod Target 属于 Pods.xcodeproj;Multiple Project 是 CocoaPods 1.7.0 引入的新功能,不只会生成 Pods/Pods.xcodeproj,并且会为每一个 Pod 单独生成 Xcode Project,Pod Native Target 属于独立的 Pod Xcode Project,Pod Xcode Project 是 Pods.xcodeproj 的子工程,相比 Single Project Generator,会有性能优势。这里我们以 Single Project 为例,来说明 Pods.xcodeproj 生成的一般流程。




    Pods/
    沙盒目录
    Pods/Pods.xcodeprojPod Target、Aggregate Target 的容器工程
    Pods/Manifest.lockPodfile.lock 的备份,项目构建前会和 Podfile.lock 比较,以判断当前的沙盒和工程对应
    Pods/Headers/管理 Pod 头文件的目录,支持基于 HEADER_SEARCH_PATHS 的头文件检索
    Pods/Target Support Files/CocoaPods 为 Pod Target、Aggregate Target 生成的文件,包括:xcconfig、modulemap、resouce copy script、framework copy scrpt 等

    User 工程整合

    这个流程的目标是将 Pods.xcodeproj 整合到 User.xcodeproj 上,将 User Target 整合到 CocoaPods 的依赖环境中,从而在后续的构建流程生效:



    User.xcodeproj壳工程,用于生成 App 等产品,名字一般自定义
    User Target壳工程中用于生成指定产品的 Target
    User.xcworkspaceCocoaPods 生成,合并 User.xcodeproj 和 Pods/Pods.xcodeproj

    User 工程构建

    Pod Install 执行完成后,就将 User Target 整合到了 CocoaPods 环境中。User Target 依赖 Aggregate Target,Aggregate Target 依赖所有 Pod Targets,Pod Targets 按照 Pod 描述文件(Podspec)中的依赖关系进行依赖,这些依赖关系保证了编译顺序

    iOSPlayground 工程中 User Target: iOSPlayground 依赖了 Aggregate Target 的产物 libPods-iOSPlayground.a


    编译完成后,就开始进行链接、资源整合、动态库整合、APP 签名等操作,直到最后生成完整 APP。Xcode 提供了 Build Phases 方便我们查看和编辑构建流程配置,同时我们也可以通过构建日志查看整个 APP 的构建流程:



    如何评估

    我们需要建立一些数据指标来进行衡量我们的优化结果,CocoaPods 内置了 ruby-prof(https://ruby-prof.github.io/) 工具。ruby-prof 是一个 Ruby 程序性能分析工具,可以用于测量程序耗时、对象分配以及内存占用等多种数据指标,提供了 TXT、HTML、CallGrind 三种格式。首先安装 ruby-prof,然后设置环境变量 COCOAPODS_PROFILE 为性能测试文件的地址,Pod Install 执行完成后会输出性能指标文件

    ruby-prof 提供的数据是我们进行 CocoaPods 效能优化的重要参考,结合这部分数据我们可以很方便地分析方法堆栈的耗时以及其他性能指标。


    但是 Ruby-prof 工具是 Ruby 方法级别,难以细粒度地查看实际 Pod Instal 过程中各个具体流程的耗时,可以作为数据参考,但是难以作为效率优化结果的标准。同时我们也需要一套体系来衡量 Pod Install 各个流程的耗时,基于这个诉求,我们自研了 CocoaPods 的 Profiler,并且在远端搭建了数据监控体系:

    1. Profiler 可以在本地打印各阶段耗时,也可以下钻到详细的流程

    install! consume : 5.376132s prepare consume : 0.002049s resolve_dependencies consume : 4.065177s download_dependencies consume : 0.001196s validate_targets consume : 0.037846s generate_pods_project consume : 0.697412s integrate_user_project consume : 0.009258s

    1. Profiler 会把数据上传到平台,方便进行数据可视化



    Profiler 除了上传 Pod Install 各个耗时指标以外,也会上传失败情况和错误日志,这些数据会被用于衡量稳定性优化的效果。

    优化实践

    对 Pod Install 的执行流程有了一定的了解后,基于 Ruby 语言的提供的动态性,我们开始尝试在 0 侵入、不影响现有研发流程的前提下,改造 CocoaPods 做来解决我们遇到的问题,并且取得了一些收益。

    Source 更新

    按需更新

    我们知道 CocoaPods 在进行依赖版本决议的时候,会从本地 Source 仓库(一般是多个 Git 仓库)中查找符合版本约束的 Podspecs,如果本地仓库中没有符合要求的,决议会失败。仓库中没有 Podspec 分为几种情况:

    1. 本地 Source 仓库没有更新,和远程 Source 仓库不同步
    2. 远程 Source 仓库没有发布符合版本约束的 Podspec

    原因 2 是符合预期的;原因 1 是因为研发同学没有主动更新本地 source repo 仓库,可以在 pod install 后添加 --repo-update 参数来强制更新本地仓库,但是每次都加上这个参数会导致 Pod Install 执行效率下降,尤其是对包含多个 source repo 的工程。

    UI.section 'Updating local specs repositories' do
      analyzer.update_repositories
    end if repo_update?


    怎么做可以避免这个问题,同时保证研发效率?

    1. 不主动更新仓库,如果找不到 Podspec,再自动更新仓库
    2. 不更新所有仓库,按需更新部分仓库
    3. 如果有新增组件,找不到 Podspec 后,自动更新所有仓库
    4. 如果部分更新后依然失败,自动更新所有仓库;这种情况出现在隐式依赖新增的情况


    仓库按需更新,是指基于 Podfile.lock 查找哪些依赖的版本不在所属的仓库内,标记该依赖所属的仓库为需要更新,循环执行,检查所有依赖,获取到所有需要更新的仓库,更新所有标记为需要更新的仓库。

    这样研发同学不需要关心本地 Source 仓库是否更新,仓库会按照最佳方式自动和远程同步。

    更新同步

    在仓库更新流程中也会出现并发问题,比如在抖音的 CI 环境上构建任务是并发执行的,在某些情况下多个任务会同时更新本地 source 仓库,Git 仓库会通过锁同步机制强制并发更新失败,这就导致了 CI 任务难以并发执行。如何解决并发导致的失败问题?

    1. 最简单的方式就是避免并发,一个机器同时只能执行一个任务,但是这会导致 CI 执行效率下降。
    2. 不同任务间进行 source 仓库隔离,CocoaPods 默认提供了这种机制,可以通过环境变量 CP_REPOS_DIR 的设置来自定义 source 仓库的根目录,但是 source 仓库隔离后,会导致同一个仓库占用多份磁盘,同时在需要更新的场景下,需要更新两次,这会影响到 CI 执行效率。

    方案 1 和方案 2 一定程度保证了任务的稳定性,但是影响了研发效率,更好的方式是只在需要同步的地方串行,不需要同步的地方并发执行。一个自然而然的想法就是使用锁,不同 CocoaPods 任务是不同的 Ruby 进程,在进程间做同步可以使用文件锁。通过文件锁机制,我们保证了只有一个任务在更新仓库。

    CocoaPods 仓库更新流程流程遇到的问题,本质是由于使用了本地的 Git 仓库来管理导致,在 CocoaPods 1.9.0 + ,引入 CDN Source 的概念,抖音也在尝试向 CDN Source 做迁移。

    依赖决议

    简化决议

    CocoaPods 的依赖版本决议流程是基于 Molinillo 的,Molinillo 是基于 DAG 来进行依赖解析的,通过构建图可以方便的进行依赖关系查找、依赖环查找、版本降级等。但是使用图来进行解析是有成本的,实际上大部分的本地依赖决议场景并不需要这么复杂,Podfile.lock 中的版本就是决议后的版本,大部分的研发流程直接使用 Podfile.lock 进行线性决议就可以,这可以大幅加快决议速度。

    Specification 缓存

    依赖分析流程中,CocoaPods 需要获取满足约束的 Specifications,1.7.5 上的流程是获取一个组件的所有版本的 Specifications 并缓存,然后从 Specifications 中筛选出满足约束的 Specifications。对于复杂的项目来说,往往对一个依赖的约束来自于多个组件,比如 A 依赖 F(>=0),B 依赖 F (>=0),在分析完 A 对 F 的依赖后,在处理 B 对 F 的依赖时,还是需要进行一次全量比较。通过优化 Specification 缓存层可以减少这部分耗时,直接返回。

    module Pod::Resolver
      def specifications_for_dependency(dependency, additional_requirements = [])
        requirement = Requirement.new(dependency.requirement.as_list + additional_requirements.flat_map(&:as_list))
        find_cached_set(dependency).
          all_specifications(warn_for_multiple_pod_sources).
     select { |s| requirement.satisfied_by? s.version }.
          map { |s| s.subspec_by_name(dependency.name, falsetrue) }.
          compact
      end
    end

    module Pod::Specification::Set
      def all_specifications(warn_for_multiple_pod_sources)
         @all_specifications ||= begin
          #...
        end
      end
    end

    优化后:


    module Pod::Resolver
      def specifications_for_dependency(dependency, additional_requirements = [])
        requirement_list = dependency.requirement.as_list + additional_requirements.flat_map(&:as_list)
        requirement_list.uniq!
        requirement = Requirement.new(requirement_list)
        find_cached_set(dependency).
          all_specifications(warn_for_multiple_pod_sources, requirement) .
          map { |s| s.subspec_by_name(dependency.name, falsetrue) }.
          compact
      end
    end

    module Pod::Specification::Set
      def all_specifications(warn_for_multiple_pod_sources, requirement)
        @all_specifications ||= {}
        @all_specifications[requirement]  ||= begin
          #...
        end
      end
    end

    CocoaPods 1.8.0 开始也引入了这个优化,但是 1.8.0 中并没有重载 Pod::Requirement 的 eql? 方法,这会导致使用 Pod::Requirement 对象做 Key 的情况下,没有办法命中缓存,导致缓存失效了,我们重载 eql? 生效决议缓存,加速了 Molinillo 决议流程,获得了很大的性能提升:

    module Pod::Requirement
      def eql?(other)
        @requirements.eql? other.requirements
      end
    end

    循环依赖发现

    当出现循环依赖时,CocoaPods 会报错,但报错信息只有谁和谁之间存在循环依赖,比如:

    There is a circular dependency between A/S1 and D/S1

    随着工程的复杂度提高,对于复杂的循环依赖关系,比如 A/S1 -> B -> C-> D/S2 -> D/S1 -> A/S1, 基于上面的信息我们很难找到真正的链路,而且循环依赖往往不止一条,subspec、default spec 等设置也提高了问题定位的复杂度。我们优化了循环依赖的报错,当出现循环依赖的时候,比如 A 和 D 之间有环,我们会查找 A -> D/S1 之前所有的路径,并打印出来:

    There is a circular dependency between A/S1 and D/S1 Possible Paths:A/S1 -> B -> C-> D/S2 -> D/S1 -> A/S1 A/S1 -> B -> C -> C2 -> D/S2 -> D/S1 -> A/S1 A/S1 -> B -> C -> C3 -> C2 -> D/S2 -> D/S1 -> A/S1

    沙盒分析缓存

    SandboxAnalyzer 主要用于分析沙盒,通过决议结果和沙盒内容判断哪些 Pods 需要删除哪些 Pods 需要重装,但是在分析过程中,存在大量的重复计算,我们缓存了 sandbox analyzer 计算的中间结果,使 sandbox analyzer 流程耗时减少 60%。

    依赖下载

    大型项目往往要引入几百个组件,一旦组件发布新版本或者没有命中缓存就会触发组件下载,依赖下载慢也成为大型项目反馈比较集中的问题。

    依赖并发下载

    CocoaPods 一个很明显的问题就是依赖是串行下载的,串行下载难以达到带宽峰值,而且下载过程除了网络访问,还会进行解压缩、文件准备等,这些过程中没有进行网络访问,如果把下载并行是可以提高依赖下载效率的。我们将抖音的下载过程优化为并发操作,下载流程总时间减少了 60%以上。

    HTTP API 下载

    CocoaPods 支持多种下载方式的,比如 Git、Http 等。一般组件以源码发布,会使用 Git 地址作为代码来源,但是 Git 下载是比 Http 下载慢的,一是 Git 下载需要做额外的处理和校验,速度和稳定性要低于 HTTP 下载,二是在组件是通过 Git 和 Commit 指明 source 发布的情况下,Git 下载页会克隆仓库的日志 GitLog, 对于开发比较频繁的项目,日志大小要远大于仓库实际大小,这会导致组件下载时间变长。我们基于 Gitlab API 将 Git 地址转化为 HTTP 地址进行下载,就可以加快这部分组件的下载速度了。

    沙盒软连接

    CocoaPods 在安装依赖的时候,会在沙箱 Pods 目录下查找对应依赖,如果对应依赖不存在,则会将缓存中的依赖文件拷贝到沙箱 Pods 目录下。对于本地有多个工程的情况,Pods 目录占用磁盘就会更多。同时,将缓存拷贝到沙箱也会耗时,对于抖音工程,如果所有的内容都要从缓存拷贝到沙箱,大概需要 60s 左右。我们使用软连接替换拷贝,直接通过链接缓存中的 Pod 内容来使用依赖,而不是将缓存拷贝到 Pods 沙箱目录中,从而减少这部分磁盘占用,同时减少拷贝的时间。

    缓存有效检查

    在抖音使用 CocoaPods 的过程中,尤其是 CI 并发环境,存在缓存中文件不全的情况,缺少部分文件或者整个文件夹,这会导致编译失败或者运行存在问题。CocoaPods 本身有保证 Pods 缓存有效的机制:



    def path_for_spec(request, slug_opts = {})
      path = root + 'Specs' + request.slug(slug_opts)
      path.sub_ext('.podspec.json')
    end

    但是在 依赖Podspec写入缓存 中,CoocoPods 存在 BUG。path.sub_ext('.podspec.json')会导致部分版本信息被错误地识别为后缀名,比如 XXX 0.1.8-5cd57.podspec.json 版本写入到缓存中变为 0.1.podspec.json, 丢失了小版本和内容标示信息,会导致了整个 Pod 缓存有效性校验失效。比如 XXX 0.1.8 缓存执行成功,XXX 0.1.9 在缓存 copy、prepare 的流程被取消,实际上很大概率上 XXX 0.1.9 的缓存是不完整的,但是下次执行的时候,缓存目录存在,Podspec 存在(0.1.podspec.json),不完整的缓存被判定为有效,使用了错误的缓存,导致了编译失败。

    修改 path_for_spec 逻辑,保证依赖 Podspec 缓存写入到正确的文件 0.1.8-5cd57.podspec.json,而不是 0.1.podspec.json。

    def path_for_spec(request, slug_opts = {})
      path = root + 'Specs' + request.slug(slug_opts)
      Pathname.new(path.to_path + '.podspec.json')
    end

    依赖下载同步

    在缓存下载的环境,依然会出现并发问题,我们通过对 Pod 下载流程加文件锁的机制来保证并发下下载任务的稳定。

    Pods 工程生成

    增量安装

    CocoaPods 在 1.7.0+ 提供了新的 Pods Project 的生成策略:Multiple Project Generator。通过开启多 Project「generate_multiple_pod_projects」,可以提高 Xcode 工程的检索速度。在开启多 Project 的基础上,我们可以开启增量安装「incremental_installation」,这样在 Pods 工程生成的时候,会基于上次 Pod Install 的缓存按需生成部分 Pod Target 而不会全量生成所有 Pod Target,对二次 Pod Install 的执行效率改善很明显,以抖音为例,二次 Pod Install (增量)是首次 Pod Install (全量)的 40%左右。这个是 CocoaPods 的 Feature,就不展开说明了。

    单 Target/Configuration 安装

    大部分工程会包含多个业务 Target 和 Build Configuration,Pod Install 会对所有的 Target 进行安装,对所有的 Build Configuration 进行配置。但是实际本地开发过程中一般只会使用一个 Build Configuration 下的一个 Target,其他 Target 和 Configuratioins 的依赖安装实际上是冗余操作。比如有些依赖只有某几个 Target 有,如果全量安装,即使不使用这些 Target,也要下载和集成这些依赖。抖音工程包括多个业务 Target 和多个构建 Build Configuration,不同业务 Target 之间依赖的差集有几十个,只对特定 Target 和特定的 Configuration 进行集成能够获得比较明显的优化,这个方案落地后:

    1. Pod Install 安装依赖数量减少,决议时间、Pod 工程生成时间减少;
    2. 单 Target/Configuration 下 Pod 工程复杂度减少, Xcode 索引速度改善明显,以抖音为例子,索引耗时减少了 60%;
    3. 可以为每个 Target、每个 Configuration 配置独立的依赖版本;
    4. 每个 Target 的编译隔离,避免了其他 Target 的依赖影响当前 Target 的编译。

    Pod 是全量 Target 安装,在编译的时候并没有对非当前 Target 的依赖做完整的隔离,而是在链接的时候做了隔离,但是 OC 的方法调用是消息转发机制的,因此没有链接指定库的问题被延迟到了运行时才能发现 (unrecognized selector)。使用单 Target 的方式可以提前发现这个类问题。

    缓存 FileAccessors

    在 Pods 工程生成流程中有三个流程会比较耗时,这些数据每次 Pod Install 都需要重新生成:

    • Pod 目录下的文件和目录列表,需要对目录下的所有节点做遍历;
    • Pod 目录下的动态库列表,需要分析二进制格式,判断是否为动态库;
    • Pod 文件的访问策略缓存 glob_cache,这个 glob_cache 是用于访问组件仓库中不同类型文件的,比如 source files、headers、frameworks、bundles 等。

    但其实这些数据对固定版本的依赖都是唯一的,如果可以缓存一份就可以避免二次生成导致的额外耗时,我们补充了这个缓存层,以抖音为例子,使 Pod Clean Install 减少了 36%,Pod No-clean Install 减少了 42%

    添加 FileAccessors 缓存层后,在效率上获得提升的同时,在稳定性上也获得了提升。因为在本地记录了 Pod 完整的文件结构,因此我们可以对 Pod 的内容做检查,避免 Pod 内容被删除导致构建失败。比如研发同学误删了缓存中的二进制库,CocoaPods 默认是难以发现的,需要延迟到链接阶段报 Symbol Not Found 的错误,但是基于 FileAccessors 缓存层,我们可以在 Pod Install 流程对 Pod 内容做检查,提前暴露出二进制库缺失,触发重新下载。

    提高编译并发度

    Pod Target 的依赖关系会保证 Target 按顺序编译,但是会导致 Target 编译的并发度下降,一定程度上降低了编译效率。其实生成静态库的 Pod Target 不需要按顺序进行编译,因为静态库编译不依赖产物,只是在最后进行链接。通过移除静态库的 Pod Target 对其他 Target 的依赖,可以提高整体的编译效率。

    在 Multi Project 下,「Dependency Subproject」会导致索引混乱,移除静态库的 Pod Target 对其他 Target 的依赖后,我们也可以删除 Dependent Pod Subproject,减少 Xcode 检索问题。

    Arguments Too Long

    超大型工程在编译时稳定性降低,往往会因为工程放置的目录长产生一些未定义错误,其中错误比较大的来源就是 Arguments Too Long,表现为:

    Build operation failed without specifying any errors ;Verify final result code for completed build operation

    根本原因是依赖数目过多导致编译/链接/打包流程的环境变量总数过多,从而导致命令长度超过 Unix 的限制,在构建流程中表现为各种不符合预期的错误,具体可以见https://github.com/CocoaPods/CocoaPods/issues/7383。

    其实整个构建流程的环境变量主要来源于系统 和 Build Settings,系统环境一般是固定的,影响比较大的就是 Build Settings 里的配置,其中影响最大的是:

    • 编译参数

      • GCC_PREPROCESSOR_MACRO 预编译宏
      • HEADER_SEARCH_PATHS 头文件查找路径
    • 链接参数

      • FRAMEWORK_SEARCH_PATHS FRAMEWORK 查找路径
      • LIBRARY_SEARCH_PATHS LIBRARY 查找路径
      • OTHER_LDFLAGS 用于声明连接参数,包括静态库名称

    一个比较直接的解决方案就是缩短工程目录路径长度来临时解决这个问题,但如果要彻底解决,还是要彻底优化 Build Setting 参数的复杂度,减少依赖数量可能会比较难,一个比较好的思路就是优化参数的组织方式。

    • GCC_PREPROCESSOR_MACRO,在壳工程拆分掉业务代码后,注入到 User Target 的预编译宏可以逐步废弃;
    • HEADER_SEARCH_PATHS 会引入所有头文件的目录作为 Search Path,这部分长度会随着 Pod 数目的增加不断增长,导致构建流程变量过长,从而让阻塞打包。我们基于 HMAP 将 Header Search Path 合并成一个来减少 Header Search Path 的复杂度。除了用于优化参数长度外,这个优化的主要用途是可以减少 header 的查找复杂度,从而提高编译速度,我们在后续的系列文章会介绍。
    HEADER_SEARCH_PATHS = $(inherited) "${PODS_ROOT}/Headers/hmap/37727fabd99bae1061668ae04cfc4123/Compile_Public.hmap"
    • 链接参数:FRAMEWORK_SEARCH_PATHS、LIBRARY_SEARCH_PATHS、OTHER_LDFLAGS 声明是为了给链接器提供可以查找的静态库列表。OTHER_LDFLAG S 提供 filelist 的方式来声明二进制路径列表,filelist 中是实际要参与链接的静态库路径,这样我们就可以三个参数简化为 filelist 声明,从而减少了链接参数长度。除了用于优化参数长度外,这个优化的主要用途是可以减少静态库的查找复杂度,从而提高链接速度,我们在后续的系列文章会介绍。
    OTHER_LDFLAGS[arch=*] = $(inherited) -filelist "xx-relative.filelist,${PODS_CONFIGURATION_BUILD_DIR}"


    研发流程

    新增文件

    组件化的一个目标是业务代码按架构设计拆分成组件 Pod。但如果在一个组件中新增文件,比如在组件 A 中新增文件,依赖组件 A 的组件 B 是不能直接访问新增文件的头文件的,需要重新执行 Pod Install,这样会影响整体的研发效率。

    为什么组件 B 不能够访问组件 A 的新增文件?在 Pod Install 后,组件 A 公共访问的头文件被索引在 Pods/Headers/Public/A/ 目录下,组件 B 的 HEADER_SEARCH_PATH 中配置了 Pods/Headers/Public/A/,因此就可以在组件 B 的代码里引入组件 A 的头文件。新增头文件的头文件没有在目录中索引,所以组件 B 就访问不到了。只需要在添加文件后,建立新增头文件的索引到 Pods/Headers/Public/A/目录下,就可以为组件 B 提供组件 A 新增文件的访问能力,这样就不需要重新 Pod Install 了。

    Lockfile 生成

    在依赖管理的部分场景中,我们只需要进行依赖决议,重新生成 Podfile.lock,但通过 Pod Install 生成是需要执行依赖下载及后续流程的,这些流程是比较耗时的,为了支持 Podfile.lock 的快速生成,可以对 install 命令做了简化,在依赖决议后就可以直接生成 Podfile.lock:

    class Pod::Installer
      def quick_generate_lockfile!
        # 初始化 sandbox 环境
        quick_prepare_env
        quick_resolve_dependencies
        quick_write_lockfiles
      end
    end

    总结

    CocoaPods 的整体优化方案以 RubyGem 「seer-optimize」 的方式输出,对 CocoaPods 代码 0 侵入,只要接入 seer-optimize 就可以生效,目前在字节内部已经被十几个产品线使用了:抖音、头条、西瓜、火山、多闪、瓜瓜龙等,执行效率和稳定性上都获得了明显的效果。比如抖音接入 optimize 开启相关优化后,全量 Pod Install 耗时减少 50%,增量 Pod Install 平均耗时减少 65%。

    seer-optimize 是抖音 iOS 工程化解决方案 Seer 的的一部分,Seer 致力于解决客户端在依赖管理和研发流程中遇到的问题,改善研发效率和稳定性,后续会逐步开源,以改善 iOS 的研发体验。


    摘自字节跳动技术团队:https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247489409&idx=1&sn=4f46332921d1f45594670d35bfa7d19a&chksm=e9d0dc63dea75575c526ef8f0e118b7e95d1cd3242de93e54d1db4e577dfe6406a7191a13b94&scene=178&cur_album_id=1590407423234719749#rd


    收起阅读 »

    「干货」面试官问我如何快速搜索10万个矩形?——我说RBush

    前言 亲爱的coder们,我又来了,一个喜欢图形的程序员👩‍💻,前几篇文章一直都在教大家怎么画地图、画折线图、画烟花🎆,难道图形就是这样嘛,当然不是,一个很简单的问题, 如果我在canvas中画了10万个点,鼠标在画布上移动,靠近哪一个点,哪一个点高亮。有同学...
    继续阅读 »

    前言


    亲爱的coder们,我又来了,一个喜欢图形的程序员👩‍💻,前几篇文章一直都在教大家怎么画地图、画折线图、画烟花🎆,难道图形就是这样嘛,当然不是,一个很简单的问题, 如果我在canvas中画了10万个点,鼠标在画布上移动,靠近哪一个点,哪一个点高亮。有同学就说遇事不决 用for循环遍历哇,我也知道可以用循环解决哇,循环解决几百个点可以,如果是几万甚至几百万个点你还循环,你想让用户等死?这时就引入今天的主角他来了就是Rbush


    RBUSH


    我们先看下定义,这个rbush到底能帮我们解决了什么问题?



    RBush是一个high-performanceJavaScript库,用于点和矩形的二维空间索引。它基于优化的R-tree数据结构,支持大容量插入。空间索引是一种用于点和矩形的特殊数据结构,允许您非常高效地执行“此边界框中的所有项目”之类的查询(例如,比在所有项目上循环快数百倍)。它最常用于地图和数据可视化。



    看定义他是基于优化的R-tree数据结构,那么R-tree又是什么呢?



    R-trees是用于空间访问方法的树数据结构,即用于索引多维信息,例如地理坐标矩形多边形。R-tree 在现实世界中的一个常见用途可能是存储空间对象,例如餐厅位置或构成典型地图的多边形:街道、建筑物、湖泊轮廓、海岸线等,然后快速找到查询的答案例如“查找我当前位置 2 公里范围内的所有博物馆”、“检索我所在位置 2 公里范围内的所有路段”(以在导航系统中显示它们)或“查找最近的加油站”(尽管不将道路进入帐户)。



    R-tree的关键思想是将附近的对象分组,并在树的下一个更高级别中用它们的最小边界矩形表示它们;R-tree 中的“R”代表矩形。由于所有对象都位于此边界矩形内,因此不与边界矩形相交的查询也不能与任何包含的对象相交。在叶级,每个矩形描述一个对象;在更高级别,聚合包括越来越多的对象。这也可以看作是对数据集的越来越粗略的近似。说着有点抽象,还是看一张图:


    R-tree


    我来详细解释下这张图:



    1. 首先我们假设所有数据都是二维空间下的点,我们从图中这个R8区域说起,也就是那个shape of data object。别把那一块不规则图形看成一个数据,我们把它看作是多个数据围成的一个区域。为了实现R树结构,我们用一个最小边界矩形恰好框住这个不规则区域,这样,我们就构造出了一个区域:R8。R8的特点很明显,就是正正好好框住所有在此区域中的数据。其他实线包围住的区域,如R9,R10,R12等都是同样的道理。这样一来,我们一共得到了12个最最基本的最小矩形。这些矩形都将被存储在子结点中。

    2. 下一步操作就是进行高一层次的处理。我们发现R8,R9,R10三个矩形距离最为靠近,因此就可以用一个更大的矩形R3恰好框住这3个矩形。

    3. 同样道理,R15,R16被R6恰好框住,R11,R12被R4恰好框住,等等。所有最基本的最小边界矩形被框入更大的矩形中之后,再次迭代,用更大的框去框住这些矩形。


    算法


    插入


    为了插入一个对象,树从根节点递归遍历。在每一步,检查当前目录节点中的所有矩形,并使用启发式方法选择候选者,例如选择需要最少放大的矩形。搜索然后下降到这个页面,直到到达叶节点。如果叶节点已满,则必须在插入之前对其进行拆分。同样,由于穷举搜索成本太高,因此采用启发式方法将节点一分为二。将新创建的节点添加到上一层,这一层可以再次溢出,并且这些溢出可以向上传播到根节点;当这个节点也溢出时,会创建一个新的根节点并且树的高度增加。


    搜索


    范围搜索中,输入是一个搜索矩形(查询框)。搜索从树的根节点开始。每个内部节点包含一组矩形和指向相应子节点的指针,每个叶节点包含空间对象的矩形(指向某个空间对象的指针可以在那里)。对于节点中的每个矩形,必须确定它是否与搜索矩形重叠。如果是,则还必须搜索相应的子节点。以递归方式进行搜索,直到遍历所有重叠节点。当到达叶节点时,将针对搜索矩形测试包含的边界框(矩形),如果它们位于搜索矩形内,则将它们的对象(如果有)放入结果集中。


    读着就复杂,但是社区里肯定有大佬替我们封装好了,就不用自己再去手写了,写了写估计不一定对哈哈哈。


    RBUSH 用法


    用法


    // as a ES module
    import RBush from 'rbush';

    // as a CommonJS module
    const RBush = require('rbush');

    创建一个树🌲


    const tree = new RBush(16);

    后面的16 是一个可选项,RBush 的一个可选参数定义了树节点中的最大条目数。 9(默认使用)是大多数应用程序的合理选择。 更高的值意味着更快的插入和更慢的搜索,反之亦然


    插入数据📚


    const item = {
       minX: 20,
       minY: 40,
       maxX: 30,
       maxY: 50,
       foo: 'bar'
    };
    tree.insert(item);

    删除数据📚


    tree.remove(item);

    默认情况下,RBush按引用移除对象。但是,您可以传递一个自定义的equals函数,以便按删除值进行比较,当您只有需要删除的对象的副本时(例如,从服务器加载),这很有用:


    tree.remove(itemCopy, (a, b) => {
       return a.id === b.id;
    });

    删除所有数据


    tree.clear();

    搜索🔍


    const result = tree.search({
       minX: 40,
       minY: 20,
       maxX: 80,
       maxY: 70
    });

    api 介绍完毕下面👇开始进入实战环节一个简单的小案例——canvas中画布搜索🔍的。


    用图片填充画布


    填充画布的的过程中,这里和大家介绍一个canvas点的api ——createPattern



    CanvasRenderingContext2D .createPattern()是 Canvas 2D API 使用指定的图像 (CanvasImageSource)创建模式的方法。 它通过repetition参数在指定的方向上重复元图像。此方法返回一个CanvasPattern对象。



    第一个参数是填充画布的数据源可以是下面这:



    第二个参数指定如何重复图像。允许的值有:



    如果为空字符串 ('') 或 null (但不是 undefined),repetition将被当作"repeat"。


    代码如下:


     class search { 
    constructor() {
    this.canvas = document.getElementById('map')
    this.ctx = this.canvas.getContext('2d')
    this.tree = new RBush()
    this.fillCanvas()
    }

    fillCanvas() {
    const img = new Image()
    img.src ='https://ztifly.oss-cn-hangzhou.aliyuncs.com/%E6%B2%B9%E7%94%BB.jpeg'
    img.onload = () => {
    const pattern = this.ctx.createPattern(img, '')
    this.ctx.fillStyle = pattern
    this.ctx.fillRect(0, 0, 960, 600)
    }
    }
    }

    这边有个小提醒的就是图片加载成功的回调里面去给画布创建模式,然后就是this 指向问题, 最后就是填充画布。


    如图:


    image-20210722220842530


    数据的生成


    数据生成主要在画布的宽度 和长度的范围内随机生成10万个矩形。插入到rbush数据的格式就是有minX、maxX、minY、maxY。这个实现的思路也是非常的简单哇, minX用画布的长度Math.random minY 就是画布的高度Math.random. 然后最大再此基础上随机*20 就OK了,一个矩形就形成了。这个实现的原理就是左上和右下两个点可以形成一个矩形。代码如下:


    randomRect() {
     const rect = {}
     rect.minX = parseInt(Math.random() * 960)
     rect.maxX = rect.minX + parseInt(Math.random() * 20)
     rect.minY = parseInt(Math.random() * 600)
     rect.maxY = rect.minY + parseInt(Math.random() * 20)
     rect.name = 'rect' + this.id
     this.id += 1
     return rect
    }

    然后循环加入10万条数据:


    loadItems(n = 100000) {
    let items = []
    for (let i = 0; i < n; i++) {
      items.push(this.randomRect())
    }
    this.tree.load(items)
    }

    画布填充


    这里我创建一个和当前画布一抹一样的canvas,但是里面画了n个矩形,将这个画布 当做图片填充到原先的画布中。


    memCanva() {
     this.memCanv = document.createElement('canvas')
     this.memCanv.height = 600
     this.memCanv.width = 960
     this.memCtx = this.memCanv.getContext('2d')
     this.memCtx.strokeStyle = 'rgba(255,255,255,0.7)'
    }

    loadItems(n = 10000) {
     let items = []
     for (let i = 0; i < n; i++) {
       const item = this.randomRect()
       items.push(item)
       this.memCtx.rect(
         item.minX,
         item.minY,
         item.maxX - item.minX,
         item.maxY - item.minY
      )
    }
     this.memCtx.stroke()
     this.tree.load(items)
    }

    然后在加载数据的时候,在当前画布画了10000个矩形。这时候新建的画布有东西了,然后我们用一个drawImage api ,


    这个api做了这样的一个事,就是将画布用特定资源填充,然后你可以改变位置,后面有参数可以修改,这里我就不多介绍了, 传送门


    this.ctx.drawImage(this.memCanv, 0, 0)

    我们看下效果:
    画布填充效果


    添加交互


    添加交互, 就是对画布添加mouseMove 事件, 然后呢我们以鼠标的位置,形成一个搜索的数据,然后我在统计花费的时间,然后你就会发现,这个Rbush 是真的快。代码如下:


     this.canvas.addEventListener('mousemove', this.handler.bind(this))
    // mouseMove 事件
    handler(e) {
       this.clearRect()
       const x = e.offsetX
       const y = e.offsetY
       this.bbox.minX = x - 20
       this.bbox.maxX = x + 20
       this.bbox.minY = y - 20
       this.bbox.maxY = y + 20
       const start = performance.now()
       const res = this.tree.search(this.bbox)
       this.ctx.fillStyle = this.pattern
       this.ctx.strokeStyle = 'rgba(255,255,255,0.7)'
       res.forEach((item) => {
         this.drawRect(item)
      })
       this.ctx.fill()
       this.res.innerHTML =
         'Search Time (ms): ' + (performance.now() - start).toFixed(3)
    }

    这里给大家讲解一下,现在我们画布是黑白的, 然后以鼠标搜索到数据后,然后我们画出对应的矩形,这时候呢,可以将矩形的填充模式改成 pattern 模式,这样便于我们看的更加明显。fillStyle可以填充3种类型:


    ctx.fillStyle = color;
    ctx.fillStyle = gradient;
    ctx.fillStyle = pattern;

    分别代表的是:


    填充的模式


    OK讲解完毕, 直接gif 看在1万个矩形的搜索中Rbush的表现怎么样。
    rbush 演示
    这是1万个矩形我换成10万个矩形我们在看看效果:


    10万个点


    我们发现增加到10万个矩形,速度还是非常快的,也就是1点几毫秒,增加到100万个矩形,canvas 已经有点画不出来了,整个页面已经卡顿了,这边涉及到canvas的性能问题,当图形的数量过多,或者数量过大的时候,fps会大幅度下降的。可以采用批量绘制的方法,还有一种优化手段是分层渲染


    我引用一下官方的Rbush的性能图,供大家参考。


    image.png


    总结


    最后总结下:rbush 是一种空间索引搜索🔍算法,当你涉及到空间几何搜索的时候,尤其在地图场景下,因为Rbush 实现的原理是比较搜索物体的boundingBox 和已知的boundingBox 求交集, 如果不相交,那么在树的遍历过程中就已经过滤掉了。


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

    收起阅读 »

    我们是如何封装项目里的共用弹框的

    前言 随着产品的迭代,项目里的弹框越来越多,业务模块共用的弹框也比较多。在刚开始的阶段,有可能不是共用的业务弹框,我们只放到了当前的业务模块里。随着迭代升级,有些模块会成为通用弹框。简而言之,一个弹框会在多个页面中使用。举例说下我们的场景。 项目当中有这样一个...
    继续阅读 »

    前言


    随着产品的迭代,项目里的弹框越来越多,业务模块共用的弹框也比较多。在刚开始的阶段,有可能不是共用的业务弹框,我们只放到了当前的业务模块里。随着迭代升级,有些模块会成为通用弹框。简而言之,一个弹框会在多个页面中使用。举例说下我们的场景。


    项目当中有这样一个预览的弹框,已经存放在我们的业务组件当中。内容如下


    import React from 'react';
    import {Modal} from 'antd';

    const Preview = (props) => {
    const {visible, ...otherProps} = props;
    return(
    <Modal
    visible={visible}
    {...otherProps}
    ... // 其它Props
    >
    <div>预览组件的内容</div>
    </Modal>
    )
    }

    这样的一个组件我们在多个业务模块当中使用,下面我们通过不同的方式来处理这种情况。


    各模块引入组件


    组件是共用的,我们可以在各业务模块去使用。


    在模块A中使用


    import React, {useState} from 'react';
    import Preview from './components/preview';
    import {Button} from 'antd';

    const A = () => {
    const [previewState, setPreviewState] = useState({
    visible: false,
    ... // 其它props,包括弹框的props和预览需要的参数等
    });

    // 显示弹框
    const showPreview = () => {
    setPreviewState({
    ...previewState,
    visible: true,
    })
    }

    // 关闭弹框
    const hidePreview = () => {
    setPreviewState({
    ...previewState,
    visible: false,
    })
    }

    return (<div>
    <Button onClick={showPreview}>预览</Button>
    <Preview {...previewState} onCancel={hidePreview} />
    </div>)
    }

    export default A;

    在模块B中使用


    import React, {useState} from 'react';
    import Preview from './components/preview';
    import {Button} from 'antd';

    const B = () => {
    const [previewState, setPreviewState] = useState({
    visible: false,
    ... // 其它props,包括弹框的props和预览需要的参数等
    });

    // 显示弹框
    const showPreview = () => {
    setPreviewState({
    ...previewState,
    visible: true,
    })
    }

    // 关闭弹框
    const hidePreview = () => {
    setPreviewState({
    ...previewState,
    visible: false,
    })
    }

    return (<div>
    B模块的业务逻辑
    <Button onClick={showPreview}>预览</Button>
    <Preview {...previewState} onCancel={hidePreview} />
    </div>)
    }

    export default B;

    我们发现打开弹框和关闭弹框等这些代码基本都是一样的。如果我们的系统中有三四十个地方需要引入预览组件,那维护起来简直会要了老命,每次有调整,需要改动的地方太多了。


    放到Redux中,全局管理。


    通过上面我们可以看到显示很关闭的业务逻辑是重复的,我们把它放到redux中统一去管理。先改造下Preview组件


    import React from 'react';
    import {Modal} from 'antd';

    @connect(({ preview }) => ({
    ...preview,
    }))
    const Preview = (props) => {
    const {visible} = props;

    const handleCancel = () => {
    porps.dispatch({
    type: 'preview/close'
    })
    }

    return(
    <Modal
    visible={visible}
    onCancel={handleCancel}
    ... // 其它Props
    >
    <div>预览组件的内容</div>
    </Modal>
    )
    }

    在redux中添加state管理我们的状态和处理一些参数


    const initState = {
    visible: false,
    };

    export default {
    namespace: 'preview',
    state: initState,
    reducers: {
    open(state, { payload }) {
    return {
    ...state,
    visible: true,
    };
    },
    close(state) {
    return {
    ...state,
    visible: false,
    };
    },
    },

    };


    全局引入


    我们想要在模块中通过dispatch去打开我们弹框,需要在加载这些模块之前就导入我们组件。我们在Layout中导入组件


    import Preview from './components/preview';
    const B = () => {

    return (<div>
    <Header>顶部导航</Header>
    <React.Fragment>
    // 存放我们全局弹框的地方
    <Preview />
    </React.Fragment>
    </div>)
    }

    export default B;

    在模块A中使用


    import React, {useState} from 'react';
    import Preview from './components/preview';
    import {Button} from 'antd';

    @connect()
    const A = (porps) => {
    // 显示弹框
    const showPreview = () => {
    porps.dispatch({
    type: 'preview/show'
    payload: { ... 预览需要的参数}
    })
    }
    return (<div>
    <Button onClick={showPreview}>预览</Button>
    </div>)
    }

    export default A;

    在模块B中使用


    import React, {useState} from 'react';
    import Preview from './components/preview';
    import {Button} from 'antd';

    @connect()
    const B = () => {
    // 显示弹框
    const showPreview = () => {
    this.porps.dispatch({
    type: 'preview/show'
    payload: { ... 预览需要的参数}
    })
    }
    return (<div>
    <Button onClick={showPreview}>预览</Button>
    </div>)
    }

    export default B;

    放到redux中去管理状态,先把弹框组件注入到我们全局当中,我们在业务调用的时候只需通过dispatch就可以操作我们的弹框。


    基于插件注入到业务当中


    把状态放到redux当中,我们每次都要实现redux那一套流程和在layout组件中注入我们的弹框。我们能不能不关心这些事情,直接在业务当中使用呢。


    创建一个弹框的工具类


    class ModalViewUtils {

    // 构造函数接收一个组件
    constructor(Component) {
    this.div = document.createElement('div');
    this.modalRef = React.createRef();
    this.Component = Component;
    }

    onCancel = () => {
    this.close();
    }

    show = ({
    title,
    ...otherProps
    }: any) => {
    const CurrComponent = this.Component;
    document.body.appendChild(this.div);
    ReactDOM.render(<GlobalRender>
    <Modal
    onCancel={this.onCancel}
    visible
    footer={null}
    fullScreen
    title={title || '预览'}
    destroyOnClose
    getContainer={false}
    >
    <CurrComponent {...otherProps} />
    </ZetModal>
    </GlobalRender>, this.div)

    }

    close = () => {
    const unmountResult = ReactDOM.unmountComponentAtNode(this.div);
    if (unmountResult && this.div.parentNode) {
    this.div.parentNode.removeChild(this.div);
    }
    }

    }

    export default ModalViewUtils;

    更改Preview组件


    import React, { FC, useState } from 'react';
    import * as ReactDOM from 'react-dom';
    import ModalViewUtils from '../../utils/modalView';

    export interface IModalViewProps extends IViewProps {
    title?: string;
    onCancel?: () => void;
    }

    // view 组件的具体逻辑
    const ModalView: FC<IModalViewProps> = props => {
    const { title, onCancel, ...otherProps } = props;
    return <View isModal {...otherProps} />
    }

    // 实例化工具类,传入对用的组件
    export default new ModalViewUtils(ModalView);


    在模块A中使用


    import React, {useState} from 'react';
    import Preview from './components/preview';
    import {Button} from 'antd';

    const A = (porps) => {
    // 显示弹框
    const showPreview = (params) => {
    Preview.show()
    }
    return (<div>
    <Button onClick={showPreview}>预览</Button>
    </div>)
    }

    export default A;

    在模块B中使用


    import React, {useState} from 'react';
    import Preview from './components/preview';
    import {Button} from 'antd';

    const B = () => {
    // 显示弹框
    const showPreview = () => {
    Preview.show(params)
    }
    return (<div>
    <Button onClick={showPreview}>预览</Button>
    </div>)
    }

    export default B;


    基于这种方式,我们只用关心弹框内容的实现,调用的时候直接引入组件,调用show方法, 不会依赖redux,也不用再调用的地方实例组件,并控制显示隐藏等。


    基于Umi插件,不需引入模块组件


    我们可以借助umi的插件,把全局弹框统一注入到插件当中, 直接使用。


    import React, {useState} from 'react';
    import {ModalView} from 'umi';
    import {Button} from 'antd';

    const A = () => {
    // 显示弹框
    const showPreview = () => {
    ModalView.Preview.show(params)
    }
    return (<div>
    <Button onClick={showPreview}>预览</Button>
    </div>)
    }

    export default A

    结束语


    对全局弹框做的统一处理,大家有问题,评论一起交流。


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

    收起阅读 »

    一个优秀前端的工具素养

    👆 这句话,想然大家道理都懂 ~ 但最近在暑期实习的日子里,我特意留心观察了一下身边的实习生同学使用工具的习惯。我发现自己在大学认为高效率的工作模式,他们无论在意识层面还是在使用层面上对工具的掌握都有些蹩脚。特别是有部分同学 Mac 也没有怎么接触过,算是效率...
    继续阅读 »

    👆 这句话,想然大家道理都懂 ~


    但最近在暑期实习的日子里,我特意留心观察了一下身边的实习生同学使用工具的习惯。我发现自己在大学认为高效率的工作模式,他们无论在意识层面还是在使用层面上对工具的掌握都有些蹩脚。特别是有部分同学 Mac 也没有怎么接触过,算是效率领域的门外汉了。所以本着做个负责的好师兄的态度,我将自己对工具使用的经验,分享给大家。也算是抛砖引玉,和大家一起聊聊有哪些 NB 又和好玩的工具。



    需要注意的是:我这里主要以 Mac Apple 生态作为基调,但我相信工具和效率提升的思想是不变的,Win 下也有具体的工具可以替代,所以 Win 的同学也可以认真找一找,评论回复一下 Win 下的替代方案吧 🙏🏻



    当然,👇 的工具,我没有办法在这种汇总类的文章里面讲透彻,所以都「点到为止」,给了相关扩展阅读的文章,所以感兴趣的话大家再外链出去研究一番,或者自行 Google 社区的优质资源 ~


    所以准备好了么?Here we go ~


    image.png


    🛠 前端工作中的那些工具


    在开始聊前端的主流工具之前,先强调一下作为的 Coder,熟练,及其熟练,飞一般熟练快捷键的重要性!


    成为快捷键爱好者


    使用工具软件的时候,我都是下意识地要求自己记住至少 Top 20 操作的「快捷键」。虽然不至于要求一定要成为 vim 编辑者这种级别的「纯金键盘侠」,但至少对 VSCode 主流快捷键要形成「肌肉记忆」。这就问大家一个问题,如果能够回答上,说明大家对 VSCode 快捷键掌握还是不错的 ~ 来:


    问:VSCode 中 RenameSymbol 的快捷键是什么?(P.S. 若 Rename Symbol 都不知道是什么作用的话,去打板子吧 😄)


    image.png


    如果回答不上,那请加油了,相信我,快捷键每次操作都可以节省你至少 1s 的时间,想一想,有多划算吧 ~
    当然在这里给大家推荐一个查询 Mac 下面应用对「快捷键」注册的工具 —— CheatSheet,长按 Command 键可以激活当前使用 App 的快捷键菜单。like this 👇


    image.png


    捷键没有速成之法,还是在不断重复练习,所以 KEEP ON DOING THAT


    成为 VSCode Professional


    工具,也有时髦之说,自从 Typescript 开始泛滥之后,VSCode 确乎成为了主流的前端开发工具。但我发很多同学对 VSCode 的使用上还是处于一种入门水准,没有真正发挥好这个工具的强大之处 ~ 所以也在和大家一起聊一聊。我不打算写一篇 Bible 级别的 VSCode 指南,只是通过几个小 Case 告诉大家 VSCode 有很多有趣的点可以使用以极大程度上提升效率,尤其是 VSCode Extensions(插件)。



    1. 你知道 VSCode 是可以云同步配置的功能,且可以一键同步其它设备么?

    2. 你知道 VSCode 有一个可以自动给 Typescript 的 import 排序并自动清除无效依赖的插件么?

    3. 你知道 VSCode 可以使用快捷键自动折叠代码层数么?

    4. 你知道如何快速返回到上一个编辑或者浏览过的文件吗?


    如果都知道,那还不错 👍,如果听都没听说过,那我给大家几个建议:



    • 把 VSCode 的快捷键列表看一遍,摘出来自己觉得可以将来提升自己编码效率的,反复执行,直到形成肌肉记忆。

    • 把 VSCode 安装量和受欢迎度 Top200 的插件,浏览一遍,看看简介,安装自己感兴趣的插件。 👈 来一场探索宝藏的游戏吧,少读一些推荐文章,多动手自己捣鼓,找到好工具!




    • 最后把 VSCode 上一个绝美的皮肤和字体,按照我的审美,这就是我要的「滑板鞋」 ~ btw,主题是 OneDarkPro 字体是:FiraCode





    扩展阅读:



    用好 Terminal


    作为一个工程师,不要求你成为 Shell 大师,但 Terminal 里面的常用命令以及日常美化优化还是必须要做的。这里给大家推荐 iTerm + OhMyZsh 的模式,打造一个稳定好用的 Terminal。



    • 下载 iTerm 安装(你用 VSCode 的也行,但我还是推荐独立终端 App,因为 VSCode 这货有时候会假死,然后把 iTerm 一下整没了,所以还是术业有专攻才行 🙈),有了这货,分屏幕上 👇 就是常规操作了。




    • 下载 OhMyZsh 安装,更新最新的 Git 目录,把主流插件都 down 下来,装好后秒变彩色,再安装对应的主题,不要太开心。




    • 按照个人兴趣「调教」OhMyZsh,强烈建议在 ~/.zshrc 启动这些插件:谁用谁知道 ~ 😄 随便说一个功能都好用到不行,这里就不啰嗦了,有其它好用插件的同学,欢迎盖楼分享一下。




    plugins=(git osx wd autojump zsh-autosuggestions copyfile history last-working-dir)


    比如:Git 这个插件就可以将复杂的 git 命令 git checkout -b 'xxx' 简化为:gcb 'xxx'


    比如:OSX 插件可以帮我们快速打开 Finder 等等操作。


    ...




    扩展阅读:




    • Shell 编程入门:手撸脚本,提升效率 ✍🏻




    • OhMyZsh 插件集:看那些花里胡哨的 shell 插件们,来,拉出来都晒一晒 🌞




    • Vim 快捷键 CheatSheet:在手撸服务器时代,Vim 是神器,现在看来依旧值得传火 🧎‍♂️ 大神收下我的膝盖




    用好 Chrome DebugTool


    作为一个前端我就不赘述这个的重要性了。强烈建议大家把官方文档撸一遍,你会有很多新知的。


    developer.chrome.com/docs/devtoo…


    👆 这个可以写一本书,但是我还是建议大家用好这个工具,这是我们前端最重要的调试器了,我经常在面试同学的时候会问关于他们如何使用调试器解决问题的。其实看大家调试代码的过程就知道这个同学的编程水准,真的,大家可以有意识的看看自己是怎么去调试和排查问题的,效率高么?有没有提升空间。



    • 比如:如何排查一个项目的渲染卡顿点?

    • 比如:如何排查内存泄露?

    • 比如:如何全局搜索查找重复的代码?


    用好 ChromeExtensions


    浏览器插件,我就不多说了。我在此罗列一下我日常使用的 Chrome 插件,也欢迎各路神仙补充自己的浏览器插件和那些骚操作。重点说一下 For 开发者的:





    • JSONFormatter:对于日常直接请求的 JSON 数据格式化




    • XSwitch:我前 TL 手撸的浏览器网络请求代理工具,帮他打个广告 😛




    • ReactDeveloerTools 👈 这个就不多解释了,强烈建议大家打开 HighlightRerender 功能,看看自己的代码写得多烂,多多批判一下自己 🙈




    对于 Chrome Extension 这种「神文」比较多,像油猴、AdBlock、视频下载啥的之类的工具我就不在这里提了,懂的人都懂,不懂的人自己 Google。我这里再推荐几篇文章,大家按需阅读吧:



    • Chrome 前端插件推荐:B 乎上这个 问题和 回答 比较中肯

    • Chrome 通用插件推荐:B 乎继续 推荐,看看高赞回答下的「集体智慧」吧 😁


    🔍 搜索!搜索!!搜索!!!


    呼,终于聊完了开发用的工具,那接下来我们来聊一下搜索。按照我的理解,我一直把数字化时代个人信息管理的效率分成三个基础段位:



    • 入门级:很少整理自己的磁盘和桌面,典型特征就是桌面什么奇葩的命名文件都堆在一起

    • 新手级:开始有意识整理了,文件分级分层,重视文件命名,建立标签等机制

    • 熟练级:开始有意识建立数据库索引,在 OS 层面做文件索引,有数据意识

    • 大师级:开始关注数据,将个人数据,集体数据融入日常,甚至开始使用非结构化的数据来辅助自己做事情


    扪心自问,你在哪一个 Level 呢?


    Spotlight


    第一第二级,我就不了了,这里我重点和大家分享一下达到第三级的索引和搜索工具。要知道在 Mac 下 Spotlight 一直都是一个全局搜索工具,用好 Spotlight,就可以无缝解锁系统级别的搜索,主要的 Apps、文件、日历 ... 都可以搜索。



    Alfred



    但系统自带的,往往都不是最强的是吧?所以在 Spotlight 系统级的 metadata (Mac 会自建文件索引库并开放 API 给上层应用调用)的基础上,诞生了一个很强的工具 Alfred。我一直是 Alfred 的资深粉丝 + 用户,每天使用 Alfred 的功能(搜索)高达 70 次。👇 图为证:



    Alfred 是一个「真正意义上的效率工具」,其主要的功能:



    • 文档检索

    • 快捷 URL 拼接

    • 剪切板历史快速访问 & 搜索

    • BookMark 搜索

    • 自定义工作流(下一个章节重点聊一聊这个)

    • ...(功能无敌)


    强烈建议不知道 Alfred 是啥的同学,读一下 👇 这篇文章,这篇文章是我在入职阿里第一年内网写的一篇介绍 Alfred 的文章,如果有收获,还请给我点个赞了 👍


    此处为语雀内容卡片,点击链接查看:http://www.yuque.com/surfacew/fe…


    🚌 自动化的魅力


    「自动化」一定是一种程序工作者应该深深植入自己「脑海里」的思考模式。但凡遇到重复的流程,我们是不是都应该尝试着问自己,这么费时间的流程,频次有多少,是否值得我们使用工具去自动化?


    如今,靠做自动化上市的公司也有了,所以这里重点想和大家一起聊一聊个人如何把身边的自动化做到极致。这里重点讲三个工具:Alfred Workflow、Apple 捷径、IFFTT。


    AlfredWorkflow


    主打 Mac 上的自动化流程。通过 👇 这种可视化编程的思路,创建一种动作流。比如我想实现通过 Cmd + Alt + B 搜索 Chrome 书签 🔖。社区的小伙伴们就已经帮我们实现了一套工作流。我们可以直接在 Alfred 的社区 Packtal 等论坛去下载已经实现的 Workflow 去实现这些日常生活中的小自动化流程。



    再比如上面的:




    • ChromeHistory:搜索 Chrome 历史记录(在 Alfred 搜索中)




    • GithubRepos:浏览搜索自己的 GithubRepo




    • Colors:快速转换前端颜色(前端同学一定知道为什么这个常用)🙈






    • ... 等等等等


    我们也可以定义自己的工作流来自动化一些流程,我用自身的一个 Case 来说,我会定义很多快捷键来绑定我自己的日常操作。比如:




    • Cmd + Alt + D:打开钉钉




    • Alfred 输入 weather:查询天气




    • Alfred 输入 calendar:打开百度日历(不为别的,看放假日历 😄)




    • codereview:进入集团 CR 的工作台




    • ...





    浑然一体,非常好玩,可以大量定制自己的工作流程。我之前写过一篇文章有关联到 Workflow 的部分,感兴趣的可以 一读


    AppleShortcuts


    主打手机上的自动化流程。(iPhone)


    它提供了近乎 0 代码的流程编排,让我们可以访问 App 以及一些操作系统的 API,从而实现类似 👆 Alfred 的功能编排,是不是也很强。比如我们想要实现一个从剪切板里面读取内容并打开网页的功能,只需要下面增加两个简单的编程动作(真 0 代码)就可以实现自定义流程的实现。



    Apple 捷径提供的 API 示意:




    可以看到的是,Apple 这些大厂一直在思考真正意义上的让编码平易近人,让普通的小白用户也可以低成本地定义自己的工作流程。Shortcuts 的玩法有很多,在这里就不细细展开了,给大家足够多探索的可能性。


    IFFTT


    🔗:ifttt.com/home


    三方中立的自动化流程提供商。这个工具跨平台多端支持,我用的相对偏少,但可以解决我部分跨平台的流程问题,这块大家自行探索吧 ~


    聪明的人,一定会用「自动化」的思维解决问题,所以用好自动化的工具的重要性我相信大家应该明白了。


    💻 突破次元壁の工具


    最后,再和大家聊一聊非软件的「工具」吧。我还是觉得大家作为 Coder,还是要在自己的装备上多花点盘缠,就像 Kevin 老师用了戴森吹风机就比普通发型师厉害一样。



    • 自己的 主力机,一定是要性能杠杠的,经济允许的情况下,前端我还是力挺 Mac(高配) 和 Apple 生态 ~

    • 给自己 一块 4K 屏(最好放公司),看着心情都会变好,如果财力雄厚,搞一块 Apple 的 PRO DISPLAY XDR,就给跪了。




    • 使用 iPad & ApplePencil 尝试着数字笔记的艺术,涂涂画画,发现灵感,整理思维。





    • 自动升降桌 & 人体工程学椅:对身体,脊椎好一点 🙂 就坐屁股变大,变胖,是不争的事实 😿




    • HHKB 键盘 ⌨️,最近用了一段时间,适应布局之后,觉得打字都变快了 ... 可能是金钱的力量让代码翘起来都更顺心了呢 🎶(开个玩笑)




    • ...




    🎓 结语


    当然,👆 的工具只是大千世界中,集体智慧凝练的工具的冰山一角。


    这些工具提升效率创造的增益往往初步看很小,但是大家一定要知道,这种增益会随着时间积累而放大,做一个简单的计算,一天你因为工具里面的 100 次效率优化,每一次即便是优化 5s,一年下来,节省的时间(Alfred 可以直接计算时间):



    是不是令人震惊地高达 50 个小时,活生生的 2 天 啊!!!受限于篇幅,如果大家觉得对这篇文章对自己有帮助的话,欢迎点赞收藏转载(怎么变成了 B 站三连了)哈哈,如果后续有时间的话,我再和大家一起分享一下我是如何做信息管理和知识管理的,希望能够给大家带来一些真正帮助。


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

    收起阅读 »

    你能不能在网页里实现裸眼3D

    前言 最近产品经理在掘金社区的出镜率很高,看来大家都很喜闻乐见工程师与产品经理的相爱相杀。 这次他让我调研一下在网页里实现裸眼3D 这是故意为难我把? 搞什么调研影响我摸鱼 现在的我想拿枪打他 拿弓箭射他 点火烧他 诶,如果我在3D场景中刻意加上一些框框...
    继续阅读 »

    前言


    最近产品经理在掘金社区的出镜率很高,看来大家都很喜闻乐见工程师与产品经理的相爱相杀。


    这次他让我调研一下在网页里实现裸眼3D


    这是故意为难我把?


    搞什么调研影响我摸鱼


    现在的我想拿枪打他


    619c-hawmaua2753951.gif


    拿弓箭射他


    26a78036e0304df84daf3c634f264c0d.gif


    点火烧他


    c685-hawmaua2754245.gif


    诶,如果我在3D场景中刻意加上一些框框,会不会看上去更立体呢?


    方案一:造个框框,再打破它


    现在我们用一个非常简单的立方体来试试看


    2021-07-23 13_50_55.gif


    2021-07-23 13_53_07.gif


    立体感是稍微提升一点,但就这?那怕是交不了差的...


    不过,大家发挥一下想象力,框框可以不全是直的,这个B站防遮挡弹幕是不是也让你产生了些裸眼3D的效果呢?


    image.png


    方案二:人脸识别


    不行,谁都不能耽误我摸鱼。


    此时我又想起另一个方案,是不是可以通过摄像头实时检测人脸在摄像头画面中的位置来模拟裸眼3D呢。我找到了tracking.js,这是一款在浏览器中可以实时进行人脸检测的库。


    github.com/eduardolund…


    var tracker = new tracking.ObjectTracker('face');
    tracker.setInitialScale(4);
    tracker.setStepSize(2);
    tracker.setEdgesDensity(0.1);

    tracking.track('#video', tracker, { camera: true });

    tracker.on('track', function(event) {
    context.clearRect(0, 0, canvas.width, canvas.height);

    event.data.forEach(function(rect) {
    context.strokeStyle = '#a64ceb';
    context.strokeRect(rect.x, rect.y, rect.width, rect.height);
    context.font = '11px Helvetica';
    context.fillStyle = "#fff";
    context.fillText('x: ' + rect.x + 'px', rect.x + rect.width + 5, rect.y + 11);
    context.fillText('y: ' + rect.y + 'px', rect.x + rect.width + 5, rect.y + 22);
    });
    });

    2021-07-23 14_45_40.gif


    我们可以看到,画面中呈现了人脸在摄像头视角画布中的坐标,有了这个坐标数据,我们就可以做很多事情了。


    接着把它接到threejs中,我们仍然拿这个立方体来试试看


    2021-07-23 15_11_29.gif


    实际体验还有点意思,但录屏的感受不太明显,请自行下载demo源码试试看吧


    方案三:陀螺仪


    W3C标准APIDeviceOrientation,用于检测移动设备的旋转方向和加速度。通过这个API,我们可以获取到三个基础属性:



    • alpha(设备平放时,水平旋转的角度)


    image.png



    • beta(设备平放时,绕横向X轴旋转的角度)


    image.png



    • gamma(设备平放时,绕纵向Y轴旋转的角度)


    image.png


    这个API的使用非常简单,通过给window添加一个监听


    function capture_orientation (event) { 
    var alpha = event.alpha;
    var beta = event.beta;
    var gamma = event.gamma;
    console.log('Orientation - Alpha: '+alpha+', Beta: '+beta+', Gamma: '+gamma);
    }

    window.addEventListener('deviceorientation', capture_orientation, false);

    现在我们把这个加入到咱们的立方体演示中,在加入的过程中,这里需要注意的是,在IOS设备上,这个API需要主动申请用户权限。


    window.DeviceOrientationEvent.requestPermission()
    .then(state => {
    switch (state) {
    case "granted":
    //在这里建立监听
    window.addEventListener('deviceorientation', capture_orientation, false);
    break;
    case "denied":
    alert("你拒绝了使用陀螺仪");
    break;
    case "prompt":
    alert("其他行为");
    break;
    }
    });

    返回的是一个promise,所以你也可以这么写


    var permissionState = await window.DeviceOrientationEvent.requestPermission();
    if(permissionState=="granted")window.addEventListener('deviceorientation', capture_orientation, false);

    还有几点需要注意的事,requestPermission必须由用户主动发起,也就是必须在用户的行为事件里触发,比如“click”,还有就是这个API的调用,必须在HTTPS协议访问的网页里使用。


    2021-07-25 10_46_16.gif


    结语


    至此,我能想到在网页里实现裸眼3D的几种方法都在此文中,你还能想到别的方法吗?请在评论区一起讨论吧。



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

    收起阅读 »

    感谢 compose 函数,让我的代码屎山?逐渐美丽了起来~

    有言在先 本瓜知道前不久写的《JS 如何函数式编程》系列各位可能并不感冒,因为一切理论的东西如果脱离实战的话,那就将毫无意义。 于是乎,本瓜着手于实际工作开发,尝试应用函数式编程的一些思想。 最终惊人的发现:这个实现过程并不难,但是效果却不小! 实现思路:借...
    继续阅读 »

    有言在先


    本瓜知道前不久写的《JS 如何函数式编程》系列各位可能并不感冒,因为一切理论的东西如果脱离实战的话,那就将毫无意义。


    I6cDpC.th.png


    于是乎,本瓜着手于实际工作开发,尝试应用函数式编程的一些思想。


    最终惊人的发现:这个实现过程并不难,但是效果却不小!


    实现思路:借助 compose 函数对连续的异步过程进行组装,不同的组合方式实现不同的业务流程。


    这样不仅提高了代码的可读性,还提高了代码的扩展性。我想:这也许就是高内聚、低耦合吧~


    撰此篇记之,并与各位分享。


    场景说明


    在和产品第一次沟通了需求后,我理解需要实现一个应用 新建流程,具体是这样的:


    第 1 步:调用 sso 接口,拿到返回结果 res_token;


    第 2 步:调用 create 接口,拿到返回结果 res_id;


    第 3 步:处理字符串,拼接 Url;


    第 4 步:建立 websocket 链接;


    第 5 步:拿到 websocket 后端推送关键字,渲染页面;



    • 注:接口、参数有做一定简化


    上面除了第 3 步、第 5 步,剩下的都是要调接口的,并且前后步骤都有传参的需要,可以理解为一个连续且有序的异步调用过程。


    为了快速响应产品需求,于是本瓜迅速写出了以下代码:


    /**
    * 新建流程
    * @param {*} appId
    * @param {*} tag
    */

    export const handleGetIframeSrc = function(appId, tag) {
    let h5Id
    // 第 1 步: 调用 sso 接口,获取token
    getsingleSignOnToken({ formSource: tag }).then(data => {
    return new Promise((resolve, reject) => {
    resolve(data.result)
    })
    }).then(token => {
    const para = { appId: appId }
    return new Promise((resolve, reject) => {
    // 第 2 步: 调用 create 接口,新建应用
    appH5create(para).then(res => {
    // 第 3 步: 处理字符串,拼接 Url
    this.handleInsIframeUrl(res, token, appId)
    this.setH5Id(res.result.h5Id)
    h5Id = res.result.h5Id
    resolve(h5Id)
    }).catch(err => {
    this.$message({
    message: err.message || '出现错误',
    type: 'error'
    })
    })
    })
    }).then(h5Id => {
    // 第 4 步:建立 websocket 链接;
    return new Promise((resolve, reject) => {
    webSocketInit(resolve, reject, h5Id)
    })
    }).then(doclose => {
    // 第 5 步:拿到 websocket 后端推送关键字,渲染页面;
    if (doclose) { this.setShowEditLink({ appId: appId, h5Id: h5Id, state: true }) }
    }).catch(err => {
    this.$message({
    message: err.message || '出现错误',
    type: 'error'
    })
    })
    }

    const handleInsIframeUrl = function(res, token, appId) {
    // url 拼接
    const secretId = this.$store.state.userinfo.enterpriseList[0].secretId
    let editUrl = res.result.editUrl
    const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
    editUrl = res.result.editUrl.replace(infoId, `from=a2p&${infoId}`)
    const headList = JSON.parse(JSON.stringify(this.headList))
    headList.forEach(i => {
    if (i.appId === appId) { i.srcUrl = `${editUrl}&token=${token}&secretId=${secretId}` }
    })
    this.setHeadList(headList)
    }

    这段代码是非常自然地根据产品所提需求,然后自己理解所编写。


    其实还可以,是吧?🐶


    需求更新


    但你不得不承认,程序员和产品之间有一条无法逾越的沟通鸿沟


    它大部分是由所站角度不同而产生,只能说:李姐李姐!


    所以,基于前一个场景,需求发生了点 更新 ~


    I6UGrz.th.png


    除了上节所提的 【新建流程】 ,还要加一个 【编辑流程】 ╮(╯▽╰)╭


    编辑流程简单来说就是:砍掉新建流程的第 2 步调接口,再稍微调整传参即可。


    于是本瓜直接 copy 一下再作简单删改,不到 1 分钟,编辑流程的代码就诞生了~


    /**
    * 编辑流程
    */

    const handleToIframeEdit = function() { // 编辑 iframe
    const { editUrl, appId, h5Id } = this.ruleForm
    // 第 1 步: 调用 sso 接口,获取token
    getsingleSignOnToken({ formSource: 'ins' }).then(data => {
    return new Promise((resolve, reject) => {
    resolve(data.result)
    })
    }).then(token => {
    // 第 2 步:处理字符串,拼接 Url
    return new Promise((resolve, reject) => {
    const secretId = this.$store.state.userinfo.enterpriseList[0].secretId
    const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
    const URL = editUrl.replace(infoId, `from=a2p&${infoId}`)
    const headList = JSON.parse(JSON.stringify(this.headList))
    headList.forEach(i => {
    if (i.appId === appId) { i.srcUrl = `${URL}&token=${token}&secretId=${secretId}` }
    })
    this.setHeadList(headList)
    this.setShowEditLink({ appId: appId, h5Id: h5Id, state: false })
    this.setShowNavIframe({ appId: appId, state: true })
    this.setNavLabel(this.headList.find(i => i.appId === appId).name)
    resolve(h5Id)
    })
    }).then(h5Id => {
    // 第 3 步:建立 websocket 链接;
    return new Promise((resolve, reject) => {
    webSocketInit(resolve, reject, h5Id)
    })
    }).then(doclose => {
    // 第 4 步:拿到 websocket 后端推送关键字,渲染页面;
    if (doclose) { this.setShowEditLink({ appId: appId, h5Id: h5Id, state: true }) }
    }).catch(err => {
    this.$message({
    message: err.message || '出现错误',
    type: 'error'
    })
    })
    }

    需求再更新


    老实讲,不怪产品,咱做需求的过程也是逐步理解需求的过程。理解有变化,再正常不过!(#^.^#) 李姐李姐......


    I6UIKu.th.png


    上面已有两个流程:新建流程、编辑流程


    这次,要再加一个 重新创建流程 ~


    重新创建流程可简单理解为:在新建流程之前调一个 delDraft 删除草稿接口;


    至此,我们产生了三个流程:



    1. 新建流程;

    2. 编辑流程;

    3. 重新创建流程;


    本瓜这里作个简单的脑图示意逻辑:


    I6Xi9Q.png


    我的直觉告诉我:不能再 copy 一份新建流程作修改了,因为这样就太拉了。。。没错,它没有耦合,但是它也没有内聚,这不是我想要的。于是,我开始封装了......


    实现上述脑图的代码:


    /**
    * 判断是否存在草稿记录?
    */
    judgeIfDraftExist(item) {
    const para = { appId: item.appId }
    return appH5ifDraftExist(para).then(res => {
    const { editUrl, h5Id, version } = res.result
    if (h5Id === -1) { // 不存在草稿
    this.handleGetIframeSrc(item)
    } else { // 存在草稿
    this.handleExitDraft(item, h5Id, version, editUrl)
    }
    }).catch(err => {
    console.log(err)
    })
    },
    /**
    * 选择继续编辑?
    */
    handleExitDraft(item, h5Id, version, editUrl) {
    this.$confirm('有未完成的信息收集链接,是否继续编辑?', '提示', {
    confirmButtonText: '继续编辑',
    cancelButtonText: '重新创建',
    type: 'warning'
    }).then(() => {
    const editUrlH5Id = h5Id
    this.handleGetIframeSrc(item, editUrl, editUrlH5Id)
    }).catch(() => {
    this.handleGetIframeSrc(item)
    appH5delete({ h5Id: h5Id, version: version })
    })
    },
    /**
    * 新建流程、编辑流程、重新创建流程;
    */
    handleGetIframeSrc(item, editUrl, editUrlH5Id) {
    let ws_h5Id
    getsingleSignOnToken({ formSource: item.tag }).then(data => {
    // 调用 sso 接口,拿到返回结果 res_token;
    return new Promise((resolve, reject) => {
    resolve(data.result)
    })
    }).then(token => {
    const para = { appId: item.appId }
    return new Promise((resolve, reject) => {
    if (!editUrl) { // 新建流程、重新创建流程
    // 调用 create 接口,拿到返回结果 res_id;
    appH5create(para).then(res => {
    // 处理字符串,拼接 Url;
    this.handleInsIframeUrl(res.result.editUrl, token, item.appId)
    this.setH5Id(res.result.h5Id)
    ws_h5Id = res.result.h5Id
    this.setShowNavIframe({ appId: item.appId, state: true })
    this.setNavLabel(item.name)
    resolve(true)
    }).catch(err => {
    this.$message({
    message: err.message || '出现错误',
    type: 'error'
    })
    })
    } else { // 编辑流程
    this.handleInsIframeUrl(editUrl, token, item.appId)
    this.setH5Id(editUrlH5Id)
    ws_h5Id = editUrlH5Id
    this.setShowNavIframe({ appId: item.appId, state: true })
    this.setNavLabel(item.name)
    resolve(true)
    }
    })
    }).then(() => {
    // 建立 websocket 链接;
    return new Promise((resolve, reject) => {
    webSocketInit(resolve, reject, ws_h5Id)
    })
    }).then(doclose => {
    // 拿到 websocket 后端推送关键字,渲染页面;
    if (doclose) { this.setShowEditLink({ appId: item.appId, h5Id: ws_h5Id, state: true }) }
    }).catch(err => {
    this.$message({
    message: err.message || '出现错误',
    type: 'error'
    })
    })
    },

    handleInsIframeUrl(editUrl, token, appId) {
    // url 拼接
    const secretId = this.$store.state.userinfo.enterpriseList[0].secretId
    const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
    const url = editUrl.replace(infoId, `from=a2p&${infoId}`)
    const headList = JSON.parse(JSON.stringify(this.headList))
    headList.forEach(i => {
    if (i.appId === appId) { i.srcUrl = `${url}&token=${token}&secretId=${secretId}` }
    })
    this.setHeadList(headList)
    }

    如此,我们便将 新建流程、编辑流程、重新创建流程 全部整合到了上述代码;


    需求再再更新


    上面的封装看起来似乎还不错,但是这时我害怕了!想到:如果这个时候,还要加流程或者改流程呢??? 我是打算继续用 if...else 叠加在那个主函数里面吗?还是打算直接 copy 一份再作删改?


    我都能遇见它会充斥着各种判断,变量赋值、引用飞来飞去,最终成为一坨💩,没错,代码屎山的💩


    我摸了摸左胸的左心房,它告诉我:“饶了接盘侠吧~”


    于是乎,本瓜尝试引进了之前吹那么 nb 的函数式编程!它的能力就是让代码更可读,这是我所需要的!来吧!!展示!!


    I6cPMf.png


    compose 函数


    我们在 《XDM,JS如何函数式编程?看这就够了!(三)》 这篇讲过函数组合 compose!没错,我们这次就要用到这个家伙!


    还记得那句话吗?



    组合 ———— 声明式数据流 ———— 是支撑函数式编程最重要的工具之一!



    最基础的 compose 函数是这样的:


    function compose(...fns) {
    return function composed(result){
    // 拷贝一份保存函数的数组
    var list = fns.slice();
    while (list.length > 0) {
    // 将最后一个函数从列表尾部拿出
    // 并执行它
    result = list.pop()( result );
    }
    return result;
    };
    }

    // ES6 箭头函数形式写法
    var compose =
    (...fns) =>
    result => {
    var list = fns.slice();
    while (list.length > 0) {
    // 将最后一个函数从列表尾部拿出
    // 并执行它
    result = list.pop()( result );
    }
    return result;
    };

    它能将一个函数调用的输出路由跳转到另一个函数的调用上,然后一直进行下去。


    I6c6uy.png


    我们不需关注黑盒子里面做了什么,只需关注:这个东西(函数)是什么!它需要我输入什么!它的输出又是什么!


    composePromise


    但上面提到的 compose 函数是组合同步操作,而在本篇的实战中,我们需要组合是异步函数!


    于是它被改造成这样:


    /**
    * @param {...any} args
    * @returns
    */

    export const composePromise = function(...args) {
    const init = args.pop()
    return function(...arg) {
    return args.reverse().reduce(function(sequence, func) {
    return sequence.then(function(result) {
    // eslint-disable-next-line no-useless-call
    return func.call(null, result)
    })
    }, Promise.resolve(init.apply(null, arg)))
    }
    }

    原理:Promise 可以指定一个 sequence,来规定一个执行 then 的过程,then 函数会等到执行完成后,再执行下一个 then 的处理。启动sequence 可以使用 Promise.resolve() 这个函数。构建 sequence 可以使用 reduce 。


    我们再写一个小测试在控制台跑一下!


    let compose = function(...args) {
    const init = args.pop()
    return function(...arg) {
    return args.reverse().reduce(function(sequence, func) {
    return sequence.then(function(result) {
    return func.call(null, result)
    })
    }, Promise.resolve(init.apply(null, arg)))
    }
    }

    let a = async() => {
    return new Promise((resolve, reject) => {
    setTimeout(() => {
    console.log('xhr1')
    resolve('xhr1')
    }, 5000)
    })
    }

    let b = async() => {
    return new Promise((resolve, reject) => {
    setTimeout(() => {
    console.log('xhr2')
    resolve('xhr2')
    }, 3000)
    })
    }
    let steps = [a, b] // 从右向左执行
    let composeFn = compose(...steps)

    composeFn().then(res => { console.log(666) })

    // xhr2
    // xhr1
    // 666

    它会先执行 b ,3 秒后输出 "xhr2",再执行 a,5 秒后输出 "xhr1",最后输出 666


    你也可以在控制台带参 debugger 试试,很有意思:


    composeFn(1, 2).then(res => { console.log(66) })

    逐渐美丽起来


    测试通过!借助上面 composePromise 函数,我们更加有信心用函数式编程 composePromise 重构 我们的代码了。



    • 实际上,这个过程一点不费力~


    实现如下:


    /**
    * 判断是否存在草稿记录?
    */
    handleJudgeIfDraftExist(item) {
    return appH5ifDraftExist({ appId: item.appId }).then(res => {
    const { editUrl, h5Id, version } = res.result
    h5Id === -1 ? this.compose_newAppIframe(item) : this.hasDraftConfirm(item, h5Id, editUrl, version)
    }).catch(err => {
    console.log(err)
    })
    },
    /**
    * 选择继续编辑?
    */
    hasDraftConfirm(item, h5Id, editUrl, version) {
    this.$confirm('有未完成的信息收集链接,是否继续编辑?', '提示', {
    confirmButtonText: '继续编辑',
    cancelButtonText: '重新创建',
    type: 'warning'
    }).then(() => {
    this.compose_editAppIframe(item, h5Id, editUrl)
    }).catch(() => {
    this.compose_reNewAppIframe(item, h5Id, version)
    })
    },

    敲黑板啦!画重点啦!


    /**
    * 新建应用流程
    * 入参: item
    * 输出:item
    */
    compose_newAppIframe(...args) {
    const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_appH5create, this.step_getsingleSignOnToken]
    const handleCompose = composePromise(...steps)
    handleCompose(...args)
    },
    /**
    * 编辑应用流程
    * 入参: item, draftH5Id, editUrl
    * 输出:item
    */
    compose_editAppIframe(...args) {
    const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_getsingleSignOnToken]
    const handleCompose = composePromise(...steps)
    handleCompose(...args)
    },
    /**
    * 重新创建流程
    * 入参: item,draftH5Id,version
    * 输出:item
    */
    compose_reNewAppIframe(...args) {
    const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_appH5create, this.step_getsingleSignOnToken, this.step_delDraftH5Id]
    const handleCompose = composePromise(...steps)
    handleCompose(...args)
    },

    我们通过 composePromise 执行不同的 steps,来依次执行(从右至左)里面的功能函数;你可以任意组合、增删或修改 steps 的子项,也可以任意组合出新的流程来应付产品。并且,它们都被封装在 compose_xxx 里面,相互独立,不会干扰外界其它流程。同时,传参也是非常清晰的,输入是什么!输出又是什么!一目了然!


    对照脑图再看此段代码,不正是对我们需求实现的最好诠释吗?


    对于一个阅读陌生代码的人来说,你得先告诉他逻辑是怎样的,然后再告诉他每个步骤的内部具体实现。这样才是合理的!


    I6Xi9Q.png


    功能函数(具体步骤内部实现):


    /**
    * 调用 sso 接口,拿到返回结果 res_token;
    */
    step_getsingleSignOnToken(...args) {
    const [item] = args.flat(Infinity)
    return new Promise((resolve, reject) => {
    getsingleSignOnToken({ formSource: item.tag }).then(data => {
    resolve([...args, data.result]) // data.result 即 token
    })
    })
    },
    /**
    * 调用 create 接口,拿到返回结果 res_id;
    */
    step_appH5create(...args) {
    const [item, token] = args.flat(Infinity)
    return new Promise((resolve, reject) => {
    appH5create({ appId: item.appId }).then(data => {
    resolve([item, data.result.h5Id, data.result.editUrl, token])
    }).catch(err => {
    this.$message({
    message: err.message || '出现错误',
    type: 'error'
    })
    })
    })
    },
    /**
    * 调 delDraft 删除接口;
    */
    step_delDraftH5Id(...args) {
    const [item, h5Id, version] = args.flat(Infinity)
    return new Promise((resolve, reject) => {
    appH5delete({ h5Id: h5Id, version: version }).then(data => {
    resolve(...args)
    })
    })
    },
    /**
    * 处理字符串,拼接 Url;
    */
    step_splitUrl(...args) {
    const [item, h5Id, editUrl, token] = args.flat(Infinity)
    const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
    const url = editUrl.replace(infoId, `from=a2p&${infoId}`)
    const headList = JSON.parse(JSON.stringify(this.headList))
    headList.forEach(i => {
    if (i.appId === item.appId) { i.srcUrl = `${url}&token=${token}` }
    })
    this.setHeadList(headList)
    this.setH5Id(h5Id)
    this.setShowNavIframe({ appId: item.appId, state: true })
    this.setNavLabel(item.name)
    return [...args]
    },
    /**
    * 建立 websocket 链接;
    */
    step_createWs(...args) {
    return new Promise((resolve, reject) => {
    webSocketInit(resolve, reject, ...args)
    })
    },
    /**
    * 拿到 websocket 后端推送关键字,渲染页面;
    */
    step_getDoclose(...args) {
    const [item, h5Id, editUrl, token, doclose] = args.flat(Infinity)
    if (doclose) { this.setShowEditLink({ appId: item.appId, h5Id: h5Id, state: true }) }
    return new Promise((resolve, reject) => {
    resolve(true)
    })
    },

    功能函数的输入、输出也是清晰可见的。


    至此,我们可以认为:借助 compose 函数,借助函数式编程,咱把业务需求流程进行了封装,明确了输入输出,让我们的代码更加可读了!可扩展性也更高了!这不就是高内聚、低耦合?!


    I6UWZD.th.png


    阶段总结


    你问我什么是 JS 函数式编程实战?我只能说本篇完全就是出自工作中的实战!!!


    这样导致本篇代码量可能有点多,但是这就是实打实的需求变化,代码迭代、改造的过程。(建议通篇把握、理解)


    当然,这不是终点,代码重构这个过程应该是每时每刻都在进行着。


    对于函数式编程,简单应用 compose 函数,这也只是一个起点!


    已经讲过,偏函数、函数柯里化、函数组合、数组操作、时间状态、函数式编程库等等概念......我们将再接再厉得使用它们,把代码屎山进行分类、打包、清理!让它不断美丽起来!💩 => 👩‍🦰


    以上,便是本次分享~ 都看到这里,不如点个赞吧👍👍👍


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

    收起阅读 »

    iOS swift与oc混编问题解决

    1、手动创建桥接文件2、桥接文件中导入 通过cocoapods pod下来的第三方OC文件,报找不到在target—>Build Setting里找到search Paths,双击User Header Search Paths后面的空白处,设置目录路径...
    继续阅读 »
    1、手动创建桥接文件



    2、桥接文件中导入 通过cocoapods pod下来的第三方OC文件,报找不到


    在target—>Build Setting里找到search Paths,双击User Header Search Paths后面的空白处,设置目录路径为${SRCROOT}
    ${SRCROOT}后边选择recursive递归根目录下的所有文件。


    3、OC文件中调用swift文件,需要导入头文件,这个头文件叫啥呢?

    一般为项目名称-swift.h

    当然也可查看,地方在这里



    4、Swift中 字符串转化为Class怎么做

    在Swift中由于命名空间的存在,我们可以用下面的方法进行转化。

    func getClass(stringName: String) -> Class {

    guard let nameSpage = Bundle.main.infoDictionary!["CFBundleExecutable"] as? String else {
    print("没有命名空间")
    return
    }

    guard let childVcClass = NSClassFromString(nameSpage + "." + vcName) else {
    print("没有获取到对应的class")
    return
    }

    guard let childVcType = childVcClass as? UIViewController.Type else {
    print("没有得到的类型")
    return
    }

    //根据类型创建对应的对象
    let vc = childVcType.init()

    return vc

    }
    5、修改pod文件,运行调试时缓存之前数据,如下图


    链接:https://www.jianshu.com/p/83f70b366ff4



    收起阅读 »

    一招搞定 iOS 14.2 的 libffi crash

    苹果升级 14.2,全球 iOS 遭了秧。libffi 在 iOS14.2 上发生了 crash, 我司的许多 App 深受困扰,有许多基础库都是用了 libffi。经过定位,发现是 vmremap 导致的 code sign error。我们通过使用静态 t...
    继续阅读 »

    苹果升级 14.2,全球 iOS 遭了秧。libffi 在 iOS14.2 上发生了 crash, 我司的许多 App 深受困扰,有许多基础库都是用了 libffi。


    经过定位,发现是 vmremap 导致的 code sign error。我们通过使用静态 trampoline 的方式让 libffi 不需要使用 vmremap,解决了这个问题。这里就介绍一下相关的实现原理。

    libffi 是什么

    高层语言的编译器生成遵循某些约定的代码。这些公约部分是单独汇编工作所必需的。“调用约定”本质上是编译器对函数入口处将在哪里找到函数参数的假设的一组假设。“调用约定”还指定函数的返回值在哪里找到。
    一些程序在编译时可能不知道要传递给函数的参数。例如,在运行时,解释器可能会被告知用于调用给定函数的参数的数量和类型。Libffi 可用于此类程序,以提供从解释器程序到编译代码的桥梁。
    libffi 库为各种调用约定提供了一个便携式、高级的编程接口。这允许程序员在运行时调用调用接口描述指定的任何函数。

    ffi 的使用

    简单的找了一个使用 ffi 的库看一下他的调用接口

    ffi_type *returnType = st_ffiTypeWithType(self.signature.returnType);
    NSAssert(returnType, @"can't find a ffi_type of %@", self.signature.returnType);

    NSUInteger argumentCount = self->_argsCount;
    _args = malloc(sizeof(ffi_type *) * argumentCount) ;

    for (int i = 0; i < argumentCount; i++) {
    ffi_type* current_ffi_type = st_ffiTypeWithType(self.signature.argumentTypes[i]);
    NSAssert(current_ffi_type, @"can't find a ffi_type of %@", self.signature.argumentTypes[i]);
    _args[i] = current_ffi_type;
    }

    // 创建 ffi 跳板用到的 closure
    _closure = ffi_closure_alloc(sizeof(ffi_closure), (void **)&xxx_func_ptr);

    // 创建 cif,调用函数用到的参数和返回值的类型信息, 之后在调用时会结合call convention 处理参数和返回值
    if(ffi_prep_cif(&_cif, FFI_DEFAULT_ABI, (unsigned int)argumentCount, returnType, _args) == FFI_OK) {

    // closure 写入 跳板数据页
    if (ffi_prep_closure_loc(_closure, &_cif, _st_ffi_function, (__bridge void *)(self), xxx_func_ptr) != FFI_OK) {
    NSAssert(NO, @"genarate IMP failed");
    }
    } else {
    NSAssert(NO, @"");
    }

    看完这段代码,大概能理解 ffi 的操作。

    1. 提供给外界一个指针(指向 trampoline entry)
    2. 创建一个 closure, 将调用相关的参数返回值信息放到 closure 里
    3. 将 closure 写入到 trampoline 对应的 trampoline data entry 处

    之后我们调用 trampoline entry func ptr 时,

    1. 会找到 写入到 trampoline 对应的 trampoline data entry 处的 closure 数据
    2. 根据 closure 提供的调用参数和返回值信息,结合调用约定,操作寄存器和栈,写入参数 进行函数调用,获取返回值。

    那 ffi 是怎么找到 trampoline 对应的 trampoline data entry 处的 closure 数据 呢?

    我们从 ffi 分配 trampoline 开始说起:

    static ffi_trampoline_table *
    ffi_remap_trampoline_table_alloc (void)
    {
    .....
    /* Allocate two pages -- a config page and a placeholder page */
    config_page = 0x0;
    kt = vm_allocate (mach_task_self (), &config_page, PAGE_MAX_SIZE * 2,
    VM_FLAGS_ANYWHERE);
    if (kt != KERN_SUCCESS)
    return NULL;

    /* Allocate two pages -- a config page and a placeholder page */
    //bdffc_closure_trampoline_table_page

    /* Remap the trampoline table on top of the placeholder page */
    trampoline_page = config_page + PAGE_MAX_SIZE;
    trampoline_page_template = (vm_address_t)&ffi_closure_remap_trampoline_table_page;
    #ifdef __arm__
    /* bdffc_closure_trampoline_table_page can be thumb-biased on some ARM archs */
    trampoline_page_template &= ~1UL;
    #endif
    kt = vm_remap (mach_task_self (), &trampoline_page, PAGE_MAX_SIZE, 0x0,
    VM_FLAGS_OVERWRITE, mach_task_self (), trampoline_page_template,
    FALSE, &cur_prot, &max_prot, VM_INHERIT_SHARE);
    if (kt != KERN_SUCCESS)
    {
    vm_deallocate (mach_task_self (), config_page, PAGE_MAX_SIZE * 2);
    return NULL;
    }


    /* We have valid trampoline and config pages */
    table = calloc (1, sizeof (ffi_trampoline_table));
    table->free_count = FFI_REMAP_TRAMPOLINE_COUNT/2;
    table->config_page = config_page;
    table->trampoline_page = trampoline_page;

    ......
    return table;
    }

    首先 ffi 在创建 trampoline 时,会分配两个连续的 page

    trampoline page 会 remap 到我们事先在代码中汇编写的 ffi_closure_remap_trampoline_table_page。

    其结构如图所示:

    图片

    当我们 ffi_prep_closure_loc(_closure, &_cif, _st_ffi_function, (__bridge void *)(self), entry1)) 写入 closure 数据时, 会写入到 entry1 对应的 closuer1。

    ffi_status
    ffi_prep_closure_loc (ffi_closure *closure,
    ffi_cif* cif,
    void (*fun)(ffi_cif*,void*,void**,void*),
    void *user_data,
    void *codeloc)
    {
    ......
    if (cif->flags & AARCH64_FLAG_ARG_V)
    start = ffi_closure_SYSV_V; // ffi 对 closure的处理函数
    else
    start = ffi_closure_SYSV;

    void **config = (void**)((uint8_t *)codeloc - PAGE_MAX_SIZE);
    config[0] = closure;
    config[1] = start;
    ......
    }
    这是怎么对应到的呢? closure1 和 entry1 距离其所属 Page 的 offset 是一致的,通过 offset,成功建立 trampoline entry 和 trampoline closure 的对应关系。
    现在我们知道这个关系,我们通过代码看一下到底在程序运行的时候 是怎么找到 closure 的。
    这四条指令是我们 trampoline entry 的代码实现,就是 ffi 返回的 xxx_func_ptr
    adr x16, -PAGE_MAX_SIZE
    ldp x17, x16, [x16]
    br x16
    nop

    通过 .rept 我们创建 PAGE_MAX_SIZE / FFI_TRAMPOLINE_SIZE 个跳板,刚好一个页的大小


    # 动态remap的 page
    .align PAGE_MAX_SHIFT
    CNAME(ffi_closure_remap_trampoline_table_page):
    .rept PAGE_MAX_SIZE / FFI_TRAMPOLINE_SIZE
    # 这是我们的 trampoline entry, 就是ffi生成的函数指针
    adr x16, -PAGE_MAX_SIZE // 将pc地址减去PAGE_MAX_SIZE, 找到 trampoine data entry
    ldp x17, x16, [x16] // 加载我们写入的 closure, start 到 x17, x16
    br x16 // 跳转到 start 函数
    nop /* each entry in the trampoline config page is 2*sizeof(void*) so the trampoline itself cannot be smaller that 16 bytes */
    .endr

    通过 pc 地址减去 PAGE_MAX_SIZE 就找到对应的 trampoline data entry 了。

    静态跳板的实现

    由于代码段和数据段在不同的内存区域。

    我们此时不能通过 像 vmremap 一样分配两个连续的 PAGE,在寻找 trampoline data entry 只是简单的-PAGE_MAX_SIZE 找到对应关系,需要稍微麻烦点的处理。

    主要是通过 adrp 找到_ffi_static_trampoline_data_page1 和 _ffi_static_trampoline_page1的起始地址,用 pc-_ffi_static_trampoline_page1的起始地址计算 offset,找到 trampoline data entry。

    # 静态分配的page
    #ifdef __MACH__
    #include <mach/machine/vm_param.h>

    .align 14
    .data
    .global _ffi_static_trampoline_data_page1
    _ffi_static_trampoline_data_page1:
    .space PAGE_MAX_SIZE*5
    .align PAGE_MAX_SHIFT
    .text
    CNAME(_ffi_static_trampoline_page1):

    _ffi_local_forwarding_bridge:
    adrp x17, ffi_closure_static_trampoline_table_page_start@PAGE;// text page
    sub x16, x16, x17;// offset
    adrp x17, _ffi_static_trampoline_data_page1@PAGE;// data page
    add x16, x16, x17;// data address
    ldp x17, x16, [x16];// x17 closure x16 start
    br x16
    nop
    nop
    .align PAGE_MAX_SHIFT
    CNAME(ffi_closure_static_trampoline_table_page):

    #这个label 用来adrp@PAGE 计算 trampoline 到 trampoline page的offset
    #留了5个用来调试。
    # 我们static trampoline 两条指令就够了,这里使用4个,和remap的保持一致
    ffi_closure_static_trampoline_table_page_start:
    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    // 5 * 4
    .rept (PAGE_MAX_SIZE*5-5*4) / FFI_TRAMPOLINE_SIZE
    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop
    .endr

    .globl CNAME(ffi_closure_static_trampoline_table_page)
    FFI_HIDDEN(CNAME(ffi_closure_static_trampoline_table_page))
    #ifdef __ELF__
    .type CNAME(ffi_closure_static_trampoline_table_page), #function
    .size CNAME(ffi_closure_static_trampoline_table_page), . - CNAME(ffi_closure_static_trampoline_table_page)
    #endif
    #endif


    转自:https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247488493&idx=1&sn=e86780883d5c0cf3bb34a59ec753b4f3&chksm=e9d0d80fdea751196c807991cd46f5928f6828fe268268872ec3582b4fdcad086e1cebcab2d5&scene=178&cur_album_id=1590407423234719749#rd

    收起阅读 »

    iOS 上的相机捕捉

    第一台 iPhone 问世就装有相机。在第一个 SKDs 版本中,在 app 里面整合相机的唯一方法就是使用 UIImagePickerController,但到了 iOS 4,发布了更灵活的 AVFoundation 框架。在这篇文章里,我们将会看...
    继续阅读 »

    第一台 iPhone 问世就装有相机。在第一个 SKDs 版本中,在 app 里面整合相机的唯一方法就是使用 UIImagePickerController,但到了 iOS 4,发布了更灵活的 AVFoundation 框架。

    在这篇文章里,我们将会看到如何使用 AVFoundation 捕捉图像,如何操控相机,以及它在 iOS 8 的新特性。


    概述

    AVFoundation vs. UIImagePickerController

    UIImagePickerController 提供了一种非常简单的拍照方法。它支持所有的基本功能,比如切换到前置摄像头,开关闪光灯,点击屏幕区域实现对焦和曝光,以及在 iOS 8 中像系统照相机应用一样调整曝光。

    然而,当有直接访问相机的需求时,也可以选择 AVFoundation 框架。它提供了完全的操作权,例如,以编程方式更改硬件参数,或者操纵实时预览图。

    AVFoundation 相关类

    AVFoundation 框架基于以下几个类实现图像捕捉 ,通过这些类可以访问来自相机设备的原始数据并控制它的组件。

    • AVCaptureDevice 是关于相机硬件的接口。它被用于控制硬件特性,诸如镜头的位置、曝光、闪光灯等。
    • AVCaptureDeviceInput 提供来自设备的数据。
    • AVCaptureOutput 是一个抽象类,描述 capture session 的结果。以下是三种关于静态图片捕捉的具体子类:
      • AVCaptureStillImageOutput 用于捕捉静态图片
      • AVCaptureMetadataOutput 启用检测人脸和二维码
      • AVCaptureVideoOutput 为实时预览图提供原始帧
    • AVCaptureSession 管理输入与输出之间的数据流,以及在出现问题时生成运行时错误。
    • AVCaptureVideoPreviewLayer 是 CALayer 的子类,可被用于自动显示相机产生的实时图像。它还有几个工具性质的方法,可将 layer 上的坐标转化到设备上。它看起来像输出,但其实不是。另外,它拥有session (outputs 被 session 所拥有)。

    设置

    让我们看看如何捕获图像。首先我们需要一个 AVCaptureSession 对象:

    let session = AVCaptureSession()

    现在我们需要一个相机设备输入。在大多数 iPhone 和 iPad 中,我们可以选择后置摄像头或前置摄像头 -- 又称自拍相机 (selfie camera) -- 之一。那么我们必须先遍历所有能提供视频数据的设备 (麦克风也属于 AVCaptureDevice,因此略过不谈),并检查 position 属性:

    let availableCameraDevices = AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo)
    for device in availableCameraDevices as [AVCaptureDevice] {
    if device.position == .Back {
    backCameraDevice = device
    }
    else if device.position == .Front {
    frontCameraDevice = device
    }
    }

    然后,一旦我们发现合适的相机设备,我们就能获得相关的 AVCaptureDeviceInput 对象。我们会将它设置为 session 的输入:

    var error:NSError?
    let possibleCameraInput: AnyObject? = AVCaptureDeviceInput.deviceInputWithDevice(backCameraDevice, error: &error)
    if let backCameraInput = possibleCameraInput as? AVCaptureDeviceInput {
    if self.session.canAddInput(backCameraInput) {
    self.session.addInput(backCameraInput)
    }
    }

    注意当 app 首次运行时,第一次调用 AVCaptureDeviceInput.deviceInputWithDevice() 会触发系统提示,向用户请求访问相机。这在 iOS 7 的时候只有部分国家会有,到了 iOS 8 拓展到了所有地区。除非得到用户同意,否则相机的输入会一直是一个黑色画面的数据流。

    对于处理相机的权限,更合适的方法是先确认当前的授权状态。要是在授权还没有确定的情况下 (也就是说用户还没有看过弹出的授权对话框时),我们应该明确地发起请求。

    let authorizationStatus = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)
    switch authorizationStatus {
    case .NotDetermined:
    // 许可对话没有出现,发起授权许可
    AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo,
    completionHandler: { (granted:Bool) -> Void in
    if granted {
    // 继续
    }
    else {
    // 用户拒绝,无法继续
    }
    })
    case .Authorized:
    // 继续
    case .Denied, .Restricted:
    // 用户明确地拒绝授权,或者相机设备无法访问
    }

    如果能继续的话,我们会有两种方式来显示来自相机的图像流。最简单的就是,生成一个带有 AVCaptureVideoPreviewLayer 的 view,并使用 capture session 作为初始化参数。

    previewLayer = AVCaptureVideoPreviewLayer.layerWithSession(session) as AVCaptureVideoPreviewLayer
    previewLayer.frame = view.bounds
    view.layer.addSublayer(previewLayer)

    AVCaptureVideoPreviewLayer 会自动地显示来自相机的输出。当我们需要将实时预览图上的点击转换到设备的坐标系统中,比如点击某区域实现对焦时,这种做法会很容易办到。之后我们会看到具体细节。

    第二种方法是从输出数据流捕捉单一的图像帧,并使用 OpenGL 手动地把它们显示在 view 上。这有点复杂,但是如果我们想要对实时预览图进行操作或使用滤镜的话,就是必要的了。

    为获得数据流,我们需要创建一个 AVCaptureVideoDataOutput,这样一来,当相机在运行时,我们通过代理方法 captureOutput(_:didOutputSampleBuffer:fromConnection:) 就能获得所有图像帧 (除非我们处理太慢而导致掉帧),然后将它们绘制在一个 GLKView 中。不需要对 OpenGL 框架有什么深刻的理解,我们只需要这样就能创建一个 GLKView

    glContext = EAGLContext(API: .OpenGLES2)
    glView = GLKView(frame: viewFrame, context: glContext)
    ciContext = CIContext(EAGLContext: glContext)

    现在轮到 AVCaptureVideoOutput

    videoOutput = AVCaptureVideoDataOutput()
    videoOutput.setSampleBufferDelegate(self, queue: dispatch_queue_create("sample buffer delegate", DISPATCH_QUEUE_SERIAL))
    if session.canAddOutput(self.videoOutput) {
    session.addOutput(self.videoOutput)
    }

    以及代理方法:

    func captureOutput(captureOutput: AVCaptureOutput!, didOutputSampleBuffer sampleBuffer: CMSampleBuffer!, fromConnection connection: AVCaptureConnection!) {
    let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
    let image = CIImage(CVPixelBuffer: pixelBuffer)
    if glContext != EAGLContext.currentContext() {
    EAGLContext.setCurrentContext(glContext)
    }
    glView.bindDrawable()
    ciContext.drawImage(image, inRect:image.extent(), fromRect: image.extent())
    glView.display()
    }

    一个警告:这些来自相机的样本旋转了 90 度,这是由于相机传感器的朝向所导致的。AVCaptureVideoPreviewLayer 会自动处理这种情况,但在这个例子,我们需要对 GLKView 进行旋转。

    马上就要搞定了。最后一个组件 -- AVCaptureStillImageOutput -- 实际上是最重要的,因为它允许我们捕捉静态图片。只需要创建一个实例,并添加到 session 里去:

    stillCameraOutput = AVCaptureStillImageOutput()
    if self.session.canAddOutput(self.stillCameraOutput) {
    self.session.addOutput(self.stillCameraOutput)
    }

    配置

    现在我们有了所有必需的对象,应该为我们的需求寻找最合适的配置。这里又有两种方法可以实现。最简单且最推荐是使用 session preset:

    session.sessionPreset = AVCaptureSessionPresetPhoto

    AVCaptureSessionPresetPhoto 会为照片捕捉选择最合适的配置,比如它可以允许我们使用最高的感光度 (ISO) 和曝光时间,基于相位检测 (phase detection)的自动对焦, 以及输出全分辨率的 JPEG 格式压缩的静态图片。

    然而,如果你需要更多的操控,可以使用 AVCaptureDeviceFormat 这个类,它描述了一些设备使用的参数,比如静态图片分辨率,视频预览分辨率,自动对焦类型,感光度和曝光时间限制等。每个设备支持的格式都列在 AVCaptureDevice.formats 属性中,并可以赋值给 AVCaptureDevice 的 activeFormat (注意你并不能修改格式)。

    操作相机

    iPhone 和 iPad 中内置的相机或多或少跟其他相机有相同的操作,不同的是,一些参数如对焦、曝光时间 (在单反相机上的模拟快门的速度),感光度是可以调节,但是镜头光圈是固定不可调整的。到了 iOS 8,我们已经可以对所有这些可变参数进行手动调整了。

    我们之后会看到细节,不过首先,该启动相机了:

    sessionQueue = dispatch_queue_create("com.example.camera.capture_session", DISPATCH_QUEUE_SERIAL)
    dispatch_async(sessionQueue) { () -> Void in
    self.session.startRunning()
    }

    在 session 和相机设备中完成的所有操作和配置都是利用 block 调用的。因此,建议将这些操作分配到后台的串行队列中。此外,相机设备在改变某些参数前必须先锁定,直到改变结束才能解锁,例如:

    var error:NSError?
    if currentDevice.lockForConfiguration(&error) {
    // 锁定成功,继续配置
    // currentDevice.unlockForConfiguration()
    }
    else {
    // 出错,相机可能已经被锁
    }

    对焦

    在 iOS 相机上,对焦是通过移动镜片改变其到传感器之间的距离实现的。

    自动对焦是通过相位检测和反差检测实现的。然而,反差检测只适用于低分辨率和高 FPS 视频捕捉 (慢镜头)。

    AVCaptureFocusMode 是个枚举,描述了可用的对焦模式:

    • Locked 指镜片处于固定位置
    • AutoFocus 指一开始相机会先自动对焦一次,然后便处于 Locked 模式。
    • ContinuousAutoFocus 指当场景改变,相机会自动重新对焦到画面的中心点。

    设置想要的对焦模式必须在锁定之后实施:

    let focusMode:AVCaptureFocusMode = ...
    if currentCameraDevice.isFocusModeSupported(focusMode) {
    ... // 锁定以进行配置
    currentCameraDevice.focusMode = focusMode
    ... // 解锁
    }
    }

    通常情况下,AutoFocus 模式会试图让屏幕中心成为最清晰的区域,但是也可以通过变换 “感兴趣的点 (point of interest)” 来设定另一个区域。这个点是一个 CGPoint,它的值从左上角 {0,0} 到右下角 {1,1}{0.5,0.5} 为画面的中心点。通常可以用视频预览图上的点击手势识别来改变这个点,想要将 view 上的坐标转化到设备上的规范坐标,我们可以使用 AVVideoCaptureVideoPreviewLayer.captureDevicePointOfInterestForPoint()

    var pointInPreview = focusTapGR.locationInView(focusTapGR.view)
    var pointInCamera = previewLayer.captureDevicePointOfInterestForPoint(pointInPreview)
    ...// 锁定,配置

    // 设置感兴趣的点
    currentCameraDevice.focusPointOfInterest = pointInCamera

    // 在设置的点上切换成自动对焦
    currentCameraDevice.focusMode = .AutoFocus

    ...// 解锁

    在 iOS 8 中,有个新选项可以移动镜片的位置,从较近物体的 0.0 到较远物体的 1.0 (不是指无限远)。

    ... // 锁定,配置
    var lensPosition:Float = ... // 0.0 到 1.0的float
    currentCameraDevice.setFocusModeLockedWithLensPosition(lensPosition) {
    (timestamp:CMTime) -> Void in
    // timestamp 对应于应用了镜片位置的第一张图像缓存区
    }
    ... // 解锁

    这意味着对焦可以使用 UISlider 设置,这有点类似于旋转单反上的对焦环。当用这种相机手动对焦时,通常有一个可见的辅助标识指向清晰的区域。AVFoundation 里面没有内置这种机制,但是比如可以通过显示 "对焦峰值 (focus peaking)"(一种将已对焦区域高亮显示的方式) 这样的手段来补救。我们在这里不会讨论细节,不过对焦峰值可以很容易地实现,通过使用阈值边缘 (threshold edge) 滤镜 (用自定义 CIFilter 或 GPUImageThresholdEdgeDetectionFilter),并调用 AVCaptureAudioDataOutputSampleBufferDelegate下的 captureOutput(_:didOutputSampleBuffer:fromConnection:) 方法将它覆盖到实时预览图上。

    曝光

    在 iOS 设备上,镜头上的光圈是固定的 (在 iPhone 5s 以及其之后的光圈值是 f/2.2,之前的是 f/2.4),因此只有改变曝光时间和传感器的灵敏度才能对图片的亮度进行调整,从而达到合适的效果。至于对焦,我们可以选择连续自动曝光,在“感兴趣的点”一次性自动曝光,或者手动曝光。除了指定“感兴趣的点”,我们可以通过设置曝光补偿 (compensation) 修改自动曝光,也就是曝光档位的目标偏移。目标偏移在曝光档数里有讲到,它的范围在 minExposureTargetBias 与 maxExposureTargetBias 之间,0为默认值 (即没有“补偿”)。

    var exposureBias:Float = ... // 在 minExposureTargetBias 和 maxExposureTargetBias 之间的值
    ... // 锁定,配置
    currentDevice.setExposureTargetBias(exposureBias) { (time:CMTime) -> Void in
    }
    ... // 解锁

    使用手动曝光,我们可以设置 ISO 和曝光时间,两者的值都必须在设备当前格式所指定的范围内。

    var activeFormat = currentDevice.activeFormat
    var duration:CTime = ... //在activeFormat.minExposureDuration 和 activeFormat.maxExposureDuration 之间的值,或用 AVCaptureExposureDurationCurrent 表示不变
    var iso:Float = ... // 在 activeFormat.minISO 和 activeFormat.maxISO 之间的值,或用 AVCaptureISOCurrent 表示不变
    ... // 锁定,配置
    currentDevice.setExposureModeCustomWithDuration(duration, ISO: iso) { (time:CMTime) -> Void in
    }
    ... // 解锁

    如何知道照片曝光是否正确呢?我们可以通过 KVO,观察 AVCaptureDevice 的 exposureTargetOffset 属性,确认是否在 0 附近。

    白平衡

    数码相机为了适应不同类型的光照条件需要补偿。这意味着在冷光线的条件下,传感器应该增强红色部分,而在暖光线下增强蓝色部分。在 iPhone 相机中,设备会自动决定合适的补光,但有时也会被场景的颜色所混淆失效。幸运地是,iOS 8 可以里手动控制白平衡。

    自动模式工作方式和对焦、曝光的方式一样,但是没有“感兴趣的点”,整张图像都会被纳入考虑范围。在手动模式,我们可以通过开尔文所表示的温度来调节色温和色彩。典型的色温值在 2000-3000K (类似蜡烛或灯泡的暖光源) 到 8000K (纯净的蓝色天空) 之间。色彩范围从最小的 -150 (偏绿) 到 150 (偏品红)。

    温度和色彩可以被用于计算来自相机传感器的恰当的 RGB 值,因此仅当它们做了基于设备的校正后才能被设置。

    以下是全部过程:

    var incandescentLightCompensation = 3_000
    var tint = 0 // 不调节
    let temperatureAndTintValues = AVCaptureWhiteBalanceTemperatureAndTintValues(temperature: incandescentLightCompensation, tint: tint)
    var deviceGains = currentCameraDevice.deviceWhiteBalanceGainsForTemperatureAndTintValues(temperatureAndTintValues)
    ... // 锁定,配置
    currentCameraDevice.setWhiteBalanceModeLockedWithDeviceWhiteBalanceGains(deviceGains) {
    (timestamp:CMTime) -> Void in
    }
    }
    ... // 解锁

    实时人脸检测

    AVCaptureMetadataOutput 可以用于检测人脸和二维码这两种物体。很明显,没什么人用二维码 (编者注: 因为在欧美现在二维码不是很流行,这里是一个恶搞。链接的这个 tumblr 博客的主题是 “当人们在扫二维码时的图片”,但是 2012 年开博至今没有任何一张图片,暗讽二维码根本没人在用,这和以中日韩为代表的亚洲用户群体的使用习惯完全相悖),因此我们就来看看如何实现人脸检测。我们只需通过 AVCaptureMetadataOutput的代理方法捕获的元对象:var metadataOutput = AVCaptureMetadataOutput()

    metadataOutput.setMetadataObjectsDelegate(self, queue: self.sessionQueue)
    if session.canAddOutput(metadataOutput) {
    session.addOutput(metadataOutput)
    }
    metadataOutput.metadataObjectTypes = [AVMetadataObjectTypeFace]
    func captureOutput(captureOutput: AVCaptureOutput!, didOutputMetadataObjects metadataObjects: [AnyObject]!, fromConnection connection: AVCaptureConnection!) {
    for metadataObject in metadataObjects as [AVMetadataObject] {
    if metadataObject.type == AVMetadataObjectTypeFace {
    var transformedMetadataObject = previewLayer.transformedMetadataObjectForMetadataObject(metadataObject)
    }
    }

    捕捉静态图片

    最后,我们要做的是捕捉高分辨率的图像,于是我们调用 captureStillImageAsynchronouslyFromConnection(connection, completionHandler)。在数据时被读取时,completion handler 将会在某个未指定的线程上被调用。

    如果设置使用 JPEG 编码作为静态图片输出,不管是通过 session .Photo 预设设定的,还是通过设备输出设置设定的,sampleBuffer 都会返回包含图像的元数据。如果在 AVCaptureMetadataOutput 中是可用的话,这会包含 EXIF 数据,或是被识别的人脸等:

    dispatch_async(sessionQueue) { () -> Void in

    let connection = self.stillCameraOutput.connectionWithMediaType(AVMediaTypeVideo)

    // 将视频的旋转与设备同步
    connection.videoOrientation = AVCaptureVideoOrientation(rawValue: UIDevice.currentDevice().orientation.rawValue)!

    self.stillCameraOutput.captureStillImageAsynchronouslyFromConnection(connection) {
    (imageDataSampleBuffer, error) -> Void in

    if error == nil {

    // 如果使用 session .Photo 预设,或者在设备输出设置中明确进行了设置
    // 我们就能获得已经压缩为JPEG的数据

    let imageData = AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation(imageDataSampleBuffer)

    // 样本缓冲区也包含元数据,我们甚至可以按需修改它

    let metadata:NSDictionary = CMCopyDictionaryOfAttachments(nil, imageDataSampleBuffer, CMAttachmentMode(kCMAttachmentMode_ShouldPropagate)).takeUnretainedValue()

    if let image = UIImage(data: imageData) {
    // 保存图片,或者做些其他想做的事情
    ...
    }
    }
    else {
    NSLog("error while capturing still image: \(error)")
    }
    }
    }

    当图片被捕捉的时候,有视觉上的反馈是很好的体验。想要知道何时开始以及何时结束的话,可以使用 KVO 来观察 AVCaptureStillImageOutput 的 isCapturingStillImage 属性。

    分级捕捉

    在 iOS 8 还有一个有趣的特性叫“分级捕捉”,可以在不同的曝光设置下拍摄几张照片。这在复杂的光线下拍照显得非常有用,例如,通过设定 -1、0、1 三个不同的曝光档数,然后用 HDR 算法合并成一张。

    以下是代码实现:

    dispatch_async(sessionQueue) { () -> Void in
    let connection = self.stillCameraOutput.connectionWithMediaType(AVMediaTypeVideo)
    connection.videoOrientation = AVCaptureVideoOrientation(rawValue: UIDevice.currentDevice().orientation.rawValue)!

    var settings = [-1.0, 0.0, 1.0].map {
    (bias:Float) -> AVCaptureAutoExposureBracketedStillImageSettings in

    AVCaptureAutoExposureBracketedStillImageSettings.autoExposureSettingsWithExposureTargetBias(bias)
    }

    var counter = settings.count

    self.stillCameraOutput.captureStillImageBracketAsynchronouslyFromConnection(connection, withSettingsArray: settings) {
    (sampleBuffer, settings, error) -> Void in

    ...
    // 保存 sampleBuffer(s)

    // 当计数为0,捕捉完成
    counter--

    }
    }

    这很像是单个图像捕捉,但是不同的是 completion handler 被调用的次数和设置的数组的元素个数一样多。

    总结

    我们已经详细看到如何在 iPhone 应用里面实现拍照的基础功能(呃…不光是 iPhone,用 iPad 拍照其实也是不错的)。你也可以查看这个例子。最后说下,iOS 8 允许更精确的捕捉,特别是对于高级用户,这使得 iPhone 与专业相机之间的差距缩小,至少在手动控制上。不过,不是任何人都喜欢在日常拍照时使用复杂的手动操作界面,因此请合理地使用这些特性。


    原文:https://objccn.io/issue-21-3/

    收起阅读 »

    iOS 柱状图一种实现思路

    对于iOS柱状图,不是有什么难度的效果,有很多优秀的第三方库,比如AAChartKit、XYPieChart、PNChart、Charts等好多,不过这些类库大多封装的太厉害了,如果你的项目只是单纯的几个柱状图、那么使用这些库其实挺费劲的(学习成本+项目大小)...
    继续阅读 »

    对于iOS柱状图,不是有什么难度的效果,有很多优秀的第三方库,比如AAChartKitXYPieChartPNChartCharts等好多,不过这些类库大多封装的太厉害了,如果你的项目只是单纯的几个柱状图、那么使用这些库其实挺费劲的(学习成本+项目大小),下面说说我的思路。

    iOS绘图以及图形处理主要使用的是Core Graphics/QuartZ 2D,这也是大部分人写柱状图的方法,即使用UIBezierPath配合Core Graphics实现。我的思路是使用UICollectionView,不过使用UICollectionView实现柱状图,最好需求能满足以下二点:

    • 1.柱状图的柱子够宽,最好有点击需求
    • 2.柱状图的柱子比较多,需要滑动,这个更能体现出Cell复用

    当然,也并不是一定要满足上面2点,接下来用几个小Demo演示一下(注:Demo是Objective-C实现)

    DemoA

    这个是基本的效果,使用UICollectionViewFlowLayout布局,将scrollDirection设置为UICollectionViewScrollDirectionHorizontal;每个cell内部有个绿色的UIView,根据数值调整这个绿色UIView的高度,就是图上的效果了,其实核心就是UICollectionViewFlowLayout,后面几个Demo也全是基于此。

    UICollectionViewFlowLayout *fw = [[UICollectionViewFlowLayout alloc] init];
    fw.scrollDirection = UICollectionViewScrollDirectionHorizontal;
    fw.minimumLineSpacing = 10;
    fw.minimumInteritemSpacing = 0;
    fw.itemSize = CGSizeMake(220, 30);
    fw.headerReferenceSize = CGSizeMake(10, 220);
    fw.footerReferenceSize = CGSizeMake(10, 220);
    DemoB

    这个效果是加了横坐标值和渐变Cell,每个柱状图重新出现屏幕上时,会动画出现,需要注意的是,渐变使用的是CAGradientLayer,但是对含有CAGradientLayer的view使用frame动画,会造成渐变的卡顿和动画的不流畅,所以这里是使用CAGradientLayer生成一张渐变图,设置成柱状图柱子的背景即可。

    DemoC

    这个效果是始终以中间的Cell为基准显示,点击其他Cell也会自动滚到中心。因为UICollectionView继承于UIScrollView,所以实现这种效果,关键在于两个代理方法:
    - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset;

    - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView;
    DemoD

    这个效果的目的是:有的需求是柱状图比较密集,当手指滑动时又要求可以显示出对应柱子的值。其实实现起来很简单,就是使用touchesBegan:withEvent:以及touchesMoved:withEvent:等几个方法即可。

    DemoE

    这个是有柱状图的同时,还有曲线图,实现方法是在UICollectionView上面加了一个透明的UIView,同时通过此UIViewhitTest:withEvent:方法,将事件给到UICollectionView,再通过UICollectionView的代理方法,获取界面上的Cell,绘制曲线到UIView上。需要注意的是,UICollectionViewvisibleCells方法,获取到的Cell,顺序不是界面上的顺序,需要排序之后再使用。

    其实通过UIViewhitTest:withEvent:方法,能做很多神奇的事情,大家可以自行研究。

    DemoF

    这个没啥,就是说明如果有复杂的坐标,也是可以实现的,这个Demo的做法是在UICollectionView下面有一个UIView专门绘制坐标系。

    DemoG


    这个其实跟柱状图没有关系,大家都知道,安卓的刷新和iOS不一样,下拉刷新分为侵入式非侵入式,对于iOS而言,由于UIScrollViewBounce效果,所以使用侵入式下拉刷新,成了最好的选择,但是iOS能否实现安卓那样的非侵入式刷新呢?于是本Demo就简单研究了一下,目前是存在bug的,样式也粗糙,不过思路应该没有问题,提供给大家,可以研究研究
    1. 添加 UITableView
    2. 在TableView上覆盖一个无背景色的UIScrollView
    3. 覆写UIScrollView的几个touchesBegan、touchesEnded等几个方法,使其点击事件传递到TableView
    4. 在UIScrollView的代理方法scrollViewDidScroll里处理
    4.1 scrollView.contentOffset.y小于0,处理刷新动画和刷新逻辑
    4.2 scrollView.contentOffset.y大于0,同步设置TableView的contentOffset 来保持滚动一致
    5. 应该始终让scrollView和TableView的contentSize保持一致

    至此,本文就没了,其实本文没啥技术含量,说白就是UICollectionView的使用,不过主要目的是给大家提供思路,具体需求还得具体分析。


    链接:https://www.jianshu.com/p/087e8d96fcdc/
    收起阅读 »

    iOS功能强大的富文本编辑与显示框架 -- YYText

    功能强大的 iOS 富文本编辑与显示框架。(该项目是 YYKit 组件之一)特性API 兼容 UILabel 和 UITextView支持高性能的异步排版和渲染扩展了 CoreText 的属性以支持更多文字效果支持 UIImage、UIVi...
    继续阅读 »


    功能强大的 iOS 富文本编辑与显示框架。
    (该项目是 YYKit 组件之一)

    特性

    • API 兼容 UILabel 和 UITextView
    • 支持高性能的异步排版和渲染
    • 扩展了 CoreText 的属性以支持更多文字效果
    • 支持 UIImage、UIView、CALayer 作为图文混排元素
    • 支持添加自定义样式的、可点击的文本高亮范围
    • 支持自定义文本解析 (内置简单的 Markdown/表情解析)
    • 支持文本容器路径、内部留空路径的控制
    • 支持文字竖排版,可用于编辑和显示中日韩文本
    • 支持图片和富文本的复制粘贴
    • 文本编辑时,支持富文本占位符
    • 支持自定义键盘视图
    • 撤销和重做次数的控制
    • 富文本的序列化与反序列化支持
    • 支持多语言,支持 VoiceOver
    • 支持 Interface Builder
    • 全部代码都有文档注释

    架构

    YYText 和 TextKit 架构对比


    文本属性

    YYText 原生支持的属性



    YYText 支持的 CoreText 属性



    用法

    基本用法

    // YYLabel (和 UILabel 用法一致)
    YYLabel *label = [YYLabel new];
    label.frame = ...
    label.font = ...
    label.textColor = ...
    label.textAlignment = ...
    label.lineBreakMode = ...
    label.numberOfLines = ...
    label.text = ...

    // YYTextView (和 UITextView 用法一致)
    YYTextView *textView = [YYTextView new];
    textView.frame = ...
    textView.font = ...
    textView.textColor = ...
    textView.dataDetectorTypes = ...
    textView.placeHolderText = ...
    textView.placeHolderTextColor = ...
    textView.delegate = ...

    属性文本

    // 1. 创建一个属性文本
    NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:@"Some Text, blabla..."];

    // 2. 为文本设置属性
    text.yy_font = [UIFont boldSystemFontOfSize:30];
    text.yy_color = [UIColor blueColor];
    [text yy_setColor:[UIColor redColor] range:NSMakeRange(0, 4)];
    text.yy_lineSpacing = 10;

    // 3. 赋值到 YYLabel 或 YYTextView
    YYLabel *label = [YYLabel new];
    label.frame = ...
    label.attributedString = text;

    YYTextView *textView = [YYTextView new];
    textView.frame = ...
    textView.attributedString = text;

    文本高亮

    你可以用一些已经封装好的简便方法来设置文本高亮:

    [text yy_setTextHighlightRange:range
    color:[UIColor blueColor]
    backgroundColor:[UIColor grayColor]
    tapAction:^(UIView *containerView, NSAttributedString *text, NSRange range, CGRect rect){
    NSLog(@"tap text range:...");
    }];

    或者用更复杂的办法来调节文本高亮的细节:

    // 1. 创建一个"高亮"属性,当用户点击了高亮区域的文本时,"高亮"属性会替换掉原本的属性
    YYTextBorder *border = [YYTextBorder borderWithFillColor:[UIColor grayColor] cornerRadius:3];

    YYTextHighlight *highlight = [YYTextHighlight new];
    [highlight setColor:[UIColor whiteColor]];
    [highlight setBackgroundBorder:highlightBorder];
    highlight.tapAction = ^(UIView *containerView, NSAttributedString *text, NSRange range, CGRect rect) {
    NSLog(@"tap text range:...");
    // 你也可以把事件回调放到 YYLabel 和 YYTextView 来处理。
    };

    // 2. 把"高亮"属性设置到某个文本范围
    [attributedText yy_setTextHighlight:highlight range:highlightRange];

    // 3. 把属性文本设置到 YYLabel 或 YYTextView
    YYLabel *label = ...
    label.attributedText = attributedText

    YYTextView *textView = ...
    textView.attributedText = ...

    // 4. 接受事件回调
    label.highlightTapAction = ^(UIView *containerView, NSAttributedString *text, NSRange range, CGRect rect) {
    NSLog(@"tap text range:...");
    };
    label.highlightLongPressAction = ^(UIView *containerView, NSAttributedString *text, NSRange range, CGRect rect) {
    NSLog(@"long press text range:...");
    };

    @UITextViewDelegate
    - (void)textView:(YYTextView *)textView didTapHighlight:(YYTextHighlight *)highlight inRange:(NSRange)characterRange rect:(CGRect)rect {
    NSLog(@"tap text range:...");
    }
    - (void)textView:(YYTextView *)textView didLongPressHighlight:(YYTextHighlight *)highlight inRange:(NSRange)characterRange rect:(CGRect)rect {
    NSLog(@"long press text range:...");
    }

    图文混排

    NSMutableAttributedString *text = [NSMutableAttributedString new];
    UIFont *font = [UIFont systemFontOfSize:16];
    NSMutableAttributedString *attachment = nil;

    // 嵌入 UIImage
    UIImage *image = [UIImage imageNamed:@"dribbble64_imageio"];
    attachment = [NSMutableAttributedString yy_attachmentStringWithContent:image contentMode:UIViewContentModeCenter attachmentSize:image.size alignToFont:font alignment:YYTextVerticalAlignmentCenter];
    [text appendAttributedString: attachment];

    // 嵌入 UIView
    UISwitch *switcher = [UISwitch new];
    [switcher sizeToFit];
    attachment = [NSMutableAttributedString yy_attachmentStringWithContent:switcher contentMode:UIViewContentModeBottom attachmentSize:switcher.size alignToFont:font alignment:YYTextVerticalAlignmentCenter];
    [text appendAttributedString: attachment];

    // 嵌入 CALayer
    CASharpLayer *layer = [CASharpLayer layer];
    layer.path = ...
    attachment = [NSMutableAttributedString yy_attachmentStringWithContent:layer contentMode:UIViewContentModeBottom attachmentSize:switcher.size alignToFont:font alignment:YYTextVerticalAlignmentCenter];
    [text appendAttributedString: attachment];

    文本布局计算

    NSAttributedString *text = ...
    CGSize size = CGSizeMake(100, CGFLOAT_MAX);
    YYTextLayout *layout = [YYTextLayout layoutWithContainerSize:size text:text];

    // 获取文本显示位置和大小
    layout.textBoundingRect; // get bounding rect
    layout.textBoundingSize; // get bounding size

    // 查询文本排版结果
    [layout lineIndexForPoint:CGPointMake(10,10)];
    [layout closestLineIndexForPoint:CGPointMake(10,10)];
    [layout closestPositionToPoint:CGPointMake(10,10)];
    [layout textRangeAtPoint:CGPointMake(10,10)];
    [layout rectForRange:[YYTextRange rangeWithRange:NSMakeRange(10,2)]];
    [layout selectionRectsForRange:[YYTextRange rangeWithRange:NSMakeRange(10,2)]];

    // 显示文本排版结果
    YYLabel *label = [YYLabel new];
    label.size = layout.textBoundingSize;
    label.textLayout = layout;

    文本行位置调整

    // 由于中文、英文、Emoji 等字体高度不一致,或者富文本中出现了不同字号的字体,
    // 可能会造成每行文字的高度不一致。这里可以添加一个修改器来实现固定行高,或者自定义文本行位置。

    // 简单的方法:
    // 1. 创建一个文本行位置修改类,实现 `YYTextLinePositionModifier` 协议。
    // 2. 设置到 Label 或 TextView。

    YYTextLinePositionSimpleModifier *modifier = [YYTextLinePositionSimpleModifier new];
    modifier.fixedLineHeight = 24;

    YYLabel *label = [YYLabel new];
    label.linePositionModifier = modifier;

    // 完全控制:
    YYTextLinePositionSimpleModifier *modifier = [YYTextLinePositionSimpleModifier new];
    modifier.fixedLineHeight = 24;

    YYTextContainer *container = [YYTextContainer new];
    container.size = CGSizeMake(100, CGFLOAT_MAX);
    container.linePositionModifier = modifier;

    YYTextLayout *layout = [YYTextLayout layoutWithContainer:container text:text];
    YYLabel *label = [YYLabel new];
    label.size = layout.textBoundingSize;
    label.textLayout = layout;

    异步排版和渲染

    // 如果你在显示字符串时有性能问题,可以这样开启异步模式:
    YYLabel *label = ...
    label.displaysAsynchronously = YES;

    // 如果需要获得最高的性能,你可以在后台线程用 `YYTextLayout` 进行预排版:
    YYLabel *label = [YYLabel new];
    label.displaysAsynchronously = YES; //开启异步绘制
    label.ignoreCommonProperties = YES; //忽略除了 textLayout 之外的其他属性

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    // 创建属性字符串
    NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:@"Some Text"];
    text.yy_font = [UIFont systemFontOfSize:16];
    text.yy_color = [UIColor grayColor];
    [text yy_setColor:[UIColor redColor] range:NSMakeRange(0, 4)];

    // 创建文本容器
    YYTextContainer *container = [YYTextContainer new];
    container.size = CGSizeMake(100, CGFLOAT_MAX);
    container.maximumNumberOfRows = 0;

    // 生成排版结果
    YYTextLayout *layout = [YYTextLayout layoutWithContainer:container text:text];

    dispatch_async(dispatch_get_main_queue(), ^{
    label.size = layout.textBoundingSize;
    label.textLayout = layout;
    });
    });

    文本容器控制

    YYLabel *label = ...
    label.textContainerPath = [UIBezierPath bezierPathWith...];
    label.exclusionPaths = @[[UIBezierPath bezierPathWith...];,...];
    label.textContainerInset = UIEdgeInsetsMake(...);
    label.verticalForm = YES/NO;

    YYTextView *textView = ...
    textView.exclusionPaths = @[[UIBezierPath bezierPathWith...];,...];
    textView.textContainerInset = UIEdgeInsetsMake(...);
    textView.verticalForm = YES/NO;

    文本解析

    // 1. 创建一个解析器

    // 内置简单的表情解析
    YYTextSimpleEmoticonParser *parser = [YYTextSimpleEmoticonParser new];
    NSMutableDictionary *mapper = [NSMutableDictionary new];
    mapper[@":smile:"] = [UIImage imageNamed:@"smile.png"];
    mapper[@":cool:"] = [UIImage imageNamed:@"cool.png"];
    mapper[@":cry:"] = [UIImage imageNamed:@"cry.png"];
    mapper[@":wink:"] = [UIImage imageNamed:@"wink.png"];
    parser.emoticonMapper = mapper;

    // 内置简单的 markdown 解析
    YYTextSimpleMarkdownParser *parser = [YYTextSimpleMarkdownParser new];
    [parser setColorWithDarkTheme];

    // 实现 `YYTextParser` 协议的自定义解析器
    MyCustomParser *parser = ...

    // 2. 把解析器添加到 YYLabel 或 YYTextView
    YYLabel *label = ...
    label.textParser = parser;

    YYTextView *textView = ...
    textView.textParser = parser;

    安装

    CocoaPods

    1. 在 Podfile 中添加 pod 'YYText'
    2. 执行 pod install 或 pod update
    3. 导入 <YYText/YYText.h>。

    Carthage

    1. 在 Cartfile 中添加 github "ibireme/YYText"
    2. 执行 carthage update --platform ios 并将生成的 framework 添加到你的工程。
    3. 导入 <YYText/YYText.h>。

    手动安装

    1. 下载 YYText 文件夹内的所有内容。
    2. 将 YYText 内的源文件添加(拖放)到你的工程。
    3. 链接以下 frameworks:
      • UIKit
      • CoreFoundation
      • CoreText
      • QuartzCore
      • Accelerate
      • MobileCoreServices
    4. 导入 YYText.h

    注意

    你可以添加 YYImage 或 YYWebImage 到你的工程,以支持动画格式(GIF/APNG/WebP)的图片。


    链接:https://github.com/ibireme/YYText


    收起阅读 »

    iOS中可定制性商品计数按钮-PPNumberButton

    iOS中一款高度可定制性商品计数按钮,使用简单!支持自定义加/减按钮的标题内容、背景图片;支持设置边框颜色;支持使用键盘输入;支持长按加/减按钮快速加减;支持block回调与delegate(代理)回调;支持使用xib创建、直接在IB面板设置相关属性;支持设置...
    继续阅读 »

    iOS中一款高度可定制性商品计数按钮,使用简单!

    • 支持自定义加/减按钮的标题内容、背景图片;
    • 支持设置边框颜色;
    • 支持使用键盘输入;
    • 支持长按加/减按钮快速加减;
    • 支持block回调与delegate(代理)回调;
    • 支持使用xib创建、直接在IB面板设置相关属性;
    • 支持设置maxValue(最大值)与minValue(最小值).
    • 支持按钮自定义为京东/淘宝样式,饿了么/美团外卖/百度外卖样式;

    Requirements 要求

    • iOS 7+
    • Xcode 8+

    Installation 安装

    1.手动安装:

    下载DEMO后,将子文件夹PPNumberButton拖入到项目中, 导入头文件PPNumberButton.h开始使用.

    2.CocoaPods安装:

    first pod 'PPNumberButton' then pod install或pod install --no-repo-update`

    如果发现pod search PPNumberButton 不是最新版本,在终端执行pod setup命令更新本地spec镜像缓存(时间可能有点长),重新搜索就OK了

    Usage 使用方法

    实例化方法

    [[PPNumberButton alloc] init];:默认的frame为CGRectMake(0, 0, 110, 30) 或[[PPNumberButton alloc] initWithFrame:frame];

    或 [PPNumberButton numberButtonWithFrame:frame];: 类方法创建

    1.自定义加减按钮文字标题

    PPNumberButton *numberButton = [PPNumberButton numberButtonWithFrame:CGRectMake(100, 100, 110, 30)];
    // 开启抖动动画
    numberButton.shakeAnimation = YES;
    // 设置最小值
    numberButton.minValue = 2;
    // 设置最大值
    numberButton.maxValue = 10;
    // 设置输入框中的字体大小
    numberButton.inputFieldFont = 23;
    numberButton.increaseTitle = @"+";
    numberButton.decreaseTitle = @"-";

    numberButton.resultBlock = ^(NSString *num){
    NSLog(@"%@",num);
    };
    [self.view addSubview:numberButton];

    2.边框状态

    PPNumberButton *numberButton = [PPNumberButton numberButtonWithFrame:CGRectMake(100, 160, 150, 30)];
    //设置边框颜色
    numberButton.borderColor = [UIColor grayColor];
    numberButton.increaseTitle = @"+";
    numberButton.decreaseTitle = @"-";
    numberButton.resultBlock = ^(NSString *num){
    NSLog(@"%@",num);
    };
    [self.view addSubview:numberButton];

    3.自定义加减按钮背景图片

    PPNumberButton *numberButton = [PPNumberButton numberButtonWithFrame:CGRectMake(100, 220, 100, 30)];
    numberButton.shakeAnimation = YES;
    numberButton.increaseImage = [UIImage imageNamed:@"increase_taobao"];
    numberButton.decreaseImage = [UIImage imageNamed:@"decrease_taobao"];
    numberButton.resultBlock = ^(NSString *num){
    NSLog(@"%@",num);
    };
    [self.view addSubview:numberButton];

    4.饿了么,美团外卖,百度外卖样式

    PPNumberButton *numberButton = [PPNumberButton numberButtonWithFrame:CGRectMake(100, 280, 100, 30)];
    // 初始化时隐藏减按钮
    numberButton.decreaseHide = YES;
    numberButton.increaseImage = [UIImage imageNamed:@"increase_meituan"];
    numberButton.decreaseImage = [UIImage imageNamed:@"decrease_meituan"];
    numberButton.resultBlock = ^(NSString *num){
    NSLog(@"%@",num);
    };
    [self.view addSubview:numberButton];

    使用xib创建

    在控制器界面拖入UIView控件,在右侧的设置栏中将class名修改为PPNumberButton,按回车就OK了 (注意:如果通过Cocopods导入, 使用XIB/SB创建按钮会显示不全,还可能会报错.但APP可以编译运行,这应该是Cocopods或Xcode的问题)示例图 注意!如果有的同学将控件拖线到代码中,千万不要忘记在拖线的代码文件中导入 "PPNumberButton.h"头文件,否则会报错.


    链接:https://github.com/jkpang/PPNumberButton

    收起阅读 »

    ios列表布局三方库--SwipeTableView

    功能类似半糖首页菜单与QQ音乐歌曲列表页面。即支持UITableview的上下滚动,同时也支持不同列表之间的滑动切换。同时可以设置顶部header view与列表切换功能bar,使用方式类似于原生UITableview的tableHeaderView的方式。使...
    继续阅读 »

    功能类似半糖首页菜单与QQ音乐歌曲列表页面。即支持UITableview的上下滚动,同时也支持不同列表之间的滑动切换。同时可以设置顶部header view与列表切换功能bar,使用方式类似于原生UITableview的tableHeaderView的方式。

    使用 Cocoapods 导入

    pod 'SwipeTableView'

    Mode 1


    1. 使用UICollectionView作为item的载体,实现左右滑动的功能。

    2. 在支持左右滑动之后,最关键的问题就是是滑动后相邻item的对齐问题。
    为实现前后item对齐,需要在itemView重用的时候,比较前后两个itemView的contentOffset,然后设置后一个itemView的contentOffset与前一个相同。这样就实现了左右滑动后前后itemView的offset是对齐的。

    3.由于多个item共用一个headerbar,所以,headerbar必须是根视图的子视图,即与CollectionView一样是SwipeTableView的子视图,并且在CollectionView的图层之上。

    headr & bar的滚动与悬停实现是,对当前的itemView的contentOffset进行KVO。然后在当前itemView的contentOffset发生变化时,去改变header与bar的Y坐标值。


    1. 顶部header & bar在图层的最顶部,所以每个itemView的顶部需要做出一个留白来作为header & bar的显示空间。在Mode 1中,采用修改UIScrollViewcontentInsetstop值来留出顶部留白。

    2. 由于header在图层的最顶部,所以要实现滑动header的同时使当前itemView跟随滚动,需要根据headerframe的变化回调给当前的itemView来改变contentOffset,同时也要具有ScrollView的弹性等效果。

    Mode 2

    1.Mode 2中,基本结构与Mode 1一样,唯一的不同在于每个itemView顶部留白的的方式。


    通过设置UITabelView的tableHeaderView,来提供顶部的占位留白,CollectionView采用自定义STCollectionView的collectionHeaderView来实现占位留白。(目前不支持UIScrollView)

    2 如何设置区分Mode 1Mode 2模式?

    正常条件下即为Mode 1模式;在SwipeTableView.h中或者在工程PCH文件中设置宏#define ST_PULLTOREFRESH_HEADER_HEIGHT xx设置为Mode 2模式。

    使用用法

    怎样使用?使用方式类似UITableView

    实现 SwipeTableViewDataSource 代理的两个方法:

    - (NSInteger)numberOfItemsInSwipeTableView:(SwipeTableView *)swipeView

    返回列表item的个数

    - (UIScrollView *)swipeTableView:(SwipeTableView *)swipeView viewForItemAtIndex:(NSInteger)index reusingView:(UIScrollView *)view

    使用的swipeHeaderView必须是STHeaderView及其子类的实例。

    如何支持下拉刷新?

    下拉刷新有两种实现方式,一种用户自定义下拉刷新组件(局部修改自定义),一种是简单粗暴设置宏:

    1. 一行代码支持常用的下拉刷新控件,只需要在项目的PCH文件中或者在SwipeTableView.h文件中设置如下的宏:

    #define ST_PULLTOREFRESH_HEADER_HEIGHT xx

    上述宏中的xx要与您使用的第三方下拉刷新控件的refreshHeader高度相同:
    MJRefresh 为 MJRefreshHeaderHeightSVPullToRefresh 为 SVPullToRefreshViewHeight(注:此时视图结构为Model 2

    新增下拉刷新代理,可以控制每个item下拉临界高度,并自由控制每个item是否支持下拉刷新

    - (BOOL)swipeTableView:(SwipeTableView *)swipeTableView shouldPullToRefreshAtIndex:(NSInteger)index

    根据item所在index,设置item是否支持下拉刷新。在设置#define ST_PULLTOREFRESH_HEADER_HEIGHT xx的时候默认是YES(全部支持),否则默认为NO。

    - (CGFloat)swipeTableView:(SwipeTableView *)swipeTableView heightForRefreshHeaderAtIndex:(NSInteger)index

    返回对应item下拉刷新的临界高度,如果没有实现此代理,在设置#define ST_PULLTOREFRESH_HEADER_HEIGHT xx的时候默认是ST_PULLTOREFRESH_HEADER_HEIGHT的高度。如果没有设置宏,并且想要自定义修改下拉刷新,必须实现此代理,提供下拉刷新控件RefreshHeader的高度(RefreshHeader全部露出的高度),来通知SwipeTableView触发下拉刷新。

    2. 如果想要更好的扩展性,以及喜欢自己研究的同学,可以尝试修改或者自定义下拉控件来解决下拉刷新的兼容问题,同时这里提供一些思路:

    如果下拉刷新控件的frame是固定的(比如header的frame),这样可以在初始化下拉刷新的header或者在数据源的代理中重设下拉header的frame。

    获取下拉刷新的header,将header的frame的y值减去swipeHeaderViewswipeHeaderBar的高度和(或者重写RefreshHeader的setFrame方法),就可以消除itemView contentInsets顶部留白top值的影响(否则添加的下拉header是隐藏在底部的)。

    - (UIScrollView *)swipeTableView:(SwipeTableView *)swipeView viewForItemAtIndex:(NSInteger)index reusingView:(UIScrollView *)view {
    ...
    STRefreshHeader * header = scrollView.header;
    header.y = - (header.height + (swipeHeaderView.height + swipeHeaderBar.height));
    ...
    }


    or


    - (instancetype)initWithFrame:(CGRect)frame {
    ...
    STRefreshHeader * header = [STRefreshHeader headerWithRefreshingBlock:^(STRefreshHeader *header) {

    }];
    header.y = - (header.height + (swipeHeaderView.height + swipeHeaderBar.height));
    scrollView.header = header;
    ...
    }

    对于一些下拉刷新控件,RefreshHeader的frame设置可能会在layoutSubviews中,所以,对RefreshHeader frame的修改,需要等执行完layouSubviews之后,在 有效的方法 中操作,比如:

    - (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    STRefreshHeader * header = self.header;
    CGFloat orginY = - (header.height + self.swipeTableView.swipeHeaderView.height + self.swipeTableView.swipeHeaderBar.height);
    if (header.y != orginY) {
    header.y = orginY;
    }
    }

    如何判断下拉刷新的控件的frame是不是固定不变的呢?

    一是可以研究源码查看RefreshHeader的frame是否固定不变;另一个简单的方式是,在ScrollView的滚动代理中log RefreshHeader的frame(大部分的下拉控件的frame都是固定的)。

    如果使用的下拉刷新控件的frame是变化的(个人感觉极少数),那么只能更深层的修改下拉刷新控件或者自定义下拉刷新。也可以更直接的采用第一种设置宏的方式支持下拉刷新。

    混合模式(UItableView & UICollectionView & UIScrollView)


    1. Mode 1模式下,属于最基本的模式,可扩展性也是最强的,此时,支持UITableViewUICollectionViewUIScrollView如果,同时设置shouldAdjustContentSizeYES,实现自适应contentSize,在UICollectionView内容不足的添加下,只能使用STCollectionView及其子类

      UICollectionView不支持通过contentSize属性设置contentSize


    2. Mode 2模式下,SwipeTableView支持的collectionView必须是STCollectionView及其子类的实例,目前,不支持UIScrollView

    示例代码:

    初始化并设置header与bar

    self.swipeTableView = [[SwipeTableView alloc]initWithFrame:[UIScreen mainScreen].bounds];
    _swipeTableView.delegate = self;
    _swipeTableView.dataSource = self;
    _swipeTableView.shouldAdjustContentSize = YES;
    _swipeTableView.swipeHeaderView = self.tableViewHeader;
    _swipeTableView.swipeHeaderBar = self.segmentBar;

    实现数据源代理:

    - (NSInteger)numberOfItemsInSwipeTableView:(SwipeTableView *)swipeView {
    return 4;
    }

    - (UIScrollView *)swipeTableView:(SwipeTableView *)swipeView viewForItemAtIndex:(NSInteger)index reusingView:(UIScrollView *)view {
    UITableView * tableView = view;
    if (nil == tableView) {
    UITableView * tableView = [[UITableView alloc]initWithFrame:swipeView.bounds style:UITableViewStylePlain];
    tableView.backgroundColor = [UIColor whiteColor];
    ...
    }
    // 这里刷新每个item的数据
    [tableVeiw refreshWithData:dataArray];
    ...
    return tableView;
    }

    STCollectionView使用方法:

    MyCollectionView.h

    @interface MyCollectionView : STCollectionView

    @property (nonatomic, assign) NSInteger numberOfItems;
    @property (nonatomic, assign) BOOL isWaterFlow;

    @end



    MyCollectionView.m

    - (instancetype)initWithFrame:(CGRect)frame {

    self = [super initWithFrame:frame];
    if (self) {
    STCollectionViewFlowLayout * layout = self.st_collectionViewLayout;
    layout.minimumInteritemSpacing = 5;
    layout.minimumLineSpacing = 5;
    layout.sectionInset = UIEdgeInsetsMake(5, 5, 5, 5);
    self.stDelegate = self;
    self.stDataSource = self;
    [self registerClass:UICollectionViewCell.class forCellWithReuseIdentifier:@"item"];
    [self registerClass:UICollectionReusableView.class forSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:@"header"];
    [self registerClass:UICollectionReusableView.class forSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:@"footer"];
    }
    return self;
    }


    - (NSInteger)collectionView:(UICollectionView *)collectionView layout:(STCollectionViewFlowLayout *)layout numberOfColumnsInSection:(NSInteger)section {
    return _numberOfColumns;
    }

    - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    return CGSizeMake(0, 100);
    }

    - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout referenceSizeForHeaderInSection:(NSInteger)section {
    return CGSizeMake(kScreenWidth, 35);
    }

    - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout referenceSizeForFooterInSection:(NSInteger)section {
    return CGSizeMake(kScreenWidth, 35);
    }

    - (UICollectionReusableView *)stCollectionView:(UICollectionView *)collectionView viewForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath {
    UICollectionReusableView * reusableView = nil;
    if ([kind isEqualToString:UICollectionElementKindSectionHeader]) {
    reusableView = [collectionView dequeueReusableSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:@"header" forIndexPath:indexPath];
    // custom UI......
    }else if ([kind isEqualToString:UICollectionElementKindSectionFooter]) {
    reusableView = [collectionView dequeueReusableSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:@"footer" forIndexPath:indexPath];
    // custom UI......
    }
    return reusableView;
    }

    - (NSInteger)numberOfSectionsInStCollectionView:(UICollectionView *)collectionView {
    return _numberOfSections;
    }

    - (NSInteger)stCollectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return _numberOfItems;
    }

    - (UICollectionViewCell *)stCollectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewCell * cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"item" forIndexPath:indexPath];
    // do something .......
    return cell;
    }

    如果STCollectionViewFlowLayout已经不能满足UICollectionView的布局的话,用户自定义的flowlayout需要继承自STCollectionViewFlowLayout,并在重写相应方法的时候需要调用父类方法,并需要遵循一定规则,如下:

    - (void)prepareLayout {
    [super prepareLayout];
    // do something in sub class......
    }

    - (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
    NSArray * superAttrs = [super layoutAttributesForElementsInRect:rect];
    NSMutableArray * itemAttrs = [superAttrs mutableCopy];

    // filter subClassAttrs to rect
    NSArray * filteredSubClassAttrs = ........;

    [itemAttrs addObjectsFromArray:fittesSubClassAttrs];

    return itemAttrs;
    }

    - (CGSize)collectionViewContentSize {
    CGSize superSize = [super collectionViewContentSize];

    CGSize subClassSize = .......;
    subClassSize.height += superSize.height;

    // fit mincontentSize
    STCollectionView * collectionView = (STCollectionView *)self.collectionView;
    subClassSize.height = fmax(subClassSize.height, collectionView.minRequireContentSize.height);

    return subClassSize;
    }

    使用的详细用法在SwipeTableViewDemo文件夹中,提供了五种示例:

    • SingleOneKindView
      数据源提供的是单一类型的itemView,这里默认提供的是 CustomTableView (UITableView的子类),并且每一个itemView的数据行数有多有少,因此在滑动到数据少的itemView时,再次触碰界面,当前的itemView会有回弹的动作(由于contentSize小的缘故)。

    • HybridItemViews
      数据源提供的itemView类型是混合的,即 CustomTableView 与 CustomCollectionViewUICollectionView的子类)。

    • `AdjustContentSize` 自适应调整cotentOffszie属性,这里不同的itemView的数据行数有多有少,当滑动到数据较少的itemView时,再次触碰界面并不会导致当前itemView的回弹,这里当前数据少的itemView已经做了最小contentSize的设置。

      在0.2.3版本中去除了 demo 中的这一模块,默认除了`SingleOneKindView`模式下全部是自适应 contentSize。
    • DisabledBarScroll
      取消顶部控制条的跟随滚动,只有在swipeHeaderView是nil的条件下才能生效。这样可以实现一个类似网易新闻首页的滚动菜单列表的布局。

    • HiddenNavigationBar 隐藏导航。自定义了一个返回按钮(支持手势滑动返回)。

    • Demo支持添加移除header(定义的UIImageView)与bar(自定义的 CutomSegmentControl)的功能。

    • 示例代码新增点击图片全屏查看。

    • Demo中提供简单的自定义下拉刷新控件STRefreshHeader,供参考


      链接:https://github.com/Roylee-ML/SwipeTableView










    收起阅读 »

    iOS 图片浏览器 (支持视频)-YBImageBrowser

    iOS 图片浏览器,功能强大,易于拓展,性能优化和内存控制让其运行更加的流畅和稳健。一.特性支持 GIF,APNG,WebP 等本地和网络图片类型(由 YYImage、SDWebImage 提供支持)。支持系统相册图片和视频。支持简单的视频播放。支持高清图浏览...
    继续阅读 »

    iOS 图片浏览器,功能强大,易于拓展,性能优化和内存控制让其运行更加的流畅和稳健。

    一.特性

    • 支持 GIF,APNG,WebP 等本地和网络图片类型(由 YYImage、SDWebImage 提供支持)。
    • 支持系统相册图片和视频。
    • 支持简单的视频播放。
    • 支持高清图浏览。
    • 支持图片预处理(比如添加水印)。
    • 支持根据图片的大小判断是否需要预先解码(精确控制内存)。
    • 支持图片压缩、裁剪的界限设定。
    • 支持修改下载图片的 NSURLRequest。
    • 支持主动旋转或跟随控制器旋转。
    • 支持自定义图标。
    • 支持自定义 Toast/Loading。
    • 支持自定义文案(默认提供中文和英文)。
    • 支持自定义工具视图(比如查看原图功能)。
    • 支持自定义 Cell(比如添加一个广告模块)。
    • 支持添加到其它父视图上使用(比如加到控制器上)。
    • 支持转场动效、图片布局等深度定制。
    • 支持数据重载、局部更新。
    • 支持低粒度的内存控制和性能调优。
    • 极致的性能优化和严格的内存控制让其运行更加的流畅和稳健。

    二.安装

    CocoaPods

    支持分库导入,核心部分就是图片浏览功能,视频播放作为拓展功能按需导入。

    1.在 Podfile 中添加:

    pod 'YBImageBrowser'
    pod 'YBImageBrowser/Video' //视频功能需添加

    2.执行 pod install pod update

    3.导入 <YBImageBrowser/YBImageBrowser.h>,视频功能需导入<YBImageBrowser/YBIBVideoData.h>。

    4.注意:如果你需要支持 WebP,可以在 Podfile 中添加 pod 'YYImage/WebP'。

    若搜索不到库,可执行pod repo update,或使用 rm ~/Library/Caches/CocoaPods/search_index.json 移除本地索引然后再执行安装,或更新一下 CocoaPods 版本。

    去除 SDWebImage 的依赖(版本需 >= 3.0.4)

    Podfile 相应的配置变为:

    pod 'YBImageBrowser/NOSD'
    pod 'YBImageBrowser/VideoNOSD' //视频功能需添加

    这时你必须定义一个类实现YBIBWebImageMediator协议,并赋值给YBImageBrowser类的webImageMediator属性(可以参考 YBIBDefaultWebImageMediator的实现)。

    手动导入


    1. 下载 YBImageBrowser 文件夹所有内容并且拖入你的工程中,视频功能还需下载 Video 文件夹所有内容。

    2. 链接以下 frameworks:


    • SDWebImage

    • YYImage


    1. 导入 YBImageBrowser.h,视频功能需导入YBIBVideoData.h

    2. 注意:如果你需要支持 WebP,可以在 Podfile 中添加 pod 'YYImage/WebP',或者到手动下载 YYImage 仓库 webP 支持文件。

    用法


    初始化YBImageBrowser并且赋值数据源id<YBIBDataProtocol>,默认提供YBIBImageData (图片) 和YBIBVideoData (视频) 两种数据源。

    图片处理是组件的核心,笔者精力有限,视频播放做得很轻量,若有更高的要求最好是自定义 Cell,望体谅。

    Demo 中提供了很多示例代码,演示较复杂的拓展方式,所以若需要深度定制最好是下载 Demo 查看。

    建议不对YBImageBrowser进行复用,目前还存在一些逻辑漏洞。

    基本使用

    // 本地图片
    YBIBImageData *data0 = [YBIBImageData new];
    data0.imageName = ...;
    data0.projectiveView = ...;

    // 网络图片
    YBIBImageData *data1 = [YBIBImageData new];
    data1.imageURL = ...;
    data1.projectiveView = ...;

    // 视频
    YBIBVideoData *data2 = [YBIBVideoData new];
    data2.videoURL = ...;
    data2.projectiveView = ...;

    YBImageBrowser *browser = [YBImageBrowser new];
    browser.dataSourceArray = @[data0, data1, data2];
    browser.currentPage = ...;
    [browser show];

    设置支持的旋转方向

    当图片浏览器依托的 UIViewController 仅支持一个方向:

    这种情况通过YBImageBrowser.new.supportedOrientations设置图片浏览器支持的旋转方向。

    否则:

    上面的属性将失效,图片浏览器会跟随控制器的旋转而旋转,由于各种原因这种情况的旋转过渡有瑕疵,建议不使用这种方式。

    自定义图标

    修改YBIBIconManager.sharedManager实例的属性。

    自定义文案

    修改YBIBCopywriter.sharedCopywriter实例的属性。

    自定义 Toast / Loading

    实现YBIBAuxiliaryViewHandler协议,并且赋值给YBImageBrowser.new.auxiliaryViewHandler属性,可参考和协议同名的默认实现类。

    自定义工具视图(ToolView)

    默认实现的YBImageBrowser.new.defaultToolViewHandler处理器可以做一些属性配置,当满足不了业务需求时,最好是进行自定义,参考默认实现或 Demo 中“查看原图”功能实现。

    定义一个或多个类实现YBIBToolViewHandler协议,并且装入YBImageBrowser.new.toolViewHandlers数组属性。建议使用一个中介者来实现这个协议,然后所有的工具视图都由这个中介者来管理,当然也可以让每一个自定义的工具 UIView 都实现YBIBToolViewHandler协议,请根据具体需求取舍。

    自定义 Cell

    当默认提供的YBIBImageData (图片) 和YBIBVideoData (视频) 满足不了需求时,可自定义拓展 Cell,参考默认实现或 Demo 中的示例代码。

    定义一个实现YBIBCellProtocol协议的UICollectionViewCell类和一个实现YBIBDataProtocol协议的数据类,当要求不高时实现必选协议方法就能跑起来了,若对交互有要求就相对比较复杂,最好是参考默认的交互动效实现。

    在某些场景下,甚至可以直接继承项目中的 Cell 来做自定义。

    常见问题

    SDWebImage Pods 版本兼容问题

    SDWebImage 有两种情况会出现兼容问题:该库对 SDWebImage 采用模糊向上依赖,但将来 SDWebImage 可能没做好向下兼容;当其它库依赖 SDWebImage 更低或更高 API 不兼容版本。对于这种情况,可以尝试以下方式解决:

    • Podfile 中采用去除 SDWebImage 依赖的方式导入,只需要实现一个中介者(见安装部分)。
    • 更改其它库对 SDWebImage 的依赖版本。
    • 手动导入 YBImageBrowser,然后修改YBIBDefaultWebImageMediator文件。

    为什么不去除依赖 SDWebImage 自己实现?时间成本太高。 为什么不拖入 SDWebImage 修改类名?会扩大组件的体积,若外部有 SDWebImage 就存在一份多余代码。

    依赖的 YYImage 与项目依赖的 YYKit 冲突

    实际上 YYKit 有把各个组件拆分出来,建议项目中分开导入

    pod 'YYModel'
    pod 'YYCache'
    pod 'YYImage'
    pod 'YYWebImage'
    pod 'YYText'
    ...

    而且这样更灵活便于取舍。

    低内存设备 OOM 问题

    组件内部会降低在低内存设备上的性能,减小内存占用,但若高清图过多,可能需要手动去控制(以下是硬件消耗很低的状态):

    YBIBImageData *data = YBIBImageData.new;
    // 取消预解码
    data.shouldPreDecodeAsync = NO;
    // 直接设大触发裁剪比例,绘制更小的裁剪区域压力更小,不过可能会让用户感觉奇怪,放很大才开始裁剪显示高清局部(这个属性很多时候不需要显式设置,内部会动态计算)
    data.cuttingZoomScale = 10;

    YBImageBrowser *browser = YBImageBrowser.new;
    // 调低图片的缓存数量
    browser.ybib_imageCache.imageCacheCountLimit = 1;
    // 预加载数量设为 0
    browser.preloadCount = 0;

    视频播放功能简陋

    关于大家提的关于视频的需求,有些成本过高,笔者精力有限望体谅。若组件默认的视频播放器满足不了需求,就自定义一个 Cell 吧,把成熟的播放器集成到组件中肯定更加的稳定

    链接:https://github.com/indulgeIn/YBImageBrowser


    收起阅读 »

    从精准化测试看ASM在Android中的强势插入-字节码

    从精准化测试看ASM在Android中的强势插入-字节码字节码是ASM的基础,要想熟练的使用ASM,那么了解字节码就是必备基础。Class的文件格式Class文件作为Java虚拟机所执行的直接文件,内部结构设计有着固定的协议,每一个Class文件只对应一个类或...
    继续阅读 »

    从精准化测试看ASM在Android中的强势插入-字节码

    字节码是ASM的基础,要想熟练的使用ASM,那么了解字节码就是必备基础。

    Class的文件格式

    Class文件作为Java虚拟机所执行的直接文件,内部结构设计有着固定的协议,每一个Class文件只对应一个类或接口的定义信息。

    每个Class文件都以8位为单位的字节流组成,下面是一个Class文件中所包括的内容,在Class文件中,各项内容按照严格顺序连续存放,Java虚拟机只要按照协议顺序来读取即可。

    ClassFile { 
    u4 magic;
    u2 minor_version;
    u2 major_version;
    u2 constant_pool_count;
    cp_info constant_pool[constant_pool_count-1];
    u2 access_flags;
    u2 this_class;
    u2 super_class;
    u2 interfaces_count;
    u2 interfaces[interfaces_count];
    u2 fields_count;
    field_info fields[fields_count];
    u2 methods_count;
    method_info methods[methods_count];
    u2 attributes_count;
    attribute_info attributes[attributes_count];
    }

    在Class文件结构中,上面各项的含义如下。

    Name含义
    magic作为一个魔数,确定这个文件是否是一个能被虚拟机接受的class文件,值固定为0xCAFEBABE。
    minor_version,major_version分别表示class文件的副,主版本号,不同版本的虚拟机实现支持的Class文件版本号不同。
    constant_pool_count常量池计数器,constant_pool_count的值等于常量池表中的成员数加1。
    constant_pool常量池,constant_pool是一种表结构,包含class文件结构及其子结构中引用的所有字符常量、类或接口名、字段名和其他常量。
    access_flagsaccess_flags是一种访问标志,表示这个类或者接口的访问权限及属性,包括有ACC_PUBLIC,ACC_FINAL,ACC_SUPER等等。
    this_class类索引,指向常量池表中项的一个索引。
    super_class父类索引,这个值必须为0或者是对常量池中项的一个有效索引值,如果为0,表示这个class只能是Object类,只有它是唯一没有父类的类。
    interfaces_count接口计算器,表示当前类或者接口的直接父接口数量。
    interfaces[]接口表,里面的每个成员的值必须是一个对常量池表中项的一个有效索引值。
    fields_count字段计算器,表示当前class文件中fields表的成员个数,每个成员都是一个field_info。
    fields字段表,每个成员都是一个完整的fields_info结构,表示当前类或接口中某个字段的完整描述,不包括父类或父接口的部分。
    methods_count方法计数器,表示当前class文件methos表的成员个数。
    methods方法表,每个成员都是一个完整的method_info结构,可以表示类或接口中定义的所有方法,包括实例方法,类方法,以及类或接口初始化方法。
    attributes_count属性表,其中是每一个attribute_info,包含以下这些属性,InnerClasses,EnclosingMethod,Synthetic,Signature,Annonation等。

    以上内容来自网络,我也不知道从哪copy来的。

    字节码和Java代码还是有很大区别的。

    • 一个字节码文件只能描述一个类,而一个Java文件中可以则包含多个类。当一个Java文件是描述一个包含内部类的类,那么该Java文件则会被编译为两个类文件,文件名上通过「$」来区分,主类文件中包含对其内部类的引用,定义了内部方法的内部类会包含外部引用
    • 字节码文件中不包含注释,只有有效的可执行代码,例如类、字段、方法和属性
    • 字节码文件中不包含package和import部分, 所有类型名字都必须是完全限定的
    • 字节码文件还包含常量池(constant pool),这些内容是编译时生成的,常量池本质上就是一个数组存储了类中出现的所有数值、字符串和类型常量,这些常量仅需要在这个常量池部分中定义一次,就可以利用其索引,在类文件中的所有其他各部分进行引用

    字节码的执行过程

    字节码在Java虚拟机中是以堆栈的方式进行运算的,类似CPU中的寄存器,在Java虚拟机中,它使用堆栈来完成运算,例如实现「a+b」的加法操作,在Java虚拟机中,首先会将「a」push到堆栈中,然后再将「b」push到堆栈中,最后执行「ADD」指令,取出用于计算的两个变量,完成计算后,将返回值「a+b」push到堆栈中,完成指令。

    类型描述符

    我们在Java代码中的类型,在字节码中,有相应的表示协议。

    Java TypeType description
    booleanZ
    charC
    byteB
    shortS
    intI
    floatF
    longJ
    doubleD
    objectLjava/lang/Object;
    int[][I
    Object[][][[Ljava/lang/Object;
    voidV
    引用类型L
    • Java基本类型的描述符是单个字符,例如Z表示boolean、C表示char
    • 类的类型的描述符是这个类的全限定名,前面加上字符L , 后面跟上一个「;」,例如String的类型描述符为Ljava/lang/String;
    • 数组类型的描述符是一个方括号后面跟有该数组元素类型的描述符,多维数组则使用多个方括号

    借助上面的协议分析,想要看到字节码中参数的类型,就比较简单了。

    方法描述符

    方法描述符(方法签名)是一个类型描述符列表,它用一个字符串描述一个方法的参数类型和返回类型。

    方法描述符以左括号开头,然后是每个形参的类型描述符,然后是是右括号,接下来是返回类型的类型描述符,例如,该方法返回void,则是V,要注意的是,方法描述符中不包含方法的名字或参数名。

    Java方法声明方法描述符说明
    void m(int i, float f)(IF)V接收一个int和float型参数且无返回值
    int m(Object o)(Ljava/lang/Object;)I接收Object型参数返回int
    int[] m(int i, String s)(ILjava/lang/String;)[I接受int和String返回一个int[]
    Object m(int[] i)([I)Ljava/lang/Object;接受一个int[]返回Object

    字节码示例

    我们来看下这段简单的代码,在字节码下是怎样的。

    image-20210623103259980

    通过ASMPlugin,我们看下生成的字节码,如下所示。

    image-20210623103419893

    可以发现,这里主要分成了两个部分——init和onCreate。

    Java中的每一个方法在执行的时候,Java虚拟机都会为其分配一个「栈帧」,栈帧是用来存储方法中计算所需要的所有数据的。

    其中第0个元素就是「this」,如果方法有参数传入会排在它的后面。

    字节码中有很多指令,下面对一些比较常用的指令进行下讲解。

    • ALOAD 0:这个指令是LOAD系列指令中的一个,它的意思表示push当前第0个元素到堆栈中。代码上相当于使用「this」,A表示这个数据元素的类型是一个引用类型。类似的指令还有:ALOAD,ILOAD,LLOAD,FLOAD,DLOAD,它们的作用就是针对不用数据类型而准备的LOAD指令
    • INVOKESPECIAL:这个指令是调用系列指令中的一个。其目的是调用对象类的方法。后面需要给上父类的方法完整签
    • INVOKEVIRTUAL:这个指令区别于INVOKESPECIAL的是,它是根据引用调用对象类的方法
    • INVOKESTATIC:调用类的静态方法

    大家不用完全掌握这些指令,结合代码来看的话,还是能看懂的,我们需要的是修改字节码,而不是从0开始。

    对于Java源文件:如果只有一个方法,编译生成时,也会有两个方法,其中一个是默认构造函数 对于Kotlin源文件:如果只有一个方法,编译生成时,会产生四个方法,一个是默认构造函数,还有两个是kotlin合成的方法,以及退出时清除内存的默认函数

    ASM Code

    再结合ASM Code来看,还是上面的例子。

    默认的构造函数。

    image-20210623105109646

    onCreate:

    image-20210623105143214

    这里面有些生成的代码,例如:

    Label label0 = new Label();
    methodVisitor.visitLabel(label0);
    methodVisitor.visitLineNumber(9, label0);
    methodVisitor.visitLocalVariable("this", "Lcom/yw/asmtest/MainActivity;", null, label0, label4, 0);

    这些都是调试代码和写入变量表的方法,我们不必关心。

    剩下的代码,就是我们可以在ASM中所需要的代码。

    收起阅读 »

    Android基础到进阶UI CheckedTextView 使用+实例

    CheckedTextView是什么 CheckedTextView继承自TextView且实现了Checkable接口,对TextView界面和显示进行了扩展的控件,支持Checkable。可以实现 单选或多选功能,在你懒得使用两者结合的时候,这就是不二选...
    继续阅读 »

    CheckedTextView是什么


    CheckedTextView继承自TextView且实现了Checkable接口,对TextView界面和显示进行了扩展的控件,支持Checkable。可以实现 单选多选功能,在你懒得使用两者结合的时候,这就是不二选择。


    主要XML属性如下:


    android:checkMark 按钮样式。



    • 默认单选框样式:android:checkMark="?android:attr/listChoiceIndicatorSingle"

    • 默认复选框样式:android:checkMark="?android:attr/listChoiceIndicatorMultiple"

    • 当然也可以使用drawable自定义样式


    android:checkMarkTint 按钮的颜色。


    android:checkMarkTintMode 混合模式按钮的颜色。


    android:checked 初始选中状态,默认false。


    在点击事件里判断状态设置状态


    CheckedTextView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            CheckedTextView.toggle();//切换选中与非选中状态
        }
    });

    咱们看看CheckedTextView.toggle()是干嘛的


    public void toggle() {
        setChecked(!mChecked);
    }

    就是实现这个控件的状态反设置。


    第一次点击无效


    android:focusableInTouchMode="true",这个属性加上会导致第一次点击触发不了选择事件。


    实例


    官方文档指出,「结合ListView使用更佳」,咱下面通过一个栗子了解一下,下面是效果图:



    1.主界面CheckedTextViewActivity.java


    public class CheckedTextViewActivity extends AppCompatActivity {
        private ListView lv_ctv_multiple,lv_ctv_single;
        private CtvMultipleAdapter ctvAdapter;
        private TextView tv_multiple_title,tv_single_title;
        private CtvSingleAdapter ctvSingleAdapter;
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_textview_ctv);//加载布局文件
            initView();
        }
        private void initView() {
            ArrayList<String> ctvString = new ArrayList<>();
            ctvString.add("秦始皇嬴政");
            ctvString.add("汉高祖刘邦");
            ctvString.add("唐太宗李世民");
            ctvString.add("宋太祖赵匡胤");
            //复选
            lv_ctv_multiple = findViewById(R.id.lv_ctv_multiple);
            tv_multiple_title = findViewById(R.id.tv_multiple_title);
            ctvAdapter = new CtvMultipleAdapter(this,ctvString,tv_multiple_title);
            lv_ctv_multiple.setAdapter(ctvAdapter);
            //设置Item间距
            lv_ctv_multiple.setDividerHeight(0);

            //单选
            lv_ctv_single = findViewById(R.id.lv_ctv_single);
            tv_single_title = findViewById(R.id.tv_single_title);
            ctvSingleAdapter = new CtvSingleAdapter(this,ctvString,tv_single_title);
            lv_ctv_single.setAdapter(ctvSingleAdapter);
            //设置Item间距
            lv_ctv_single.setDividerHeight(0);

        }
    }

    2.主布局activity_textview_ctv.xml


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

        <TextView
            android:id="@+id/tv_multiple_title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="@dimen/dimen_10"
            android:textColor="@color/black"
            android:text="复选"
            android:textSize="@dimen/text_size_16" />

        <ListView
            android:id="@+id/lv_ctv_multiple"
            android:layout_width="match_parent"
            android:layout_height="180dp" />


        <TextView
            android:id="@+id/tv_single_title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="@dimen/dimen_10"
            android:text="单选"
            android:textColor="@color/color_188FFF"
            android:layout_marginTop="@dimen/dimen_10"
            android:textSize="@dimen/text_size_20" />

        <ListView
            android:id="@+id/lv_ctv_single"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </LinearLayout>

    3.复选框Adapter


    public class CtvMultipleAdapter extends BaseAdapter {
        private LayoutInflater mInflater;//得到一个LayoutInfalter对象用来导入布局
        private List<String> list;
        private TextView tvTitle;
        private List<String> selectList = new ArrayList<>();
        public CtvMultipleAdapter(Context context, List<String> list, TextView tv) {
            this.mInflater = LayoutInflater.from(context);
            this.list = list;
            tvTitle = tv;
        }

        @Override
        public int getCount() {
            return list.size();
        }

        @Override
        public Object getItem(int position) {
            return null;
        }

        @Override
        public long getItemId(int position) {
            return 0;
        }

        @Override
        public View getView(final int position, View convertView, ViewGroup parent) {
            final CtvViewHolder holder;
            final String string = list.get(position);
            //观察convertView随ListView滚动情况
            if (convertView == null) {
                convertView = mInflater.inflate(R.layout.item_ctv_multiple, null);
                holder = new CtvViewHolder();
                /*得到各个控件的对象*/
                holder.ctv_top = (CheckedTextView) convertView.findViewById(R.id.ctv_top);
                convertView.setTag(holder);//绑定ViewHolder对象
            } else {
                holder = (CtvViewHolder) convertView.getTag();//取出ViewHolder对象
            }
            holder.ctv_top.setText(string);
            //默认选中状态
            if(holder.ctv_top.isChecked()){
                //list未包含选中string;
                if(!selectList.contains(string)){
                    selectList.add(string);
                }
            }
            if (selectList.size() == 0) {
                tvTitle.setText("");
            } else {
                tvTitle.setText(selectList.toString());
            }

            holder.ctv_top.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    holder.ctv_top.toggle();//切换选中与非选中状态
                    //单选
                    if(holder.ctv_top.isChecked()){//
                        //list未包含选中string;
                        if(!selectList.contains(string)){
                            selectList.add(string);
                        }
                    }else{
                        //list未包含选中string;
                        if(selectList.contains(string)){
                            selectList.remove(string);
                        }
                    }
                    if (selectList.size() == 0) {
                        tvTitle.setText("");
                    } else {
                        tvTitle.setText(selectList.toString());
                    }

                }
            });
            return convertView;
        }
        /*存放控件*/
        public class CtvViewHolder {
            public CheckedTextView ctv_top;
        }

        @Override
        public boolean areAllItemsEnabled() {
            return false;//Item不可点击
        }

        @Override
        public boolean isEnabled(int position) {
            return false;//Item不可点击
            // 拦截事件交给上一级处理
            //return super.isEnabled(position);
        }

    }

    4.复选框adapter对应布局


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

        <CheckedTextView
            android:id="@+id/ctv_top"
            android:checked="true"
            android:checkMark="?android:attr/listChoiceIndicatorMultiple"
            android:checkMarkTint="@color/color_FF773D"
            android:padding="10dp"
            android:textSize="16sp"
            android:layout_marginTop="3dp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

    </LinearLayout>

    这里用到checkMark(默认复选框样式)、checkMarkTint(复选框颜色设为黄色)、和checked(true默认选中)几个属性,可以更好的理解他们。


    5.单选框adapter


      private String selectStr="";//全局变量
            holder.ctv_top.setText(string);
            holder.ctv_top.setChecked(selectStr.equals(string));
            holder.ctv_top.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    holder.ctv_top.toggle();//切换选中与非选中状态
                    //单选
                    if(holder.ctv_top.isChecked()){
                        selectStr=string;
                    }else{
                        selectStr="";
                    }
                    tvTitle.setText(selectStr);
                    notifyDataSetChanged();
                }
            });

    大部分与复选框CtvMultipleAdapter设置相同,仅部分不同就不做多重复了。


    6.单选框adapter对应布局


     <CheckedTextView
            android:id="@+id/ctv_top"
            android:checkMark="?android:attr/listChoiceIndicatorSingle"
            android:padding="10dp"
            android:textSize="16sp"
            android:layout_marginTop="3dp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />


    仅使用单选默认样式。


    7.逻辑处理从adapter放在主界面处理


      ListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
                @Override
                public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                    //在这里进行单选复选的逻辑处理
                }
            });

    使用CheckedTextView配合ListView实现单选与多选的功能我们实现了。到这里,关于CheckedTextView我们也就介绍完了,嘿嘿。



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

    Binder概述,快速了解Binder体系

    前言 众所周知,Binder是Android系统中最主要的进程间通信套件,更具体一点,很多文章称之为Binder驱动,那为什么说它是一个驱动呢,驱动又是何物,让我们自底向上,从内核中的Binder来一步步揭开它的面纱。本文重点在帮助读者对于Binder系统有...
    继续阅读 »

    前言


    众所周知,Binder是Android系统中最主要的进程间通信套件,更具体一点,很多文章称之为Binder驱动,那为什么说它是一个驱动呢,驱动又是何物,让我们自底向上,从内核中的Binder来一步步揭开它的面纱。本文重点在帮助读者对于Binder系统有一个简略的了解,所以写得比较笼统,后续文章会详细分析。


    Binder到底是什么


    Android系统内核是Linux,每个进程有自己的虚拟地址空间,在32位系统下最大是4GB,其中3GB为用户空间,1GB为内核空间;每个进程用户空间相对独立,而内核空间是一样的,可以共享,如下图


    地址空间.png


    Linux驱动运行在内核空间,狭义上讲是系统用于控制硬件的中间程序,但是归根结底它只是一个程序一段代码,所以具体实现并不一定要和硬件有关。Binder就是将自己注册为一个misc类型的驱动,不涉及硬件操作,同时自身运行于内核中,所以可以当作不同进程间的桥梁实现IPC功能。


    Linux最大的特点就是一切皆文件,驱动也不例外,所有驱动都会被挂载在文件系统dev目录下,Binder对应的目录是/dev/binder,注册驱动时将open release mmap等系统调用注册到Binder自己的函数,这样的话在用户空间就可以通过系统调用以访问文件的方式使用Binder。下面来粗略看一下相关代码。


    device_initcall函数用于注册驱动,由系统调用


    binder_init中调用misc_register注册一个名为binder的misc驱动,同时指定函数映射,将binder_open映射到系统调用open,这样就可以通过open("/dev/binder")来调用binder_open函数了


    drivers/android/binder.c


    // 驱动函数映射
    static const struct file_operations binder_fops = {
    .owner = THIS_MODULE,
    .poll = binder_poll,
    .unlocked_ioctl = binder_ioctl,
    .compat_ioctl = binder_ioctl,
    .mmap = binder_mmap,
    .open = binder_open,
    .flush = binder_flush,
    .release = binder_release,
    };

    // 注册驱动参数结构体
    static struct miscdevice binder_miscdev = {
    .minor = MISC_DYNAMIC_MINOR,
    // 驱动名称
    .name = "binder",
    .fops = &binder_fops
    };

    static int binder_open(struct inode *nodp, struct file *filp){......}
    static int binder_mmap(struct file *filp, struct vm_area_struct *vma){......}

    static int __init binder_init(void)
    {
    int ret;
    // 创建名为binder的单线程的工作队列
    binder_deferred_workqueue = create_singlethread_workqueue("binder");
    if (!binder_deferred_workqueue)
    return -ENOMEM;
    ......
    // 注册驱动,misc设备其实也就是特殊的字符设备
    ret = misc_register(&binder_miscdev);
    ......
    return ret;
    }
    // 驱动注册函数
    device_initcall(binder_init);

    Binder的简略通讯过程


    一个进程如何通过binder和另一个进程通讯?最简单的流程如下



    1. 接收端进程开启一个专门的线程,通过系统调用在binder驱动(内核)中先注册此进程(创建保存一个bidner_proc),驱动为接收端进程创建一个任务队列(biner_proc.todo)

    2. 接收端线程开始无限循环,通过系统调用不停访问binder驱动,如果该进程对应的任务队列有任务则返回处理,否则阻塞该线程直到有新任务入队

    3. 发送端也通过系统调用访问,找到目标进程,将任务丢到目标进程的队列中,然后唤醒目标进程中休眠的线程处理该任务,即完成通讯


    在Binder驱动中以binder_proc结构体代表一个进程,binder_thread代表一个线程,binder_proc.todo即为进程需要处理的来自其他进程的任务队列


    struct binder_proc {
    // 存储所有binder_proc的链表
    struct hlist_node proc_node;
    // binder_thread红黑树
    struct rb_root threads;
    // binder_proc进程内的binder实体组成的红黑树
    struct rb_root nodes;
    ......
    }

    Binder的一次拷贝


    众所周知Binder的优势在于一次拷贝效率高,众多博客已经说烂了,那么什么是一次拷贝,如何实现,发生在哪里,这里尽量简单地解释一下。


    上面已经说过,不同进程通过在内核中的Binder驱动来进行通讯,但是用户空间和内核空间是隔离开的,无法互相访问,他们之间传递数据需要借助copy_from_user和copy_to_user两个系统调用,把用户/内核空间内存中的数据拷贝到内核/用户空间的内存中,这样的话,如果两个进程需要进行一次单向通信则需要进行两次拷贝,如下图。


    2copy.png


    Binder单次通信只需要进行一次拷贝,因为它使用了内存映射,将一块物理内存(若干个物理页)分别映射到接收端用户空间和内核空间,达到用户空间和内核空间共享数据的目的。


    发送端要向接收端发送数据时,内核直接通过copy_from_user将数据拷贝到内核空间映射区,此时由于共享物理内存,接收进程的内存映射区也就能拿到该数据了,如下图。


    binder_mmap.png


    代码实现部分


    用户空间通过mmap系统调用,调用到Binder驱动中binder_mmap函数进行内存映射,这部分代码比较难读,感兴趣的可以看一下。


    drivers/android/binder.c


    binder_mmap创建binder_buffer,记录进程内存映射相关信息(用户空间映射地址,内核空间映射地址等)


    static int binder_mmap(struct file *filp, struct vm_area_struct *vma)
    {
    int ret;
    //内核虚拟空间
    struct vm_struct *area;
    struct binder_proc *proc = filp->private_data;
    const char *failure_string;
    // 每一次Binder传输数据时,都会先从Binder内存缓存区中分配一个binder_buffer来存储传输数据
    struct binder_buffer *buffer;

    if (proc->tsk != current)
    return -EINVAL;
    // 保证内存映射大小不超过4M
    if ((vma->vm_end - vma->vm_start) > SZ_4M)
    vma->vm_end = vma->vm_start + SZ_4M;
    ......
    // 采用IOREMAP方式,分配一个连续的内核虚拟空间,与用户进程虚拟空间大小一致
    // vma是从用户空间传过来的虚拟空间结构体
    area = get_vm_area(vma->vm_end - vma->vm_start, VM_IOREMAP);
    if (area == NULL) {
    ret = -ENOMEM;
    failure_string = "get_vm_area";
    goto err_get_vm_area_failed;
    }
    // 指向内核虚拟空间的地址
    proc->buffer = area->addr;
    // 用户虚拟空间起始地址 - 内核虚拟空间起始地址
    proc->user_buffer_offset = vma->vm_start - (uintptr_t)proc->buffer;
    ......
    // 分配物理页的指针数组,数组大小为vma的等效page个数
    proc->pages = kzalloc(sizeof(proc->pages[0]) * ((vma->vm_end - vma->vm_start) / PAGE_SIZE), GFP_KERNEL);
    if (proc->pages == NULL) {
    ret = -ENOMEM;
    failure_string = "alloc page array";
    goto err_alloc_pages_failed;
    }
    proc->buffer_size = vma->vm_end - vma->vm_start;

    vma->vm_ops = &binder_vm_ops;
    vma->vm_private_data = proc;
    // 分配物理页面,同时映射到内核空间和进程空间,先分配1个物理页
    if (binder_update_page_range(proc, 1, proc->buffer, proc->buffer + PAGE_SIZE, vma)) {
    ret = -ENOMEM;
    failure_string = "alloc small buf";
    goto err_alloc_small_buf_failed;
    }
    buffer = proc->buffer;
    // buffer插入链表
    INIT_LIST_HEAD(&proc->buffers);
    list_add(&buffer->entry, &proc->buffers);
    buffer->free = 1;
    binder_insert_free_buffer(proc, buffer);
    // oneway异步可用大小为总空间的一半
    proc->free_async_space = proc->buffer_size / 2;
    barrier();
    proc->files = get_files_struct(current);
    proc->vma = vma;
    proc->vma_vm_mm = vma->vm_mm;

    /*pr_info("binder_mmap: %d %lx-%lx maps %p\n",
    proc->pid, vma->vm_start, vma->vm_end, proc->buffer);*/

    return 0;
    }

    binder_update_page_range 函数为映射地址分配物理页,这里先分配一个物理页(4KB),然后将这个物理页同时映射到用户空间地址和内存空间地址


    static int binder_update_page_range(struct binder_proc *proc, int allocate,
    void *start, void *end,
    struct vm_area_struct *vma)

    {
    // 内核映射区起始地址
    void *page_addr;
    // 用户映射区起始地址
    unsigned long user_page_addr;
    struct page **page;
    // 内存结构体
    struct mm_struct *mm;

    if (end <= start)
    return 0;
    ......
    // 循环分配所有物理页,并分别建立用户空间和内核空间对该物理页的映射
    for (page_addr = start; page_addr < end; page_addr += PAGE_SIZE) {
    int ret;
    page = &proc->pages[(page_addr - proc->buffer) / PAGE_SIZE];

    BUG_ON(*page);
    // 分配一页物理内存
    *page = alloc_page(GFP_KERNEL | __GFP_HIGHMEM | __GFP_ZERO);
    if (*page == NULL) {
    pr_err("%d: binder_alloc_buf failed for page at %p\n",
    proc->pid, page_addr);
    goto err_alloc_page_failed;
    }
    // 物理内存映射到内核虚拟空间
    ret = map_kernel_range_noflush((unsigned long)page_addr,
    PAGE_SIZE, PAGE_KERNEL, page);
    flush_cache_vmap((unsigned long)page_addr,
    // 用户空间地址 = 内核地址+偏移
    user_page_addr =
    (uintptr_t)page_addr + proc->user_buffer_offset;
    // 物理空间映射到用户虚拟空间
    ret = vm_insert_page(vma, user_page_addr, page[0]);
    }
    }

    binder_mmap函数中调用binder_update_page_range只为映射区分配了一个物理页的空间,在Binder开始通讯时,会再通过binder_alloc_buf函数分配更多物理页,这是后话了。


    Binder套件架构


    内核层的Binder驱动已经提供了IPC功能,不过还需要在framework native层提供一些对于驱动层的调用封装,使framework开发者更易于使用,由此封装出了native Binder;同时,由于framework native层是c/c++语言实现,对于应用开发者,需要更加方便的Java层的封装,衍生出Java Binder;最后在此之上,为了减少重复代码的编写和规范接口,在Java Binder的基础上又封装出了AIDL。经过层层封装,在使用者使用AIDL时对于Binder基本上是无感知的。


    这里贴一张架构图。


    binder架构.png


    Native层


    BpBinder代表服务端Binder的一个代理,内部有一个成员mHandle就是服务端Binder在驱动层的句柄,客户端通过调用BpBinder::transact传入该句柄,经过驱动层和服务端BBinder产生会话,最后服务端会调用到BBinder::onTransact。在这里两者之间通过约定好的code来标识会话内容。


    前面提到过,需要用Binder进行通信的进程都需要在驱动中先注册该进程,并且每次通讯时需要一个线程死循环读写binder驱动。驱动层中一个进程对应一个binder_proc,一个线程对应binder_thread;而在framework native层中,进程对应一个ProcessState,线程对应IPCThreadStateBpBinder::transact发起通讯最终也是通过IPCThreadState.transact调用驱动进行。


    实际上Android中每个应用进程都打开了Binder驱动(在驱动中注册),Zygote进程在fork出应用进程后,调用app_main.cpp中onZygoteInit函数初始化,此函数中就创建了该进程的ProcessState实例,打开Binder驱动然后分配映射区,驱动中也创建并保存一个该进程的binder_proc实例。这里借一张图来描述。


    Java层


    Java层是对native层相关类的封装,BBinder对应Binder,BpBinder对应BinderProxy,java层最后还是会调用到native层对应函数


    AIDL


    AIDL生成的代码对于Binder进行了进一步封装,<接口>.Stub对应服务端Binder,<接口>.Stub.Proxy标识客户端,内部持有一个mRemote实例(BinderProxy),aidl根据定义的接口方法生成若干个TRANSACTION_<函数名> code常量,两端Binder通过这些code标识解析参数,调用相应接口方法。换言之AIDL就是对BinderProxy.transactBinder.onTransact进行了封装,使用者不必再自己定义每次通讯的code以及参数解析


    后记


    本篇文章主要为不了解Binder体系的读者提供一个笼统的认识,接下来的文章会从AIDL远程服务开始层层向下分析整个IPC过程,所以如果想要更深一步了解Binder,本文作为前置知识也比较重要。


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

    手把手教你 Debug — iOS 14 ImageIO Crash 分析

    背景去年 9 月份开始,许多用户升级到 iOS 14 之后,线上出现很多 ImageIO 相关堆栈的 Crash 问题,而且公司内几乎所有的 APP 上都有出现,在部分 APP上甚至达到了 Top 3  Crash。得益于 APM 平台精准数据采集机...
    继续阅读 »

    背景

    去年 9 月份开始,许多用户升级到 iOS 14 之后,线上出现很多 ImageIO 相关堆栈的 Crash 问题,而且公司内几乎所有的 APP 上都有出现,在部分 APP上甚至达到了 Top 3  Crash。

    得益于 APM 平台精准数据采集机制和丰富的异常信息现场,我们通过收集到详细的 Crash 日志信息进行分析解决。

    问题定位

    堆栈信息

    从堆栈信息看,是在 ImageIO 解析图片信息的时候 Crash ,并且最后调用的方法都是看起来都是和 INameSpacePrefixMap 相关,推测 Crash 应该是和这个方法 CGImageSourceCopyPropertiesAtIndex 的实现有关。




    • 从堆栈信息看,这段代码是图片库在子线程通过 CGImageSourceCopyPropertiesAtIndex 解析 imageSource 中的图片相关信息,然后发生了野指针的 Crash。

    • CGImageSourceCopyPropertiesAtIndex 的输入只有一个 imageSourceimageSource 由图片的 data 生成,调用栈并没有多线程操作,可以排除是多线程操作 imageSource、data 导致的 Crash。

    • 看堆栈是在解析 PNG 图片,通过将下发的图片格式换成 JPG 格式,发现量级并没有降低。推测 Crash 不是某种特定图片格式引起的。

    反汇编分析

    反汇编准备

    • iOS 14.3 的 iPhone 8
    • ImageIO 系统库:~/Library/Developer/Xcode/iOS DeviceSupport目录下找到对应 iOS 14.3 的 ImageIO
    • 一份 iOS 14.3、iPhone 8 上发生的  CrashLog
    • Hopper

    反汇编

    1、从 CrashLog 上找到 Crash 对应的指令偏移地址 2555072


    2、通过 Hopper 打开 ImageIO,跳转到指令偏移地址 2555072

    Navigate => Go To File Offset 2555072


    3、Crash 对应的指令应该是0000000181b09cc0 ldr x8, [x8, #0x10],可以看到应该是访问 [x8, #0x10]指向的内存出错


    5、向上回溯查看 x8 的来源

    • 0000000181b09cbc ldr x8, [x20] x8 是存在 x20 指向的内存中(即 x8 = *x20

    • 0000000181b09c98 ldr x20, [x21, #0x8] x20 又存在[x21, #0x8] 指向的内存中

    • 0000000181b09c8c adrp x21, #0x1da0ed0000000000181b09c90 add x21, x21, #0xe10 x21 指向的是一个 data 段,推测 x21 应该是一个全局变量,所以,可能是这个全局变量野了,或者是这个全局变量引用的某些内存(x20)野了

    6、运行时 debug 查看 x8、x20、x21 对应寄存器的值是什么

    • x21 从内存地址的名字看,应该是一个全局的 Map


    8、经过在运行时反复调试,这个

    AdobeXMPCore_Int::ManageDefaultNameSpacePrefixMap(bool) 会在多个方法中调用(并且调用时都加了锁,不太可能会出现 data race):

    • AdobeXMPCore_Int::INameSpacePrefixMap_I::CreateDefaultNameSpacePrefixMap()

    • AdobeXMPCore_Int::INameSpacePrefixMap_I::InsertInDefaultNameSpacePrefixMap(char const*, unsigned long long, char const*, unsigned long long)

    • AdobeXMPCore_Int::INameSpacePrefixMap_I::DestroyDefaultNameSapcePrefixMap()



    9、在后台线程访问访问全局变量 sDefaultNameSpacePrefixMap 时 Crash,推测可能是用户手动杀进程后,全局变量在主线程已经被析构,后台线程还会继续访问这个全局变量,从而出现野指针访问异常。发现 Crash 日志的主线程堆栈也出现 _exit 的调用,可以确定是全局变量析构导致。


    Crash 发生的原因:

    在用户手动杀进程后,主线程将这个全局变量析构了,这时候子线程再访问这个全局变量就出现了野指针。


    复现问题

    尝试在子线程不断调用 CFDictionaryRef CGImageSourceCopyPropertiesAtIndex(CGImageSourceRef isrc, size_t index, CFDictionaryRef options);,并且手动杀掉进程触发这个 crash



    可以证明上述的推理是正确的。

    总结

    • CFDictionaryRef CGImageSourceCopyPropertiesAtIndex(CGImageSourceRef isrc, size_t index, CFDictionaryRef options); 这个方法在解析部分图片的时候最终会访问全局变量

    收起阅读 »

    Objective-C & Swift 最轻量级 Hook 方案-SDMagicHook

    本文从一个 iOS 日常开发的 hook 案例入手,首先简要介绍了 Objective-C 的动态特性以及传统 hook 方式常见的命名冲突、操作繁琐、hook 链意外断裂、hook 作用范围不可控制等缺陷,然后详细介绍了一套基于消息转发机制的 instanc...
    继续阅读 »

    本文从一个 iOS 日常开发的 hook 案例入手,首先简要介绍了 Objective-C 的动态特性以及传统 hook 方式常见的命名冲突、操作繁琐、hook 链意外断裂、hook 作用范围不可控制等缺陷,然后详细介绍了一套基于消息转发机制的 instance 粒度的轻量级 hook 方案:SDMagicHook


    背景

    某年某月的某一天,产品小 S 向开发君小 Q 提出了一个简约而不简单的需求:扩大一下某个 button 的点击区域。小 Q 听完暗自窃喜:还好,这是一个我自定义的 button,只需要重写一下 button 的 pointInside:withEvent:方法即可。只见小 Q 手起刀落在产品小 S 崇拜的目光中轻松完成。代码如下:



    次日,产品小 S 又一次满怀期待地找到开发君小 Q:欧巴~,帮我把这个 button 也扩大一下点击区域吧。小 Q 这次却犯了难,心中暗自思忖:这是系统提供的标准 UI 组件里面的 button 啊,我只能拿来用没法改呀,我看你这分明就是故意为难我胖虎!我…我…我.----小 Q 卒。

    在这个 case 中,小 Q 的遭遇着实令人同情。但是痛定思痛,难道产品提出的这个问题真的无解吗?其实不然,各位看官静息安坐,且听我慢慢分析:


    1. Objective-C 的动态特性

    Objective-C 作为一门古老而又灵活的语言有很多动态特性为开发者所津津乐道,这其中尤其以动态类型(Dynamic typing)、动态绑定(Dynamic binding)、动态加载(Dynamic loading)等特性最为著名,许多在其他语言中看似不可能实现的功能也可以在 OC 中利用这些动态特性达到事半功倍的效果。

    1.1 动态类型(Dynamic typing)

    动态类型就是说运行时才确定对象的真正类型。例如我们可以向一个 id 类型的对象发送任何消息,这在编译期都是合法的,因为类型是可以动态确定的,消息真正起作用的时机也是在运行时这个对象的类型确定以后,这个下面就会讲到。我们甚至可以在运行时动态修改一个对象的 isa 指针从而修改其类型,OC 中 KVO 的实现正是对动态类型的典型应用。

    1.2 动态绑定(Dynamic binding)

    当一个对象的类型被确定后,其对应的属性和可响应的消息也被确定,这就是动态绑定。绑定完成之后就可以在运行时根据对象的类型在类型信息中查找真正的函数地址然后执行。

    1.3 动态加载(Dynamic loading)

    根据需求加载所需要的素材资源和代码资源,用户可根据需求加载一些可执行的代码资源,而不是在在启动的时候就加载所有的组件,可执行代码可以含有新的类。

    了解了 OC 的这些动态特性之后,让我们再次回顾一下产品的需求要领:产品只想任性地修改任何一个 button 的点击区域,而恰巧这次这个 button 是系统原生组件中的一个子 View。所以当前要解决的关键问题就是如何去改变一个用系统原生类实例化出来的组件的“点击区域检测方法”。刚才在 OC 动态类型特性的介绍中我们说过“消息真正起作用的时机是在运行时这个对象的类型确定以后”、“我们甚至可以在运行时动态修改一个对象的 isa 指针从而修改其类型,OC 中 KVO 的实现正是对动态类型的典型应用”。看到这里,你应该大概有了一些思路,我们不妨照猫画虎模仿 KVO 的原理来实现一下。

    2. 初版 SDMagicHook 方案

    要想使用这种类似 KVO 的替换 isa 指针的方案,首先需要解决以下几个问题:

    2.1 如何动态创建一个新的类

    在 OC 中,我们可以调用 runtime 的 objc_allocateClassPairobjc_registerClassPair 函数动态地生成新的类,然后调用 object_setClass 函数去将某个对象的 isa 替换为我们自建的临时类。

    2.2 如何给这些新建的临时类命名

    作为一个有意义的临时类名,首先得可以直观地看出这个临时类与其基类的关系,所以我们可以这样拼接新的类名[NSString stringWithFormat:@“SDHook*%s”, originalClsName],但这有一个很明显的问题就是无法做到一个对象独享一个专有类,为此我们可以继续扩充下,不妨在类名中加上一个对象的唯一标记–内存地址,新的类名组成是这样的[NSString stringWithFormat:@“SDHook_%s_%p”, originalClsName, self],这次看起来似乎完美了,但在极端的情况下还会出问题,例如我们在一个一万次的 for 循环中不断创建同一种类型的对象,那么就会大概率出现新对象的内存地址和之前已经释放了的对象的内存地址一样,而我们会在一个对象析构后很快就会去释放它所使用的临时类,这就会有概率导致那个新生成的对象正在使用的类被释放了然后就发生了 crash。为解决此类问题,我们需要再在这个临时的类名中添加一个随机标记来降低这种情况发生的概率,最终的类名组成是这样的[NSString stringWithFormat:@“SDHook_%s_%p_%d”, originalClsName, self, mgr.randomFlag]


    2.3 何时销毁这些临时类

    我们通过 objc_setAssociatedObject 的方式可以为每个 NSObject 对象动态关联上一个 SDNewClassManager 实例,在 SDNewClassManager 实例里面持有当前对象所使用的临时类。当前对象销毁时也会销毁这个 SDNewClassManager 实例,然后我们就可以在 SDNewClassManager 实例的 dealloc 方法里面做一些销毁临时类的操作。但这里我们又不能立即做销毁临时类的操作,因为此时这个对象还没有完全析构,它还在做一些其它善后操作,如果此时去销毁那个临时类必然会造成 crash,所以我们需要稍微延迟一段时间来做这些临时类的销毁操作,代码如下:




    好了,到目前为止我们已经实现了第一版 hook 方案,不过这里两个明显的问题:

    1. 每次 hook 都要增加一个 category 定义一个函数相对比较麻烦;
    2. 如果我们在某个 Class 的两个 category 里面分别实现了一个同名的方法就会导致只有一个方法最终能被调用到。

    为此,我们研发了第二版针对第一版的不足予以改进和优化。

    3. 优化版 SDMagicHook 方案

    针对上面提到的两个问题,我们可以通过用 block 生成 IMP 然后将这个 IMP 替换到目标 Selector 对应的 method 上即可,API 示例代码如下:


    这个 block 方案看上去确实简洁和方便了很多,但同样面临着任何一个 hook 方案都避不开的问题那就是,如何在 block 里面调用原生的对应方法呢?

    3.1 关键点一:如何在 block 里面调用原生方法

    在初版方案中,我们在一个类的 category 中增加了一个 hook 专用的方法,然后在完成方法交换之后通过向实例发送 hook 专用的方法自身对应的 selector 消息即可实现对原生方法的回调。但是现在我们是使用的 block 创建了一个“匿名函数”来替换原生方法,既然是匿名函数也就没有明确的 selector,这也就意味着我们根本没有办法在方法交换后找到它的原生方法了!

    那么眼下的关键问题就是找到一个合适的 Selector 来映射到被 hook 的原生函数。而目前来看,我们唯一可以在当前编译环境下方便调用且和这个 block 还有一定关联关系的 Selector 就是原方法的 Selector 也就是我们的 demo 中的pointInside:withEvent:了。这样一来pointInside:withEvent:这个 Selector 就变成了一个一对多的映射 key,当有人在外部向我们的 button 发送 pointInside:withEvent:消息时,我们应该首先将 pointInside:withEvent:转发给我们自定义的 block 实现的 IMP,然后当在 block 内部再次向 button 发送 pointInside:withEvent:消息时就将这个消息转发给系统原生的方法实现,如此一来就可以完成了一次完美的方法调度了。

    3.2 关键点二:如何设计消息调度方案

    在 OC 中要想调度方法派发就需要拿到消息转发的控制权,而要想获得这个消息转发控制权就需要强制让这个 receiver 每次收到这个消息都触发其消息转发机制然后我们在消息转发的过程中做对应的调度。在这个例子中我们将目标 button 的 pointInside:withEvent:对应的 method 的 imp 指针替换为_objc_msgForward,这样每当有人调用这个 button 的 pointInside:withEvent:方法时最终都会走到消息转发方法 forwardInvocation:里面,我们实现这个方法来完成具体的方法调度工作。

    因为目标 button 的 pointInside:withEvent:对应的 method 的 imp 指针被替换成了_objc_msgForward,所以我们需要另外新增一个方法 A 和方法 B 来分别存储目标 button 的 pointInside:withEvent:方法的 block 自定义实现和原生实现。然后当需要在自定义的方法内部调用原始方法时通过调用 callOriginalMethodInBlock:这个 api 来显式告知,示例代码如下:



    当目标 button 实例收到 pointInside:withEvent:消息时会启用我们自定义的消息调度机制,检查如果 OriginalCallFlag 为 false 就去调用自定义实现方法 A,否则就去调用原始实现方法 B,从而顺利实现一次方法调度。流程图及示例代码如下:




    想象这样一个应用场景:有一个全局的 keywindow,各个业务都想监听一下 keywindow 的 layoutSubviews 方法,那我们该如何去管理和维护添加到 keywindow 上的多个 hook 实现之间的关系呢?如果一个对象要销毁了,它需要移除掉之前对 keywindow 的 hook,这时又该如何处理呢?

    我们的解决方案是为每个被 hook 的目标原生方法生成一张 hook 表,按照 hook 发生的顺序依次为其生成内部 selector 并加入到 hook 表中。当 keywindow 收到 layoutSubviews 消息时,我们从 hook 表中取出该次消息对应的 hook selector 发送给 keywindow 让它执行对应的动作。如果删除某个 hook 也只需将其对应的 selector 从 hook 表中移除即可。代码如下:




    4. 防止 hook 链意外断裂

    我们都知道在对某个方法进行 hook 操作时都需要在我们的 hook 代码方法体中调用一下被 hook 的那个原始方法,如果遗漏了此步操作就会造成 hook 链断裂,这样就会导致被 hook 的那个原始方法永远不会被调用到,如果有人在你之前也 hook 了这个方法的话就会导致在你之前的所有 hook 都莫名失效了,因为这是一个很隐蔽的问题所以你往往很难意识到你的 hook 操作已经给其他人造成了严重的问题。

    为了方便 hook 操作者快速及时发现这一问题,我们在 DEBUG 模式下增加了一套“hook 链断裂检测机制”,其实现原理大致如下:

    前面已经提到过,我们实现了对 hook 目标方法的自定义调度,这就使得我们有机会在这些方法调用结束后检测其是否在方法执行过程中通过 callOriginalMethodInBlock 调用原始方法。如果发现某个方法体不是被 hook 的目标函数的最原始的方法体且这次方法执行结束之后也没有调用过原始方法就会通过 raise(SIGTRAP)方式发送一个中断信号暂停当前的程序以提醒开发者当次 hook 操作没有调用原始方法。



    5. SDMagicHook 的优缺点

    与传统的在 category 中新增一个自定义方法然后进行 hook 的方案对比,SDMagicHook 的优缺点如下:

    优点:

    1. 只用一个 block 即可对任意一个实例的任意方法实现 hook 操作,不需要新增任何 category,简洁高效,可以大大提高你调试程序的效率;
    2. hook 的作用域可以控制在单个实例粒度内,将 hook 的副作用降到最低;
    3. 可以对任意普通实例甚至任意类进行 hook 操作,无论这个实例或者类是你自己生成的还是第三方提供的;
    4. 可以随时添加或去除者任意 hook,易于对 hook 进行管理。

    缺点:

    1. 为了保证增删 hook 时的线程安全,SDMagicHook 进行增删 hook 相关的操作时在实例粒度内增加了读写锁,如果有在多线程频繁的 hook 操作可能会带来一点线程等待开销,但是大多数情况下可以忽略不计;
    2. 因为是基于实例维度的所以比较适合处理对某个类的个别实例进行 hook 的场景,如果你需要你的 hook 对某个类的所有实例都生效建议继续沿用传统方式的 hook。

    总结

    SDMagicHook 方案在 OC 中和 Swift 的 UIKit 层均可直接使用,而且 hook 作用域可以限制在你指定的某个实例范围内从而避免污染其它不相关的实例。Api 设计简洁易用,你只需要花费一分钟的时间即可轻松快速上手,希望我们的这套方案可以给你带来更美妙的 iOS 开发体验。



    Github 项目地址:https://github.com/larksuite/SDMagicHook

    源码下载:SDMagicHook-master.zip



    收起阅读 »

    iOS基于二进制文件重排的解决方案 APP启动速度提升超15%!

    背景启动是App给用户的第一印象,对用户体验至关重要。业务迭代迅速,如果放任不管,启动速度会一点点劣化。为此iOS客户端团队做了大量优化工作,除了传统的修改业务代码方式,我们还做了些开拓性的探索,发现修改代码在二进制文件的布局可以提高启动性能,方案落地后在上启...
    继续阅读 »

    背景

    启动是App给用户的第一印象,对用户体验至关重要。业务迭代迅速,如果放任不管,启动速度会一点点劣化。为此iOS客户端团队做了大量优化工作,除了传统的修改业务代码方式,我们还做了些开拓性的探索,发现修改代码在二进制文件的布局可以提高启动性能,方案落地后在上启动速度提高了约15%。

    本文从原理出发,介绍了我们是如何通过静态扫描和运行时trace找到启动时候调用的函数,然后修改编译参数完成二进制文件的重新排布。

    原理

    Page Fault

    进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。

    通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多:



    Page Fault

    重排

    编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。

    静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o。




    默认布局

    简化问题:假设我们只有两个page:page1/page2,其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。

    但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。





    重排之后

    我们的经验是优化一个Page Fault,启动速度提升0.6~0.8ms。

    核心问题

    为了完成重排,有以下几个问题要解决:

    • 重排效果怎么样 - 获取启动阶段的page fault次数

    • 重排成功了没 - 拿到当前二进制的函数布局

    • 如何重排 - 让链接器按照指定顺序生成Mach-O

    • 重排的内容 - 获取启动时候用到的函数

    System Trace

    日常开发中性能分析是用最多的工具无疑是Time Profiler,但Time Profiler是基于采样的,并且只能统计线程实际在运行的时间,而发生Page Fault的时候线程是被blocked,所以我们需要用一个不常用但功能却很强大的工具:System Trace。

    选中主线程,在VM Activity中的File Backed Page In次数就是Page Fault次数,并且双击还能按时序看到引起Page Fault的堆栈:







    System Trace

    signpost

    现在我们在Instrument中已经能拿到某个时间段的Page In次数,那么如何和启动映射起来呢?

    我们的答案是:os_signpost

    os_signpost是iOS 12开始引入的一组API,可以在Instruments绘制一个时间段,代码也很简单:


    1os_log_t logger = os_log_create("com.bytedance.tiktok", "performance");2os_signpost_id_t signPostId = os_signpost_id_make_with_pointer(logger,sign);3//标记时间段开始4os_signpost_interval_begin(logger, signPostId, "Launch","%{public}s", "");5//标记结束6os_signpost_interval_end(logger, signPostId, "Launch");

    通常可以把启动分为四个阶段处理:



    启动阶段

    有多少个Mach-O,就会有多少个Load和C++静态初始化阶段,用signpost相关API对对应阶段打点,方便跟踪每个阶段的优化效果。

    Linkmap

    Linkmap是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings里开启Write Link Map File:


    Build Settings

    比如以下是一个单页面Demo项目的linkmap。



    linkmap

    linkmap主要包括三大部分:

    • Object Files 生成二进制用到的link单元的路径和文件编号

    • Sections 记录Mach-O每个Segment/section的地址范围

    • Symbols 按顺序记录每个符号的地址范围

    ld

    Xcode使用的链接器件是ld,ld有一个不常用的参数-order_file,通过man ld可以看到详细文档:

    Alters the order in which functions and data are laid out. For each section in the output file, any symbol in that section that are specified in the order file file is moved to the start of its section and laid out in the same order as in the order file file.

    可以看到,order_file中的符号会按照顺序排列在对应section的开始,完美的满足了我们的需求。

    Xcode的GUI也提供了order_file选项:




    order_file

    如果order_file中的符号实际不存在会怎么样呢?

    ld会忽略这些符号,如果提供了link选项-order_file_statistics,会以warning的形式把这些没找到的符号打印在日志里。

    获得符号

    还剩下最后一个,也是最核心的一个问题,获取启动时候用到的函数符号。

    我们首先排除了解析Instruments(Time Profiler/System Trace) trace文件方案,因为他们都是基于特定场景采样的,大多数符号获取不到。最后选择了静态扫描+运行时Trace结合的解决方案。

    Load

    Objective C的符号名是+-[Class_name(category_name) method:name:],其中+表示类方法,-表示实例方法。

    刚刚提到linkmap里记录了所有的符号名,所以只要扫一遍linkmap的__TEXT,__text,正则匹配("^\+\[.*\ load\]$")既可以拿到所有的load方法符号。

    C++静态初始化

    C++并不像Objective C方法那样,大部分方法调用编译后都是objc_msgSend,也就没有一个入口函数去运行时hook。

    但是可以用-finstrument-functions在编译期插桩“hook”,但由于APP很多依赖由其他团队提供静态库,这套方案需要修改依赖的构建过程。二进制文件重排在没有业界经验可供参考,不确定收益的情况下,选择了并不完美但成本最低的静态扫描方案。

    1. 扫描linkmap的__DATA,__mod_init_func,这个section存储了包含C++静态初始化方法的文件,获得文件号[ 5]


    1//__mod_init_func20x100008060    0x00000008  [  5] ltmp73//[  5]对应的文件4[  5] .../Build/Products/Debug-iphoneos/libStaticLibrary.a(StaticLibrary.o)

    2. 通过文件号,解压出.o。

    1➜  lipo libStaticLibrary.a -thin arm64 -output arm64.a2ar -x arm64.a StaticLibrary.o

    3. 通过.o,获得静态初始化的符号名_demo_constructor

    1  objdump -r -section=__mod_init_func StaticLibrary.o23StaticLibrary.o:    file format Mach-O arm6445RELOCATION RECORDS FOR [__mod_init_func]:60000000000000000 ARM64_RELOC_UNSIGNED _demo_constructor

    4. 通过符号名,文件号,在linkmap中找到符号在二进制中的范围:

    10x100004A30    0x0000001C  [  5] _demo_constructor

    5. 通过起始地址,对代码进行反汇编:

     1  objdump -d --start-address=0x100004A30 --stop-address=0x100004A4B demo_arm64  2 3_demo_constructor: 4100004a30:    fd 7b bf a9     stp x29, x30, [sp, #-16]! 5100004a34:    fd 03 00 91     mov x29, sp 6100004a38:    20 0c 80 52     mov w0, #97 7100004a3c:    da 06 00 94     bl  #7016  8100004a40:    40 0c 80 52     mov w0, #98 9100004a44:    fd 7b c1 a8     ldp x29, x30, [sp], #1610100004a48:    d7 06 00 14     b   #7004 

    6. 通过扫描bl指令扫描子程序调用,子程序在二进制的开始地址为:100004a3c +1b68(对应十进制的7016)。

    1100004a3c:    da 06 00 94     bl  #7016 

    7. 通过开始地址,可以找到符号名和结束地址,然后重复5~7,递归的找到所有的子程序调用的函数符号。


    小坑

    STL里会针对string生成初始化函数,这样会导致多个.o里存在同名的符号,例如:

    1__ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC1IDnEEPKc

    类似这样的重复符号的情况在C++里有很多,所以C/C++符号在order_file里要带着所在的.o信息:

    1//order_file.txt2libDemoLibrary.a(object.o):__GLOBAL__sub_I_demo_file.cpp

    局限性

    branch系列汇编指令除了bl/b,还有br/blr,即通过寄存器的间接子程序调用,静态扫描无法覆盖到这种情况。

    Local符号

    在做C++静态初始化扫描的时候,发现扫描出了很多类似l002的符号。经过一番调研,发现是依赖方输出静态库的时候裁剪了local符号。导致__GLOBAL__sub_I_demo_file.cpp 变成了l002。

    需要静态库出包的时候保留local符号,CI脚本不要执行strip -x,同时Xcode对应target的Strip Style修改为Debugging symbol:








    Strip Style

    静态库保留的local符号会在宿主App生成IPA之前裁剪掉,所以不会对最后的IPA包大小有影响。宿主App的Strip Style要选择All Symbols,宿主动态库选择Non-Global Symbols。

    Objective C方法

    绝大部分Objective C的方法在编译后会走objc_msgSend,所以通过fishhook(https://github.com/facebook/fishhook) hook这一个C函数即可获得Objective C符号。由于objc_msgSend是变长参数,所以hook代码需要用汇编来实现:


     1//代码参考InspectiveC 2__attribute__((__naked__)) 3static void hook_Objc_msgSend() { 4    save() 5    __asm volatile ("mov x2, lr\n"); 6    __asm volatile ("mov x3, x4\n"); 7    call(blr, &before_objc_msgSend) 8    load() 9    call(blr, orig_objc_msgSend)10    save()11    call(blr, &after_objc_msgSend)12    __asm volatile ("mov lr, x0\n");13    load()14    ret()15}


    子程序调用时候要保存和恢复参数寄存器,所以save和load分别对x0~x9, q0~q9入栈/出栈。call则通过寄存器来间接调用函数:


     1#define save() \ 2__asm volatile ( \ 3"stp q6, q7, [sp, #-32]!\n"\ 4... 5 6#define load() \ 7__asm volatile ( \ 8"ldp x0, x1, [sp], #16\n" \ 9...1011#define call(b, value) \12__asm volatile ("stp x8, x9, [sp, #-16]!\n"); \13__asm volatile ("mov x12, %0\n" :: "r"(value)); \14__asm volatile ("ldp x8, x9, [sp], #16\n"); \15__asm volatile (#b " x12\n");


    before_objc_msgSend中用栈保存lr,在after_objc_msgSend恢复lr。由于要生成trace文件,为了降低文件的大小,直接写入的是函数地址,且只有当前可执行文件的Mach-O(app和动态库)代码段才会写入:

    iOS中,由于ALSR(https://en.wikipedia.org/wiki/Address_space_layout_randomization)的存在,在写入之前需要先减去偏移量slide:

    1IMP imp = (IMP)class_getMethodImplementation(object_getClass(self), _cmd);2unsigned long imppos = (unsigned long)imp;3unsigned long addr = immpos - macho_slide

    获取一个二进制的__text段地址范围:

    1unsigned long size = 0;2unsigned long start = (unsigned long)getsectiondata(mhp,  "__TEXT", "__text", &size);3unsigned long end = start + size;

    获取到函数地址后,反查linkmap既可找到方法的符号名。

    Block

    block是一种特殊的单元,block在编译后的函数体是一个C函数,在调用的时候直接通过指针调用,并不走objc_msgSend,所以需要单独hook。

    通过Block的源码可以看到block的内存布局如下:

     1struct Block_layout { 2    void *isa; 3    int32_t flags; // contains ref count 4    int32_t reserved; 5    void  *invoke; 6    struct Block_descriptor1 *descriptor; 7}; 8struct Block_descriptor1 { 9    uintptr_t reserved;10    uintptr_t size;11};
    其中invoke就是函数的指针,hook思路是将invoke替换为自定义实现,然后在reserved保存为原始实现。

    1//参考 https://github.com/youngsoft/YSBlockHook2if (layout->descriptor != NULL && layout->descriptor->reserved == NULL)3{4    if (layout->invoke != (void *)hook_block_envoke)5    {6        layout->descriptor->reserved = layout->invoke;7        layout->invoke = (void *)hook_block_envoke;8    }9}

    由于block对应的函数签名不一样,所以这里仍然采用汇编来实现hook_block_envoke

     1__attribute__((__naked__)) 2static void hook_block_envoke() { 3    save() 4    __asm volatile ("mov x1, lr\n"); 5    call(blr, &before_block_hook); 6    __asm volatile ("mov lr, x0\n"); 7    load() 8    //调用原始的invoke,即resvered存储的地址 9    __asm volatile ("ldr x12, [x0, #24]\n");10    __asm volatile ("ldr x12, [x12]\n");11    __asm volatile ("br x12\n");12}

    before_block_hook中获得函数地址(同样要减去slide)。

    1intptr_t before_block_hook(id block,intptr_t lr)2{3    Block_layout * layout = (Block_layout *)block;4    //layout->descriptor->reserved即block的函数地址5    return lr;6}

    同样,通过函数地址反查linkmap既可找到block符号。


    瓶颈

    基于静态扫描+运行时trace的方案仍然存在少量瓶颈:

    • initialize hook不到

    • 部分block hook不到

    • C++通过寄存器的间接函数调用静态扫描不出来

    目前的重排方案能够覆盖到80%~90%的符号,未来我们会尝试编译期插桩等方案来进行100%的符号覆盖,让重排达到最优效果。

    整体流程

    流程

    1. 设置条件触发流程

    2. 工程注入Trace动态库,选择release模式编译出.app/linkmap/中间产物

    3. 运行一次App到启动结束,Trace动态库会在沙盒生成Trace log

    4. 以Trace Log,中间产物和linkmap作为输入,运行脚本解析出order_file

    总结

    目前,在缺少业界经验参考的情况下,我们成功验证了二进制文件重排方案在iOS APP开发中的可行性和稳定性。基于二进制文件重排,我们在针对iOS客户端上的优化工作中,获得了约15%的启动速度提升。

    抽象来看,APP开发中大家会遇到这样一个通用的问题,即在某些情况下,APP运行需要进行大量的Page Fault,这会影响代码执行速度。而二进制文件重排方案,目前看来是解决这一通用问题比较好的方案。


    转载于字节跳动技术团队:https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q








    收起阅读 »

    WKWebView音视频媒体播放处理

    1. 对WKWebViewConfiguration进行设置。实现媒体文件可以自动播放、使用内嵌HTML5播放等功能使用这个测试网址// 初始化配置对象 WKWebViewConfiguration *configuration = [[WKWebViewCo...
    继续阅读 »

    1. 对WKWebViewConfiguration进行设置。

    实现媒体文件可以自动播放、使用内嵌HTML5播放等功能
    使用这个测试网址

    // 初始化配置对象
    WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
    // 默认是NO,这个值决定了用内嵌HTML5播放视频还是用本地的全屏控制
    configuration.allowsInlineMediaPlayback = YES;
    // 自动播放, 不需要用户采取任何手势开启播放
    // WKAudiovisualMediaTypeNone 音视频的播放不需要用户手势触发, 即为自动播放
    configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone;
    configuration.allowsAirPlayForMediaPlayback = YES;
    configuration.allowsPictureInPictureMediaPlayback = YES;

    self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
    self.webView.navigationDelegate = self;
    NSURL *url =[NSURL URLWithString:@"测试网址"];
    [self.webView loadRequest:[NSURLRequest requestWithURL:url]];
    [self.view addSubview:self.webView];

    由于H5的video未设置autoplay、playsinline属性。我们需自己注入,才能实现效果。

    NSString *jSString = @"document.getElementsByTagName('video')[0].setAttribute('playsinline','');";
    NSString *jSString2 = @"document.getElementsByTagName('video')[0].autoplay=true;";
    //用于进行JavaScript注入
    WKUserScript *wkUScript = [[WKUserScript alloc] initWithSource:jSString injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    WKUserScript *wkUScript2 = [[WKUserScript alloc] initWithSource:jSString2 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript];
    [configuration.userContentController addUserScript:wkUScript2];


    2. 监听网页内播放器的回调

    可以使用两种办法。

    2.1 利用HTML5 Audio/Video 事件

    HTML5 Audio/Video 事件代码可以由H5同事完成,也可以由App端注入。
    注入代码如下:


    NSString *jSString3 = @"document.getElementsByTagName('video')[0].addEventListener('canplay', function(e) {window.webkit.messageHandlers.readytoplay.postMessage(\"canplay\");})";
    NSString *jSString4 = @"document.getElementsByTagName('video')[0].addEventListener('pause', function(e) {window.webkit.messageHandlers.pause.postMessage(\"pause\");})";
    NSString *jSString5 = @"document.getElementsByTagName('video')[0].addEventListener('play', function(e) {window.webkit.messageHandlers.play.postMessage(\"play\");})";
    NSString *jSString6 = @"document.getElementsByTagName('video')[0].addEventListener('ended', function(e) {window.webkit.messageHandlers.ended.postMessage(\"ended\");})";
    WKUserScript *wkUScript3 = [[WKUserScript alloc] initWithSource:jSString3 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript3];
    WKUserScript *wkUScript4 = [[WKUserScript alloc] initWithSource:jSString4 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript4];
    WKUserScript *wkUScript5 = [[WKUserScript alloc] initWithSource:jSString5 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript5];
    WKUserScript *wkUScript6 = [[WKUserScript alloc] initWithSource:jSString6 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript6];
    App端接收js的代码如下:
    需遵守WKScriptMessageHandler协议

    @interface ViewController () <WKNavigationDelegate,WKScriptMessageHandler>
    @end

    再为WKWebViewConfiguration添加协议

    //添加一个协议
    [configuration.userContentController addScriptMessageHandler:self name:@"readytoplay"];
    [configuration.userContentController addScriptMessageHandler:self name:@"play"];
    [configuration.userContentController addScriptMessageHandler:self name:@"pause"];
    [configuration.userContentController addScriptMessageHandler:self name:@"ended"];

    使用以下方法即可获取播放器事件

    #pragma mark - WKScriptMessageHandler

    //! WKWebView收到ScriptMessage时回调此方法
    - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.name caseInsensitiveCompare:@"readytoplay"] == NSOrderedSame) {
    NSLog(@"video is readytoplay");
    }
    if ([message.name caseInsensitiveCompare:@"play"] == NSOrderedSame) {
    NSLog(@"video is play");
    }
    if ([message.name caseInsensitiveCompare:@"pause"] == NSOrderedSame) {
    NSLog(@"video is pause");
    }
    if ([message.name caseInsensitiveCompare:@"ended"] == NSOrderedSame) {
    NSLog(@"video is ended");
    }
    }
    2.2 还有一种是App可自己实现的,使用AVAudioSession进行监听:

    使用AVAudioSession监听,必须用到AVAudioSessionCategoryOptionMixWithOthers。这样会导致切换别的音视频App不会打断播放器。例如网易云音乐、bilibili。
    手机来电会打断播放器。


    NSError *sessionError = nil;
    [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback
    withOptions:AVAudioSessionCategoryOptionMixWithOthers
    error:&sessionError];
    [[AVAudioSession sharedInstance] setActive:YES error:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(audioSessionSilenceSecondaryAudioHint:)
    name:AVAudioSessionSilenceSecondaryAudioHintNotification
    object:[AVAudioSession sharedInstance]];
    - (void)audioSessionSilenceSecondaryAudioHint:(NSNotification *)notification
    {
    NSDictionary *userInfo = notification.userInfo;
    NSLog(@"audioSessionSilenceSecondaryAudioHint %@",userInfo);
    }

    开始播放输出:

    2021-04-01 15:22:31.302248+0800 webViewPlayMedia[18078:2811391] audioSessionSilenceSecondaryAudioHint  {
    AVAudioSessionSilenceSecondaryAudioHintTypeKey = 1;

    结束播放输出:

    2021-04-01 15:22:31.382646+0800 webViewPlayMedia[18078:2811391] audioSessionSilenceSecondaryAudioHint  {
    AVAudioSessionSilenceSecondaryAudioHintTypeKey = 0;

    3. 获取视频播放地址,使用自定义播放器进行播放

    - (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation {
    NSLog(@"WKPhoneWebView didFinishNavigation");

    NSString *JsStr = @"(document.getElementsByTagName(\"video\")[0]).src";
    [self.webView evaluateJavaScript:JsStr completionHandler:^(id _Nullable response, NSError * _Nullable error) {
    if(![response isEqual:[NSNull null]] && response != nil){
    //截获到视频地址了
    NSLog(@"response == %@",response);
    }else{
    //没有视频链接
    }
    }];
    }

    4. 坑

    4.1 播放视频,会有ERROR提示:
    2021-04-01 09:34:57.361477+0800 webViewPlayMedia[17109:2655981] [assertion] Error acquiring assertion: <Error Domain=RBSAssertionErrorDomain Code=3 "Required client entitlement is missing" UserInfo={RBSAssertionAttribute=<RBSDomainAttribute| domain:"com.apple.webkit" name:"MediaPlayback" sourceEnvironment:"(null)">, NSLocalizedFailureReason=Required client entitlement is missing}>
    2021-04-01 09:34:57.361610+0800 webViewPlayMedia[17109:2655981] [ProcessSuspension] 0x1043dc990 - ProcessAssertion: Failed to acquire RBS MediaPlayback assertion 'WebKit Media Playback' for process with PID 17110, error: Error Domain=RBSAssertionErrorDomain Code=3 "Required client entitlement is missing" UserInfo={RBSAssertionAttribute=<RBSDomainAttribute| domain:"com.apple.webkit" name:"MediaPlayback" sourceEnvironment:"(null)">, NSLocalizedFailureReason=Required client entitlement is missing}


    但是设置了background属性了,依然无法解除,但是不影响播放。
    这个问题在https://stackoverflow.com/questions/66493177/required-client-entitlement-is-missing-in-wkwebview亦有提出,但是没有解决方案。

    4.2 iOS13.2 13.3系统手机会在加载WKWebView时会连续报错:
    2021-04-01 15:55:11.083253+0800 webViewPlayMedia[342:59346] [Process] kill() returned unexpected error 1

    在该系统版本下,WKWebView使用配置WKWebViewConfiguration,会无法播放。

    资料:收到控制台警告:当我在iOS13.2中加载WKWebView时,[Process] kill() returned unexpected error 1
    该错误已在13.4版本中修复。



    作者:左方
    链接:https://www.jianshu.com/p/a77a33063755


    收起阅读 »

    iOS抖音的转场动画

    转场调用代码- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { AwemeListV...
    继续阅读 »



    转场调用代码


    - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
    AwemeListViewController *awemeVC = [[AwemeListViewController alloc] init];
    awemeVC.transitioningDelegate = self; //0

    // 1
    UICollectionViewCell *cell = [collectionView cellForItemAtIndexPath:indexPath];
    // 2
    CGRect cellFrame = cell.frame;
    // 3
    CGRect cellConvertedFrame = [collectionView convertRect:cellFrame toView:collectionView.superview];

    //弹窗转场
    self.presentScaleAnimation.cellConvertFrame = cellConvertedFrame; //4

    //消失转场
    self.dismissScaleAnimation.selectCell = cell; // 5
    self.dismissScaleAnimation.originCellFrame = cellFrame; //6
    self.dismissScaleAnimation.finalCellFrame = cellConvertedFrame; //7

    awemeVC.modalPresentationStyle = UIModalPresentationOverCurrentContext; //8
    self.modalPresentationStyle = UIModalPresentationCurrentContext; //9

    [self.leftDragInteractiveTransition wireToViewController:awemeVC];
    [self presentViewController:awemeVC animated:YES completion:nil];
    }

    0 处代码使我们需要把当前的类做为转场的代理
    1 这里我们要拿出cell这个view
    2 拿出当前Cell的frame坐标
    3 cell的坐标转成屏幕坐标
    4 设置弹出时候需要cell在屏幕的位置坐标
    5 设置消失转场需要的选中cell视图
    6 设置消失转场原始cell坐标位置
    7 设置消失转场最终得cell屏幕坐标位置 用于消失完成回到原来位置的动画
    8 设置弹出得vc弹出样式 这个用于显示弹出VC得时候 默认底部使blua的高斯模糊
    9 设置当前VC的模态弹出样式为当前的弹出上下文

    5~7 步设置的消失转场动画 下面会讲解
    这里我们用的是前面讲上下滑的VC对象 大家不必担心 当它是一个普通的UIViewController即可

    实现转场所需要的代理

    首先在需要实现UIViewControllerTransitioningDelegate这个代理


     #pragma mark -
    #pragma mark - UIViewControllerAnimatedTransitioning Delegate
    - (nullable id <UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source {

    return self.presentScaleAnimation; //present VC
    }

    - (nullable id <UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed {
    return self.dismissScaleAnimation; //dismiss VC
    }

    - (nullable id <UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id <UIViewControllerAnimatedTransitioning>)animator {
    return self.leftDragInteractiveTransition.isInteracting? self.leftDragInteractiveTransition: nil;
    }

    这里面我们看到我们分别返回了

    • 弹出动画实例self.presentScaleAnimation
    • dismiss动画实例self.dismissScaleAnimation
    • 以及self.leftDragInteractiveTransition实例用于负责转场切换的具体实现

    所以我们需要在 当前的VC中声明3个成员变量 并初始化

    @property (nonatomic, strong) PresentScaleAnimation *presentScaleAnimation;
    @property (nonatomic, strong) DismissScaleAnimation *dismissScaleAnimation;
    @property (nonatomic, strong) DragLeftInteractiveTransition *leftDragInteractiveTransition;

    并在viewDidLoad:方法中初始化一下

     //转场的两个动画
    self.presentScaleAnimation = [[PresentScaleAnimation alloc] init];
    self.dismissScaleAnimation = [[DismissScaleAnimation alloc] init];
    self.leftDragInteractiveTransition = [DragLeftInteractiveTransition new];

    这里我说一下这三个成员都负责啥事
    首先DragLeftInteractiveTransition类负责转场的 手势 过程,就是pan手势在这个类里面实现,并继承自UIPercentDrivenInteractiveTransition类,这是iOS7以后系统提供的转场基类必须在interactionControllerForDismissal:代理协议中返回这个类或者子类的实例对象,所以我们生成一个成员变量self.leftDragInteractiveTransition

    其次是弹出present和消失dismiss的动画类,这俩类其实是负责简单的手势完成之后的动画.

    这两个类都是继承自NSObject并实现UIViewControllerAnimatedTransitioning协议的类,这个协议里面有 需要你复写某些方法返回具体的动画执行时间,和中间过程中我们需要的相关的容器视图以及控制器的视图实例,当我们自己执行完成之后调用相关的block回答告知转场是否完成就行了.

     @implementation PresentScaleAnimation

    - (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext{
    return 0.3f;
    }

    - (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext{
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    if (CGRectEqualToRect(self.cellConvertFrame, CGRectZero)) {
    [transitionContext completeTransition:YES];
    return;
    }
    CGRect initialFrame = self.cellConvertFrame;

    UIView *containerView = [transitionContext containerView];
    [containerView addSubview:toVC.view];

    CGRect finalFrame = [transitionContext finalFrameForViewController:toVC];
    NSTimeInterval duration = [self transitionDuration:transitionContext];

    toVC.view.center = CGPointMake(initialFrame.origin.x + initialFrame.size.width/2, initialFrame.origin.y + initialFrame.size.height/2);
    toVC.view.transform = CGAffineTransformMakeScale(initialFrame.size.width/finalFrame.size.width, initialFrame.size.height/finalFrame.size.height);

    [UIView animateWithDuration:duration
    delay:0
    usingSpringWithDamping:0.8
    initialSpringVelocity:1
    options:UIViewAnimationOptionLayoutSubviews
    animations:^{
    toVC.view.center = CGPointMake(finalFrame.origin.x + finalFrame.size.width/2, finalFrame.origin.y + finalFrame.size.height/2);
    toVC.view.transform = CGAffineTransformMakeScale(1, 1);
    } completion:^(BOOL finished) {
    [transitionContext completeTransition:YES];
    }];
    }
    @end

    很简单.

    消失的动画 同上边差不多

    @interface DismissScaleAnimation ()

    @end

    @implementation DismissScaleAnimation

    - (instancetype)init {
    self = [super init];
    if (self) {
    _centerFrame = CGRectMake((ScreenWidth - 5)/2, (ScreenHeight - 5)/2, 5, 5);
    }
    return self;
    }

    - (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext{
    return 0.25f;
    }

    - (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext{
    UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    // UINavigationController *toNavigation = (UINavigationController *)[transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    // UIViewController *toVC = [toNavigation viewControllers].firstObject;


    UIView *snapshotView;
    CGFloat scaleRatio;
    CGRect finalFrame = self.finalCellFrame;
    if(self.selectCell && !CGRectEqualToRect(finalFrame, CGRectZero)) {
    snapshotView = [self.selectCell snapshotViewAfterScreenUpdates:NO];
    scaleRatio = fromVC.view.frame.size.width/self.selectCell.frame.size.width;
    snapshotView.layer.zPosition = 20;
    }else {
    snapshotView = [fromVC.view snapshotViewAfterScreenUpdates:NO];
    scaleRatio = fromVC.view.frame.size.width/ScreenWidth;
    finalFrame = _centerFrame;
    }

    UIView *containerView = [transitionContext containerView];
    [containerView addSubview:snapshotView];

    NSTimeInterval duration = [self transitionDuration:transitionContext];

    fromVC.view.alpha = 0.0f;
    snapshotView.center = fromVC.view.center;
    snapshotView.transform = CGAffineTransformMakeScale(scaleRatio, scaleRatio);
    [UIView animateWithDuration:duration
    delay:0
    usingSpringWithDamping:0.8
    initialSpringVelocity:0.2
    options:UIViewAnimationOptionCurveEaseInOut
    animations:^{
    snapshotView.transform = CGAffineTransformMakeScale(1.0f, 1.0f);
    snapshotView.frame = finalFrame;
    } completion:^(BOOL finished) {
    [transitionContext finishInteractiveTransition];
    [transitionContext completeTransition:YES];
    [snapshotView removeFromSuperview];
    }];
    }



    @end
    我们重点需要说一下 转场过渡的类DragLeftInteractiveTransition继承自UIPercentDrivenInteractiveTransition负责转场过程,
    头文件的声明

    @interface DragLeftInteractiveTransition : UIPercentDrivenInteractiveTransition

    /** 是否正在拖动返回 标识是否正在使用转场的交互中 */
    @property (nonatomic, assign) BOOL isInteracting;


    /**
    设置需要返回的VC

    @param viewController 控制器实例
    */

    -(void)wireToViewController:(UIViewController *)viewController;


    @end


    实现


    @interface DragLeftInteractiveTransition ()

    @property (nonatomic, strong) UIViewController *presentingVC;
    @property (nonatomic, assign) CGPoint viewControllerCenter;
    @property (nonatomic, strong) CALayer *transitionMaskLayer;

    @end

    @implementation DragLeftInteractiveTransition

    #pragma mark -
    #pragma mark - override methods 复写方法
    -(CGFloat)completionSpeed{
    return 1 - self.percentComplete;
    }

    - (void)updateInteractiveTransition:(CGFloat)percentComplete {
    NSLog(@"%.2f",percentComplete);

    }

    - (void)cancelInteractiveTransition {
    NSLog(@"转场取消");
    }

    - (void)finishInteractiveTransition {
    NSLog(@"转场完成");
    }


    - (CALayer *)transitionMaskLayer {
    if (_transitionMaskLayer == nil) {
    _transitionMaskLayer = [CALayer layer];
    }
    return _transitionMaskLayer;
    }

    #pragma mark -
    #pragma mark - private methods 私有方法
    - (void)prepareGestureRecognizerInView:(UIView*)view {
    UIPanGestureRecognizer *gesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleGesture:)];
    [view addGestureRecognizer:gesture];
    }

    #pragma mark -
    #pragma mark - event response 所有触发的事件响应 按钮、通知、分段控件等
    - (void)handleGesture:(UIPanGestureRecognizer *)gestureRecognizer {
    UIView *vcView = gestureRecognizer.view;
    CGPoint translation = [gestureRecognizer translationInView:vcView.superview];
    if(!self.isInteracting &&
    (translation.x < 0 ||
    translation.y < 0 ||
    translation.x < translation.y)) {
    return;
    }
    switch (gestureRecognizer.state) {
    case UIGestureRecognizerStateBegan:{
    //修复当从右侧向左滑动的时候的bug 避免开始的时候从又向左滑动 当未开始的时候
    CGPoint vel = [gestureRecognizer velocityInView:gestureRecognizer.view];
    if (!self.isInteracting && vel.x < 0) {
    self.isInteracting = NO;
    return;
    }
    self.transitionMaskLayer.frame = vcView.frame;
    self.transitionMaskLayer.opaque = NO;
    self.transitionMaskLayer.opacity = 1;
    self.transitionMaskLayer.backgroundColor = [UIColor whiteColor].CGColor; //必须有颜色不能透明
    [self.transitionMaskLayer setNeedsDisplay];
    [self.transitionMaskLayer displayIfNeeded];
    self.transitionMaskLayer.anchorPoint = CGPointMake(0.5, 0.5);
    self.transitionMaskLayer.position = CGPointMake(vcView.frame.size.width/2.0f, vcView.frame.size.height/2.0f);
    vcView.layer.mask = self.transitionMaskLayer;
    vcView.layer.masksToBounds = YES;

    self.isInteracting = YES;
    }
    break;
    case UIGestureRecognizerStateChanged: {
    CGFloat progress = translation.x / [UIScreen mainScreen].bounds.size.width;
    progress = fminf(fmaxf(progress, 0.0), 1.0);

    CGFloat ratio = 1.0f - progress*0.5f;
    [_presentingVC.view setCenter:CGPointMake(_viewControllerCenter.x + translation.x * ratio, _viewControllerCenter.y + translation.y * ratio)];
    _presentingVC.view.transform = CGAffineTransformMakeScale(ratio, ratio);
    [self updateInteractiveTransition:progress];
    break;
    }
    case UIGestureRecognizerStateCancelled:
    case UIGestureRecognizerStateEnded:{
    CGFloat progress = translation.x / [UIScreen mainScreen].bounds.size.width;
    progress = fminf(fmaxf(progress, 0.0), 1.0);
    if (progress < 0.2){
    [UIView animateWithDuration:progress
    delay:0
    options:UIViewAnimationOptionCurveEaseOut
    animations:^{
    CGFloat w = [UIScreen mainScreen].bounds.size.width;
    CGFloat h = [UIScreen mainScreen].bounds.size.height;
    [self.presentingVC.view setCenter:CGPointMake(w/2, h/2)];
    self.presentingVC.view.transform = CGAffineTransformMakeScale(1.0f, 1.0f);
    } completion:^(BOOL finished) {
    self.isInteracting = NO;
    [self cancelInteractiveTransition];
    }];
    }else {
    _isInteracting = NO;
    [self finishInteractiveTransition];
    [_presentingVC dismissViewControllerAnimated:YES completion:nil];
    }
    //移除 遮罩
    [self.transitionMaskLayer removeFromSuperlayer];
    self.transitionMaskLayer = nil;
    }
    break;
    default:
    break;
    }
    }

    #pragma mark -
    #pragma mark - public methods 公有方法
    -(void)wireToViewController:(UIViewController *)viewController {
    self.presentingVC = viewController;
    self.viewControllerCenter = viewController.view.center;
    [self prepareGestureRecognizerInView:viewController.view];
    }

    @end


    关键的核心代码

    [self updateInteractiveTransition:progress];

    最后 手势结束

    CGFloat progress = translation.x / [UIScreen mainScreen].bounds.size.width;
    progress = fminf(fmaxf(progress, 0.0), 1.0);
    if (progress < 0.2){
    [UIView animateWithDuration:progress
    delay:0
    options:UIViewAnimationOptionCurveEaseOut
    animations:^{
    CGFloat w = [UIScreen mainScreen].bounds.size.width;
    CGFloat h = [UIScreen mainScreen].bounds.size.height;
    [self.presentingVC.view setCenter:CGPointMake(w/2, h/2)];
    self.presentingVC.view.transform = CGAffineTransformMakeScale(1.0f, 1.0f);
    } completion:^(BOOL finished) {
    self.isInteracting = NO;
    [self cancelInteractiveTransition];
    }];
    }else {
    _isInteracting = NO;
    [self finishInteractiveTransition];
    [_presentingVC dismissViewControllerAnimated:YES completion:nil];
    }
    //移除 遮罩
    [self.transitionMaskLayer removeFromSuperlayer];
    self.transitionMaskLayer = nil;


    demo及常见问题:https://github.com/sunyazhou13/AwemeDemoTransition



    收起阅读 »

    UITableView 建模

    tableview 是开发中项目中常用的视图控件,并且是重复的使用,布局类似,只是数据源及Cell更改,所以会出现很多重复的内容,并且即使新建一个基础的列表也要重复这些固定逻辑的代码,这对于开发效率很不友好。本文的重点是抽取重复的逻辑代码,简化列表页面的搭建,...
    继续阅读 »
    tableview 是开发中项目中常用的视图控件,并且是重复的使用,布局类似,只是数据源及Cell更改,所以会出现很多重复的内容,并且即使新建一个基础的列表也要重复这些固定逻辑的代码,这对于开发效率很不友好。
    本文的重点是抽取重复的逻辑代码简化列表页面的搭建,达到数据驱动列表

    说明:
    首先tableview有两个代理delegate 和 datasource(基于单一职责设计规则)
    delegate :负责交互事件;
    datasource :负责cell创建及数据填充,这也是本文探讨的重点。
    (1)基本原则
    苹果将tableView的数据通过一个二维数组构建(组,行),这是一个很重要的设计点,要沿着这套规则继续发展,设计模式的继承,才是避免坏代码产生的基础。
    (2)组
    “组”是这套逻辑的根基先有组再有行,并且列表动态修改的内容都是以为基础,的结构相对固定,因此本文将抽离成一个数据模型而不是接口


    #import <Foundation/Foundation.h>
    #import "RWCellViewModelProtocol.h"

    @interface RWSectionModel : NSObject
    /// item数组:元素必须是遵守RWCellViewModel协议
    @property (nonatomic, strong) NSMutableArray <id<RWCellViewModel>>*itemsArray;

    /// section头部高度
    @property (nonatomic, assign) CGFloat sectionHeaderHeight;
    /// section尾部高度
    @property (nonatomic, assign) CGFloat sectionFooterHeight;
    /// sectionHeaderView: 必须是UITableViewHeaderFooterView或其子类,并且遵循RWHeaderFooterDataSource协议
    @property (nonatomic, strong) Class headerReuseClass;
    /// sectionFooterView: 必须是UITableViewHeaderFooterView或其子类,并且遵循RWHeaderFooterDataSource协议
    @property (nonatomic, strong) Class footerReuseClass;

    /// headerData
    @property (nonatomic, strong) id headerData;
    /// footerData
    @property (nonatomic, strong) id footerData;
    @end

    (2)行
    最核心的有三大CellCell高度Cell数据
    这次的设计参考MVVM设计模式,对于行的要素提取成一个ViewModel,并且ViewModel要做成接口的方式,因为行除了这三个基本的元素外,可能要需要Cell填充的数据,比如titleString,subTitleString,headerImage等等,这样便于扩展。


    #ifndef RWCellViewModel_h
    #define RWCellViewModel_h

    @import UIKit;

    @protocol RWCellViewModel <NSObject>
    /// Cell 的类型
    @property (nonatomic, strong) Class cellClass;
    /// Cell的高度: 0 则是UITableViewAutomaticDimension
    @property (nonatomic, assign) CGFloat cellHeight;
    @end

    #endif /* RWCellViewModel_h */

    (3)tableView
    此处不用使用tableViewController的方式,而使用view的方式,这样嵌入更方便。并且对外提供基本的接口,用于列表数据的获取,及点击事件处理。

    备注:
    关于数据,这里提供了多组和单组的两个接口,为了减少使用的过程中外部新建RWSectionModel这一步,但是其内部还是基于RWSectionModel这一个模型。


    #import <UIKit/UIKit.h>
    #import "RWCellViewModelProtocol.h"
    #import "RWSectionModel.h"

    @protocol RWTableViewDelegate;

    @interface RWTableView : UITableView
    /// rwdelegate
    @property (nonatomic, weak) id<RWTableViewDelegate> rwdelegate;

    /// 构建方法
    /// @param delegate 是指rwdelegate
    - (instancetype)initWithDelegate:(id<RWTableViewDelegate>)delegate;

    @end


    @protocol RWTableViewDelegate <NSObject>
    @optional
    /// 多组构建数据
    - (NSArray <RWSectionModel*>*)tableViewWithMutilSectionDataArray;

    /// 单组构建数据
    - (NSArray <id<RWCellViewModel>>*)tableViewWithSigleSectionDataArray;


    /// cell点击事件
    /// @param data cell数据模型
    /// @param indexPath indexPath
    - (void)tableViewDidSelectedCellWithDataModel:(id)data indexPath:(NSIndexPath *)indexPath;

    RWTableview.m

    #pragma mark - dataSource
    - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    /// 数据源始终保持“二维数组的状态”,即SectionModel中包裹items的方式
    if ([self.rwdelegate respondsToSelector:@selector(tableViewWithMutilSectionDataArray)]) {
    self.dataArray = [self.rwdelegate tableViewWithMutilSectionDataArray];
    return self.dataArray.count;
    }
    else if ([self.rwdelegate respondsToSelector:@selector(tableViewWithSigleSectionDataArray)]) {
    RWSectionModel *sectionModel = [[RWSectionModel alloc]init];
    sectionModel.itemsArray = [self.rwdelegate tableViewWithSigleSectionDataArray].mutableCopy;
    self.dataArray = @[sectionModel];
    return 1;
    }
    return 0;
    }

    - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    RWSectionModel *sectionModel = [self.dataArray objectAtIndex:section];
    return sectionModel.itemsArray.count;
    }

    - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    /// 此处只做Cell的复用或创建
    RWSectionModel *sectionModel = [self.dataArray objectAtIndex:indexPath.section];
    id<RWCellViewModel>cellViewModel = [sectionModel.itemsArray objectAtIndex:indexPath.row];
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass(cellViewModel.cellClass)];
    if (cell == nil) {
    cell = [[cellViewModel.cellClass alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:NSStringFromClass(cellViewModel.cellClass)];
    }
    cell.selectionStyle = UITableViewCellSelectionStyleNone;
    return cell;
    }

    (4)Cell上子控件的交互事件处理
    腾讯QQ部门的大神峰之巅提供了一个很好的解决办法,基于苹果现有的响应链(真的很牛逼),将点击事件传递给下个响应者,而不需要为事件的传递搭建更多的依赖关系。这是一篇鸡汤文章,有很多营养,比如tableview模块化,这也是我接下来要学习的。

    #import <UIKit/UIKit.h>
    #import "RWEvent.h"

    @interface UIResponder (RWEvent)

    - (void)respondEvent:(NSObject<RWEvent> *)event;

    @end
    #import "UIResponder+RWEvent.h"

    @implementation UIResponder (RWEvent)

    - (void)respondEvent:(NSObject<RWEvent> *)event {
    [self.nextResponder respondEvent:event];
    }

    @end


    2020年11月18日 更新

    鉴于此tableView封装在实际项目遇到的问题进行改善,主要内容如下:
    (1)使用分类的方式替换协议
    优点:分类能更便捷的扩展原有类,并且使用更方便,不需要再导入协议文件及遵守协议
    【RWCellDataSource协议】替换成:【UITableViewCell (RWData)】
    【RWHeaderFooterDataSource协议】 替换成:【UITableViewHeaderFooterView (RWData)】

    (2)cell高度缓存的勘误
    willDisplayCell:中要想获取准确的Cell高度,那么必须在heightForRowAtIndexPath:方法中给Cell赋值,因为系统计算Cell的高度是在这个方法中进行的

    - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    RWSectionModel *sectionModel = [self.dataArray objectAtIndex:indexPath.section];
    id<RWCellViewModel>cellViewModel = [sectionModel.itemsArray objectAtIndex:indexPath.row];
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass(cellViewModel.cellClass)];
    /// Cell创建
    if (cell == nil) {
    cell = [[cellViewModel.cellClass alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:NSStringFromClass(cellViewModel.cellClass)];
    }
    /// Cell赋值
    [cell rw_setData:cellViewModel];
    cell.selectionStyle = UITableViewCellSelectionStyleNone;
    return cell;
    }

    - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    RWSectionModel *sectionModel = [self.dataArray objectAtIndex:indexPath.section];
    id<RWCellViewModel>cellViewModel = [sectionModel.itemsArray objectAtIndex:indexPath.row];
    return cellViewModel.cellHeight ? : UITableViewAutomaticDimension;
    }

    - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
    RWSectionModel *sectionModel = [self.dataArray objectAtIndex:indexPath.section];
    id<RWCellViewModel>cellViewModel = [sectionModel.itemsArray objectAtIndex:indexPath.row];
    /// 高度缓存
    /// 此处高度做一个缓存是为了高度自适应的Cell,避免重复计算的工作量,对于性能优化有些帮助
    /// 如果想要在willDisplayCell获取到准确的Cell高度,那么必须在cellForRowAtIndexPath:方法给Cell赋值
    /// 同时可以避免由于高度自适应导致Cell的定位不准确,比如置顶或者滑动到某一个Cell的位置
    /// 如果自动布局要更新高度,可以将cellViewModel设置为0
    cellViewModel.cellHeight = cell.frame.size.height;
    }













    收起阅读 »

    Flutter中的异步

    同步与异步 程序的运行是出于满足人们对某种逻辑需求的处理,在计算机上表现为可执行指令,正常情况下我们期望的指令是按逻辑的顺序依次执行的,而实际情况由于某些指令是耗时操作,不能立即返回结果而造成了阻塞,导致程序无法继续执行。这种情况多见于一些io操作。这时,对...
    继续阅读 »

    同步与异步


    程序的运行是出于满足人们对某种逻辑需求的处理,在计算机上表现为可执行指令,正常情况下我们期望的指令是按逻辑的顺序依次执行的,而实际情况由于某些指令是耗时操作,不能立即返回结果而造成了阻塞,导致程序无法继续执行。这种情况多见于一些io操作。这时,对于用户层面来说,我们可以选择stop the world,等待操作完成返回结果后再继续操作,也可以选择继续去执行其他操作,等事件返回结果后再通知回来。这就是从用户角度来看的同步与异步。


    从操作系统的角度,同步异步,与任务调度,进程间切换,中断,系统调用之间有着更为复杂的关系。


    同步I/O 与 异步I/O的区别


    img

    为什么使用异步


    用户可以阻塞式的等待,因为人的操作和计算机相比是非常慢的,计算机如果阻塞那就是很大的性能浪费了,异步操作让您的程序在等待另一个操作的同时完成工作。三种异步操作的场景:



    • I/O操作:例如:发起一个网络请求,读写数据库、读写文件、打印文档等,一个同步的程序去执行这些操作,将导致程序的停止,直到操作完成。更有效的程序会改为在操作挂起时去执行其他操作,假设您有一个程序读取一些用户输入,进行一些计算,然后通过电子邮件发送结果。发送电子邮件时,您必须向网络发送一些数据,然后等待接收服务器响应。等待服务器响应所投入的时间是浪费的时间,如果程序继续计算,这将得到更好的利用

    • 并行执行多个操作:当您需要并行执行不同的操作时,例如进行数据库调用、Web 服务调用以及任何计算,那么我们可以使用异步

    • 长时间运行的基于事件驱动的请求:这就是您有一个请求进来的想法,并且该请求进入休眠状态一段时间等待其他一些事件的发生。当该事件发生时,您希望请求继续,然后向客户端发送响应。所以在这种情况下,当请求进来时,线程被分配给该请求,当请求进入睡眠状态时,线程被发送回线程池,当任务完成时,它生成事件并从线程池中选择一个线程发送响应


    计算机中异步的实现方式就是任务调度,也就是进程的切换


    任务调度采用的是时间片轮转的抢占式调度方式,进程是任务调度的最小单位。


    计算机系统分为用户空间内核空间,用户进程在用户空间,操作系统运行在内核空间,内核空间的数据访问修改拥有高于普通进程的权限,用户进程之间相互独立,内存不共享,保证操作系统的运行安全。如何最大化的利用CPU,确定某一时刻哪个进程拥有CPU资源就是任务调度的过程。内核负责调度管理用户进程,以下为进程调度过程


    img

    在任意时刻, 一个 CPU 核心上(processor)只可能运行一个进程



    每一个进程可以包含多个线程,线程是执行操作的最小单元,因此进程的切换落实到具体细节就是正在执行线程的切换


    Future


    Future<T> 表示一个异步的操作结果,用来表示一个延迟的计算,返回一个结果或者error,使用代码实例:


    Future<int> future = getFuture();
    future.then((value) => handleValue(value))
    .catchError((error) => handleError(error))
    .whenComplete(func);

    future可以是三种状态:未完成的返回结果值返回异常


    当一个返回future对象被调用时,会发生两件事:



    • 将函数操作入队列等待执行结果并返回一个未完成的Future对象

    • 函数操作完成时,Future对象变为完成并携带一个值或一个错误


    首先,Flutter事件处理模型为先执行main函数,完成后检查执行微任务队列Microtask Queue中事件,最后执行事件队列Event Queue中的事件,示例:


    void main(){
    Future(() => print(10));
    Future.microtask(() => print(9));
    print("main");
    }
    /// 打印结果为:
    /// main
    /// 9
    /// 10

    基于以上事件模型的基础上,看下Future提供的几种构造函数,其中最基本的为直接传入一个Function


    factory Future(FutureOr<T> computation()) {
    _Future<T> result = new _Future<T>();
    Timer.run(() {
    try {
    result._complete(computation());
    } catch (e, s) {
    _completeWithErrorCallback(result, e, s);
    }
    });
    return result;
    }

    Function有多种写法:


    //简单操作,单步
    Future(() => print(5));
    //稍复杂,匿名函数
    Future((){
    print(6);
    });
    //更多操作,方法名
    Future(printSeven);

    printSeven(){
    print(7);
    }


    Future.microtask


    此工程方法创建的事件将发送到微任务队列Microtask Queue,具有相比事件队列Event Queue优先执行的特点


    factory Future.microtask(FutureOr<T> computation()) {
    _Future<T> result = new _Future<T>();
    //
    scheduleMicrotask(() {
    try {
    result._complete(computation());
    } catch (e, s) {
    _completeWithErrorCallback(result, e, s);
    }
    });
    return result;
    }

    Future.sync


    返回一个立即执行传入参数的Future,可理解为同步调用


    factory Future.sync(FutureOr<T> computation()) {
    try {
    var result = computation();
    if (result is Future<T>) {
    return result;
    } else {
    // TODO(40014): Remove cast when type promotion works.
    return new _Future<T>.value(result as dynamic);
    }
    } catch (error, stackTrace) {
    /// ...
    }
    }

    	Future.microtask(() => print(9));
    Future(() => print(10));
    Future.sync(() => print(11));

    /// 打印结果: 11、9、10

    Future.value


    创建一个将来包含value的future


    factory Future.value([FutureOr<T>? value]) {
    return new _Future<T>.immediate(value == null ? value as T : value);
    }

    参数FutureOr含义为T value 和 Future value 的合集,因为对于一个Future参数来说,他的结果可能为value或者是Future,所以对于以下两种写法均合法:


    	Future.value(12).then((value) => print(value));
    Future.value(Future<int>((){
    return 13;
    }));


    这里需要注意即使value接收的是12,仍然会将事件发送到Event队列等待执行,但是相对其他Future事件执行顺序会提前



    Future.error


    创建一个执行结果为error的future


    factory Future.error(Object error, [StackTrace? stackTrace]) {
    /// ...
    return new _Future<T>.immediateError(error, stackTrace);
    }

    _Future.immediateError(var error, StackTrace stackTrace)
    : _zone = Zone._current {
    _asyncCompleteError(error, stackTrace);
    }

     Future.error(new Exception("err msg"))
    .then((value) => print("err value: $value"))
    .catchError((e) => print(e));

    /// 执行结果为:Exception: err msg

    Future.delayed


    创建一个延迟执行回调的future,内部实现为Timer加延时执行一个Future


    factory Future.delayed(Duration duration, [FutureOr<T> computation()?]) {
    /// ...
    new Timer(duration, () {
    if (computation == null) {
    result._complete(null as T);
    } else {
    try {
    result._complete(computation());
    } catch (e, s) {
    _completeWithErrorCallback(result, e, s);
    }
    }
    });
    return result;
    }

    Future.wait


    等待多个Future并收集返回结果


    static Future<List<T>> wait<T>(Iterable<Future<T>> futures,
    {bool eagerError = false, void cleanUp(T successValue)?}) {
    /// ...
    }

    FutureBuilder结合使用:


    child: FutureBuilder(
    future: Future.wait([
    firstFuture(),
    secondFuture()
    ]),
    builder: (context,snapshot){
    if(!snapshot.hasData){
    return CircularProgressIndicator();
    }
    final first = snapshot.data[0];
    final second = snapshot.data[1];
    return Text("data $first $second");
    },
    ),

    Future.any


    返回futures集合中第一个返回结果的值


    static Future<T> any<T>(Iterable<Future<T>> futures) {
    var completer = new Completer<T>.sync();
    void onValue(T value) {
    if (!completer.isCompleted) completer.complete(value);
    }
    void onError(Object error, StackTrace stack) {
    if (!completer.isCompleted) completer.completeError(error, stack);
    }
    for (var future in futures) {
    future.then(onValue, onError: onError);
    }
    return completer.future;
    }

    对上述例子来说,Future.any snapshot.data 将返回firstFuturesecondFuture中第一个返回结果的值


    Future.forEach


    为传入的每一个元素,顺序执行一个action


    static Future forEach<T>(Iterable<T> elements, FutureOr action(T element)) {
    var iterator = elements.iterator;
    return doWhile(() {
    if (!iterator.moveNext()) return false;
    var result = action(iterator.current);
    if (result is Future) return result.then(_kTrue);
    return true;
    });
    }

    这里边action是方法作为参数,头一次见这种形式语法还是在js中,当时就迷惑了很大一会儿,使用示例:


    Future.forEach(["one","two","three"], (element) {
    print(element);
    });

    Future.doWhile


    执行一个操作直到返回false


    Future.doWhile((){
    for(var i=0;i<5;i++){
    print("i => $i");
    if(i >= 3){
    return false;
    }
    }
    return true;
    });
    /// 结果打印到 3

    以上为Future中常用构造函数和方法


    在Widget中使用Future


    Flutter提供了配合Future显示的组件FutureBuilder,使用也很简单,伪代码如下:


    child: FutureBuilder(
    future: getFuture(),
    builder: (context, snapshot){
    if(!snapshot.hasData){
    return CircularProgressIndicator();
    } else if(snapshot.hasError){
    return _ErrorWidget("Error: ${snapshot.error}");
    } else {
    return _ContentWidget("Result: ${snapshot.data}")
    }
    }
    )

    Async-await


    使用


    这两个关键字提供了异步方法的同步书写方式,Future提供了方便的链式调用使用方式,但是不太直观,而且大量的回调嵌套造成可阅读性差。因此,现在很多语言都引入了await-async语法,学习他们的使用方式是很有必要的。


    两条基本原则:



    • 定义一个异步方法,必须在方法体前声明 async

    • await关键字必须在async方法中使用


    首先,在要执行耗时操作的方法体前增加async:


    void main() async { ··· }

    然后,根据方法的返回类型添加Future修饰


    Future<void> main() async { ··· }

    现在就可以使用await关键字来等待这个future执行完毕


    print(await createOrderMessage());

    例如实现一个由一级分类获取二级分类,二级分类获取详情的需求,使用链式调用的代码如下:


    var list = getCategoryList();
    list.then((value) => value[0].getCategorySubList(value[0].id))
    .then((subCategoryList){
    var courseList = subCategoryList[0].getCourseListByCategoryId(subCategoryList[0].id);
    print(courseList);
    }).catchError((e) => (){
    print(e);
    });

    现在来看下使用async/await,事情变得简单了多少


    Future<void> main() async {
    await getCourses().catchError((e){
    print(e);
    });
    }
    Future<void> getCourses() async {
    var list = await getCategoryList();
    var subCategoryList = await list[0].getCategorySubList(list[0].id);
    var courseList = subCategoryList[0].getCourseListByCategoryId(subCategoryList[0].id);
    print(courseList);
    }

    可以看到这样更加直观


    缺陷


    async/await 非常方便,但是还是有一些缺点需要注意


    因为它的代码看起来是同步的,所以是会阻塞后面的代码执行,直到await返回结果,就像执行同步操作一样。它确实可以允许其他任务在此期间继续运行,但后边自己的代码被阻塞。


    这意味着代码可能会由于有大量await代码相继执行而阻塞,本来用Future编写表示并行的操作,现在使用await变成了串行,例如,首页有一个同时获取轮播接口,tab列表接口,msg列表接口的需求


    Future<String> getBannerList() async {
    return await Future.delayed(Duration(seconds: 3),(){
    return "banner list";
    });
    }

    Future<String> getHomeTabList() async {
    return await Future.delayed(Duration(seconds: 3),(){
    return "tab list";
    });
    }

    Future<String> getHomeMsgList() async {
    return await Future.delayed(Duration(seconds: 3),(){
    return "msg list";
    });
    }

    使用await编写很可能会写成这样,打印执行操作的时间


    Future<void> main2() async {
    var startTime = DateTime.now().second;
    await getBannerList();
    await getHomeTabList();
    await getHomeMsgList();
    var endTime = DateTime.now().second;
    print(endTime - startTime); // 9
    }

    在这里,我们直接等待所有三个模拟接口的调用,使每个调用3s。后续的每一个都被迫等到上一个完成, 最后会看到总运行时间为9s,而实际我们想三个请求同时执行,代码可以改成如下这种:


    Future<void> main() async {
    var startTime = DateTime.now().second;
    var bannerList = getBannerList();
    var homeTabList = getHomeTabList();
    var homeMsgList = getHomeMsgList();

    await bannerList;
    await homeTabList;
    await homeMsgList;
    var endTime = DateTime.now().second;
    print(endTime - startTime); // 3
    }

    将三个Future存储在变量中,这样可以同时启动,最后打印时间仅为3s,所以在编写代码时,我们必须牢记这点,避免性能损耗。


    原理


    线程模型


    当一个Flutter应用或者Flutter Engine启动时,它会启动(或者从池中选择)另外三个线程,这些线程有些时候会有重合的工作点,但是通常,它们被称为UI线程GPU线程IO线程。需要注意一点这个UI线程并不是程序运行的主线程,或者说和其他平台上的主线程理解不同,通常的,Flutter将平台的主线程叫做"Platform thread"


    img


    UI线程是所有的Dard代码运行的地方,例如framework和你的应用,除非你启动自己的isolates,否则Dart将永远不会运行在其他线程。平台线程是所有依赖插件的代码运行的地方。该线程也是native frameworks为其他任务提供服务的地方,一般来说,一个Flutter应用启动的时候会创建一个Engine实例,Engine创建的时候会创建一个Platform thread为其提供服务。跟Flutter Engine的所有交互(接口调用)必须发生在Platform Thread,试图在其它线程中调用Flutter Engine会导致无法预期的异常。这跟Android/iOS UI相关的操作都必须在主线程进行相类似。


    Isolates是Dart中概念,本意是隔离,它的实现功能和thread类似,但是他们之间的实现又有着本质的区别,Isolote是独立的工作者,它们之间不共享内存,而是通过channel传递消息。Dart是单线程执行代码,Isolate提供了Dart应用可以更好的利用多核硬件的解决方案。


    事件循环


    单线程模型中主要就是在维护着一个事件循环(Event Loop) 与 两个队列(event queue和microtask queue)当Flutter项目程序触发如点击事件IO事件网络事件时,它们就会被加入到eventLoop中,eventLoop一直在循环之中,当主线程发现事件队列不为空时发现,就会取出事件,并且执行。


    microtask queue中事件优先于event queue执行,当有任务发送到microtask队列时,会在当前event执行完成后,阻塞当前event queue转而去执行microtask queue中的事件,这样为Dart提供了任务插队的解决方案。


    event queue的阻塞意味着app无法进行UI绘制,响应鼠标和I/O等事件,所以要谨慎使用,如下为流程图:


    event queue和microtask queue


    这两个任务队列中的任务切换在某些方面就相当于是协程调度机制


    协程


    协程是一种协作式的任务调度机制,区别于操作系统的抢占式任务调度机制,它是用户态下面的,避免线程切换的内核态、用户态转换的性能开销。它让调用者自己来决定什么时候让出cpu,比操作系统的抢占式调度所需要的时间代价要小很多,后者为了恢复现场会保存相当多的状态(不仅包括进程上下文的虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态),并且会频繁的切换,以现在流行的大多数Linux机器来说,每一次的上下文切换要消耗大约1.2-1.5μs的时间,这是仅考虑直接成本,固定在单个核心以避免迁移的成本,未固定情况下,切换时间可达2.2μs


    img

    对cpu来说这算一个很长的时间吗,一个很好的比较是memcpy,在相同的机器上,完成一个64KiB数据的拷贝需要3μs的时间,上下文的切换比这个操作稍微快一些


    Plot of thread/process launch and context switch

    协程和线程非常相似,是从异步执行任务的角度来看,而并不是从设计的实体角度像进程->线程->协程这样类似于细胞->原子核->质子中子这样的关系。可以理解为线程上执行的一段函数,用yield完成异步请求、注册回调/通知器、保存状态,挂起控制流、收到回调/通知、恢复状态、恢复控制流的所有过程


    多线程执行任务模型如图:



    线程的阻塞要靠系统间进程的切换,完成逻辑流的执行,频繁的切换耗费大量资源,而且逻辑流的执行数量严重依赖于程序申请到的线程的数量。


    协程是协同多任务的,这意味着协程提供并发性但不提供并行性,执行流模型图如下:



    协程可以用逻辑流的顺序去写控制流,协程的等待会主动释放cpu,避免了线程切换之间的等待时间,有更好的性能,逻辑流的代码编写和理解上也简单的很多


    但是线程并不是一无是处,抢占式线程调度器事实上提供了准实时的体验。例如Timer,虽然不能确保在时间到达的时候一定能够分到时间片运行,但不会像协程一样万一没有人让出时间片就永远得不到运行……


    总结



    • 同步与异步

    • Future提供了Flutter中异步代码链式编写方式

    • async-wait提供了异步代码的同步书写方式

    • Future的常用方法和FutureBuilder编写UI

    • Flutter中线程模型,四个线程

    • 单线程语言的事件驱动模型

    • 进程间切换和协程对比



    收起阅读 »

    Protobuf 和 JSON对比分析

    Protocol Buffers (a.k.a., protobuf) are Google's language-neutral, platform-neutral, extensible mechanism for serializing structur...
    继续阅读 »

    Protocol Buffers (a.k.a., protobuf) are Google's language-neutral, platform-neutral, extensible mechanism for serializing structured data.


    Protobuf是Google公司开发的一种语言中立 平台中立 可扩展 的 对结构化数据 序列化的机制。


    本文主要对Protobuf和JSON序列化&反序列化的性能做横向对比分析。 JSON序列化使用Google官方的Gson框架。


    ProtobufGsonLanguagePlatform
    3.17.32.8.7KotlinmacOS IntelliJ IDEA


    测试序列化内容,高效作业25分钟的训练数据(mock)


    数据结构


    syntax = "proto3";
    package me.sunnyxibei.data;
    option java_package = "me.sunnyxibei.data";
    option java_outer_classname = "TaskProto";

    message Eeg{
    repeated double alphaData = 1;
    repeated double betaData = 2;
    repeated double attentionData = 3;
    repeated int64 timestampData = 4;
    int64 startTimestamp = 5;
    int64 endTimestamp = 6;
    }
    message TaskRecord{
    string localId = 1;
    int64 localCreated = 2;
    int64 localUpdated = 3;
    int32 score = 4;
    int64 originDuration = 5;
    string subject = 6;
    string content = 7;
    Eeg eeg = 8;
    }

    对比结果 repeat = 1


    Gson序列化大小 = 30518 bytes
    Gson序列化时间 = 113 ms
    protobuf序列化大小 = 13590 bytes
    protobuf序列化时间 = 39 ms
    *************************
    Gson反序列化时间 = 15 ms
    protobuf反序列化时间 = 3 ms

    repeat = 10


    Gson序列化时间 = 137 ms
    protobuf序列化时间 = 41 ms
    *************************
    Gson反序列化时间 = 50 ms
    protobuf反序列化时间 = 5 ms

    repeat = 100


    Gson序列化时间 = 347 ms
    protobuf序列化时间 = 47 ms
    *************************
    Gson反序列化时间 = 212 ms
    protobuf反序列化时间 = 22 ms

    repeat = 1000


    Gson序列化时间 = 984 ms
    protobuf序列化时间 = 97 ms
    *************************
    Gson反序列化时间 = 817 ms
    protobuf反序列化时间 = 105 ms

    repeat = 10000


    Gson序列化时间 = 7034 ms
    protobuf序列化时间 = 225 ms
    *************************
    Gson反序列化时间 = 5544 ms
    protobuf反序列化时间 = 300 ms

    repeat = 100000


    Gson序列化时间 = 65560 ms
    protobuf序列化时间 = 1469 ms
    *************************
    Gson反序列化时间 = 49984 ms
    protobuf反序列化时间 = 2409 ms

    结论:



    1. 空间对比,Protobuf序列化后的数据大小,为JSON序列化后的44.5%

    2. 时间对比


    次数序列化(Protobuf/JSON)反序列化(Protobuf/JSON)
    134.5%20%
    1029.9%10%
    10013.5%9.43%
    10009.9%12.9%
    100003.2%5.41%
    1000002.24%4.82%


    收起阅读 »

    CoordinatorLayout 嵌套Recycleview 卡顿问题

    1.问题场景 伪代码: <CoordinatorLayout> <AppBarLayout> <RecycleView> </RecycleView> </AppBa...
    继续阅读 »

    1.问题场景


    伪代码:
    <CoordinatorLayout>
    <AppBarLayout>
    <RecycleView>
    </RecycleView>
    </AppBarLayout>
    </ConstraintLayout>

    一般这种做法是,底部view的相应滑动,滑动联动,但是同时会出现RecycleView ViewHoder复用失败,造成cpu 的消耗,item到达一定数量后会造成oom页面出现卡顿


    2. 问题原理


    RecycleView ViewHoder 复用问题第一时间我们应想到是; ViewGrop/onMeasureChild
    测量问题,重写 onMeasureChild ,避免中间MeasureSpec.UNSPECIFIED模式 的赋值造成RecycleView的item复用,但是是失败的!


     @Override
    protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
    child.measure(parentWidthMeasureSpec, parentHeightMeasureSpec);
    }


    原因是: parentHeightMeasureSpec 已经被设置 MeasureSpec.UNSPECIFIED 测量模式 看下源码CoordinatorLayout onMeasure 局部关键代码:


    prepareChildren();

    final Behavior b = lp.getBehavior();
    if (b == null || !b.onMeasureChild(this, child, childWidthMeasureSpec, keylineWidthUsed,
    childHeightMeasureSpec, 0)) {
    onMeasureChild(child, childWidthMeasureSpec, keylineWidthUsed,
    childHeightMeasureSpec, 0);
    }

    通过prepareChildren()结合LayoutParams


            R.styleable.CoordinatorLayout_Layout_layout_behavior);
    if (mBehaviorResolved) {
    mBehavior = parseBehavior(context, attrs, a.getString(
    R.styleable.CoordinatorLayout_Layout_layout_behavior));
    }

    我们可以得到 Behavior b 就是我们再布局内设置的 AppBarLayout. layout_behavior, 可以看到 Behavior/onMeasureChild 做了一层测量, ,我们继续看 Behavior/onMeasureChild 源码:


    @Override
    public boolean onMeasureChild(
    @NonNull CoordinatorLayout parent,
    @NonNull T child,
    int parentWidthMeasureSpec,
    int widthUsed,
    int parentHeightMeasureSpec,
    int heightUsed) {
    final CoordinatorLayout.LayoutParams lp =
    (CoordinatorLayout.LayoutParams) child.getLayoutParams();
    if (lp.height == CoordinatorLayout.LayoutParams.WRAP_CONTENT) {
    // If the view is set to wrap on it's height, CoordinatorLayout by default will
    // cap the view at the CoL's height. Since the AppBarLayout can scroll, this isn't
    // what we actually want, so we measure it ourselves with an unspecified spec to
    // allow the child to be larger than it's parent
    parent.onMeasureChild(
    child,
    parentWidthMeasureSpec,
    widthUsed,
    MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
    heightUsed);
    return true;
    }

    // Let the parent handle it as normal
    return super.onMeasureChild(
    parent, child, parentWidthMeasureSpec, widthUsed, parentHeightMeasureSpec, heightUsed);
    }

    问题找到了问题关键 if (lp.height == CoordinatorLayout.LayoutParams.WRAP_CONTENT) ,造成了MeasureSpec.UNSPECIFIED 的使用, 而这个模式又会造成Recycleview.LayoutManager加载所有的item,导致复用失败; 看到这 AppBarLayout给固定值或者match_parent 不就解决问题了吗, 是能解决问题,但是这样 我们的layout ui就不符合我们绘制ui的布局了,也会造成页面空白显示问题,所以这样使用recycleview 嵌套是非法使用,矛盾使用!


    解决问题



    • 同一使用RecycleView 使用,作为RecycleView item 的一部分,但是也会造成滑动冲突问题,然后通过 NestedScrollingParent3 外部拦截法,来解决内外层的滑动冲突,问题顺利解决


    override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
    if (e!!.action == MotionEvent.ACTION_DOWN) {
    val childRecyclerView = findCurrentChildRecyclerView()

    // 1. 是否禁止拦截
    doNotInterceptTouchEvent = doNotInterceptTouch(e.rawY, childRecyclerView)

    // 2. 停止Fling
    this.stopFling()
    childRecyclerView?.stopFling()
    }

    return if (doNotInterceptTouchEvent) {
    false
    } else {
    super.onInterceptTouchEvent(e)
    }
    }


    • 根据业务场景,也可使用baserecyclerviewadapterhelper,一个优秀的Adapter 框架, 


    addHeaderView来添加itemView,通过 notifyItemInserted(position) 添加ReceiveView 的item


    @JvmOverloads
    fun addHeaderView(view: View, index: Int = -1, orientation: Int = LinearLayout.VERTICAL): Int {
    if (!this::mHeaderLayout.isInitialized) {
    mHeaderLayout = LinearLayout(view.context)
    mHeaderLayout.orientation = orientation
    mHeaderLayout.layoutParams = if (orientation == LinearLayout.VERTICAL) {
    RecyclerView.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
    } else {
    RecyclerView.LayoutParams(WRAP_CONTENT, MATCH_PARENT)
    }
    }

    val childCount = mHeaderLayout.childCount
    var mIndex = index
    if (index < 0 || index > childCount) {
    mIndex = childCount
    }
    mHeaderLayout.addView(view, mIndex)
    if (mHeaderLayout.childCount == 1) {
    val position = headerViewPosition
    if (position != -1) {
    notifyItemInserted(position)
    }
    }
    return mIndex
    }
    收起阅读 »

    优雅地封装 Activity Result API,完美地替代 startActivityForResult()

    前言 Activity Result API。这是官方用于替代 startActivityForResult() 和 onActivityResult() 的。虽然出了有大半年了,但是个人到现在没看到比较好用的封装。最初大多数人会用拓展函数进行封装,而在 a...
    继续阅读 »

    前言


    Activity Result API。这是官方用于替代 startActivityForResult()onActivityResult() 的。虽然出了有大半年了,但是个人到现在没看到比较好用的封装。最初大多数人会用拓展函数进行封装,而在 activity-ktx:1.2.0-beta02 版本之后,调用注册方法的时机必须在 onStart() 之前,原来的拓展函数就不适用了,在这之后就没看到有人进行封装了。


    个人对 Activity Result API 的封装思考了很久,已经尽量做到在 Kotlin 和 Java 都足够地好用,可以完美替代 startActivityForResult() 了。下面带着大家一起来封装 Activity Result API。


    基础用法


    首先要先了解基础的用法,在 ComponentActivity 或 Fragment 中调用 Activity Result API 提供的 registerForActivityResult() 方法注册结果回调(在 onStart() 之前调用)。该方法接收 ActivityResultContract 和 ActivityResultCallback 参数,返回可以启动另一个 activity 的 ActivityResultLauncher 对象。


    ActivityResultContract 协议类定义生成结果所需的输入类型以及结果的输出类型,Activity Result API 已经提供了很多默认的协议类,方便大家实现请求权限、拍照等常见操作。


    val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
     // Handle the returned Uri
    }

    只是注册回调并不会启动另一个 activity ,还要调用 ActivityResultLauncher#launch() 方法才会启动。传入协议类定义的输入参数,当用户完成后续 activity 的操作并返回时,将执行 ActivityResultCallback 中的 onActivityResult()回调方法。


    getContent.launch("image/*")

    完整的使用代码:


    val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
      // Handle the returned Uri
    }

    override fun onCreate(savedInstanceState: Bundle?) {
      // ...
      selectButton.setOnClickListener {
        getContent.launch("image/*")
      }
    }

    ActivityResultContracts 提供了许多默认的协议类:


    协议类作用
    RequestPermission()请求单个权限
    RequestMultiplePermissions()请求多个权限
    TakePicturePreview()拍照预览,返回 Bitmap
    TakePicture()拍照,返回 Uri
    TakeVideo()录像,返回 Uri
    GetContent()获取单个内容文件
    GetMultipleContents()获取多个内容文件
    CreateDocument()创建文档
    OpenDocument()打开单个文档
    OpenMultipleDocuments()打开多个文档
    OpenDocumentTree()打开文档目录
    PickContact()选择联系人
    StartActivityForResult()通用协议


    我们还可以自定义协议类,继承 ActivityResultContract,定义输入和输出类。如果不需要任何输入,可使用 Void 或 Unit 作为输入类型。需要实现两个方法,用于创建与 startActivityForResult() 配合使用的 Intent 和解析输出的结果。


    class PickRingtone : ActivityResultContract<Int, Uri?>() {
      override fun createIntent(context: Context, ringtoneType: Int) =
        Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
          putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, ringtoneType)
        }

      override fun parseResult(resultCode: Int, result: Intent?) : Uri? {
        if (resultCode != Activity.RESULT_OK) {
          return null
        }
        return result?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
      }
    }

    自定义协议类实现后,就能调用注册方法和 launch() 方法进行使用。


    val pickRingtone = registerForActivityResult(PickRingtone()) { uri: Uri? ->
      // Handle the returned Uri
    }

    pickRingtone.launch(ringtoneType)

    不想自定义协议类的话,可以使用通用的协议 ActivityResultContracts.StartActivityForResult(),实现类似于之前 startActivityForResult() 的功能。


    val startForResult = registerForActivityResult(StartActivityForResult()) { result: ActivityResult ->
      if (result.resultCode == Activity.RESULT_OK) {
          val intent = result.intent
          // Handle the Intent
      }
    }

    startForResult.launch(Intent(this, InputTextActivity::class.java))

    封装思路


    为什么要封装?


    看完上面的用法,不知道大家会不会和我初次了解的时候一样,感觉比原来复杂很多。


    主要是引入的新概念比较多,原来只需要了解 startActivityForResult()onActivityResult() 的用法,现在要了解一大堆类是做什么的,学习成本高了不少。


    用法也有些奇怪,比如官方示例用注册方法得到一个叫 getContent 对象,这更像是函数的命名,还要用这个对象去调用 launch() 方法,代码阅读起来总感觉怪怪的。


    而且有个地方个人觉得不是很好,callback 居然在 registerForActivityResult() 方法里传。个人觉得 callback 在 launch() 方法里传更符合习惯,逻辑也更加连贯,代码阅读性更好。最好改成下面的用法,启动后就接着处理结果的逻辑。


    getContent.launch("image/*") { uri: Uri? ->
     // Handle the returned Uri
    }

    所以还是有必要对 Activity Result API 进行封装的。


    怎么封装?


    首先是修改 callback 传参的位置,实现思路也比较简单,重载 launch() 方法加一个 callback 参数,用个变量缓存起来。在回调的时候拿缓存的 callback 对象去执行。


    private var callback: ActivityResultCallback? = null

    fun launch(input: I?, callback: ActivityResultCallback<O>) {
     this.callback = callback
     launcher.launch(input)
    }

    由于需要缓存 callback 对象,还要写一个类来持有该缓存变量。


    有一个不好处理的问题是 registerForActivityResult() 需要的 onStart() 之前调用。可以通过 lifecycle 在 onCreate() 的时候自动注册,但是个人思考了好久并没有想到更优的实现方式。就是获取 lifecycleOwner 观察声明周期自动注册,也是需要在 onStart() 之前调用,那为什么不直接执行注册方法呢?所以个人改变了思路,不纠结于自动注册,而是简化注册的代码。


    前面说了需要再写一个类缓存 callback 对象,使用一个类的时候有个方法基本会用到,就是构造函数。我们可以在创建对象的时候进行注册。


    注册方法需要 callback 和协议类对象两个参数,callback 是从 launch() 方法得到,而协议类对象就需要传了。这样用起来个人觉得还不够友好,综合考虑后决定用继承的方式把协议类对象给“隐藏”了。


    最终得到以下的基类。


    public class BaseActivityResultLauncher<I, O> {

     private final ActivityResultLauncher launcher;
     private ActivityResultCallback callback;

     public BaseActivityResultLauncher(ActivityResultCaller caller, ActivityResultContract contract) {
       launcher = caller.registerForActivityResult(contract, (result) -> {
         if (callback != null) {
           callback.onActivityResult(result);
           callback = null;
        }
      });
    }

     public void launch(@SuppressLint("UnknownNullness") I input, @NonNull ActivityResultCallback callback) {
       this.callback = callback;
       launcher.launch(input);
    }
    }

    改用了 Java 代码来实现,返回的结果可以判空也可以不判空,比如返回数组的时候一定不为空,只是数组大小为 0 。用 Kotlin 实现的话要写两个不同名的方法来应对这个情况,使用起来并不是很方便。


    这是多增加一个封装的步骤来简化后续的使用,原本只是继承 ActivityResultContract 实现协议类,现在还需要再写一个启动器类继承 BaseActivityResultLauncher


    比如用前面获取图片的示例,我们再封装一个 GetContentLauncher 类。


    class GetContentLauncher(caller: ActivityResultCaller) :
    BaseActivityResultLauncher(caller, GetContent())

    只需这么简单的继承封装,后续使用就更加简洁易用了。


    val getContentLauncher = GetContentLauncher(this)

    override fun onCreate(savedInstanceState: Bundle?) {
      // ...
      selectButton.setOnClickListener {
        getContentLauncher.launch("image/*") { uri: Uri? ->
      // Handle the returned Uri
    }
      }
    }

    再封装一个 Launcher 类的好处是,能更方便地重载 launch() 方法,比如在类里增加一个方法在获取图片之前会先授权读取权限。如果改用 Kotlin 拓展函数来实现,在 Java 会更加难用。Launcher 类能对 Java 用法进行兼顾。


    最后总结一下,对比原本 Activity Result API 的用法,改善了什么问题:



    • 简化冗长的注册代码,改成简单地创建一个对象;

    • 改善对象的命名,比如官方示例命名为 getContent 对象就很奇怪,这通常是函数的命名。优化后很自然地用类名来命名为 getContentLauncher,使用一个启动器对象调用 launch() 方法会更加合理;

    • 改变回调的位置,使其更加符合使用习惯,逻辑更加连贯,代码阅读性更好;

    • 输入参数和输出参数不会限制为一个对象,可以重载方法简化用法;

    • 能更方便地整合多个启动器的功能,比如获取读取权限后再跳转相册选择图片;


    最终用法


    由于 Activity Result API 已有很多的协议类,如果每一个协议都去封装一个启动器类会有点麻烦,所以个人已经写好一个库 ActivityResultLauncher 方便大家使用。还新增和完善了一些功能,有以下特点:



    • 完美替代 startActivityForResult()

    • 支持 Kotlin 和 Java 用法

    • 支持请求权限

    • 支持拍照

    • 支持录像

    • 支持选择图片或视频(已适配 Android 10)

    • 支持裁剪图片(已适配 Android11)

    • 支持打开蓝牙

    • 支持打开定位

    • 支持使用存储访问框架 SAF

    • 支持选择联系人


    个人写了个 Demo 给大家来演示有什么功能,完整的代码在 Github 里。


    demo-qr-code.png


    screenshot


    下面来介绍 Kotlin 的用法,Java 的用法可以查看 Wiki 文档


    在根目录的 build.gradle 添加:


    allprojects {
       repositories {
           // ...
           maven { url 'https://www.jitpack.io' }
      }
    }

    添加依赖:


    dependencies {
       implementation 'com.github.DylanCaiCoding:ActivityResultLauncher:1.0.0'
    }

    用法也只有简单的两步:


    第一步,在 ComponentActivityFragment 创建对应的对象,需要注意创建对象的时机要在 onStart() 之前。例如创建通用的启动器:


    private val startActivityLauncher = StartActivityLauncher(this)

    提供以下默认的启动器类:

    启动器作用
    StartActivityLauncher完美替代 startActivityForResult()
    TakePicturePreviewLauncher调用系统相机拍照预览,只返回 Bitmap
    TakePictureLauncher调用系统相机拍照
    TakeVideoLauncher调用系统相机录像
    PickContentLauncher, GetContentLauncher选择单个图片或视频,已适配 Android 10
    GetMultipleContentsLauncher选择多个图片或视频,已适配 Android 10
    CropPictureLauncher裁剪图片,已适配 Android 11
    RequestPermissionLauncher请求单个权限
    RequestMultiplePermissionsLauncher请求多个权限
    AppDetailsSettingsLauncher打开系统设置的 App 详情页
    EnableBluetoothLauncher打开蓝牙
    EnableLocationLauncher打开定位
    CreateDocumentLauncher创建文档
    OpenDocumentLauncher打开单个文档
    OpenMultipleDocumentsLauncher打开多个文档
    OpenDocumentTreeLauncher访问目录内容
    PickContactLauncher选择联系人
    StartIntentSenderLauncher替代 startIntentSender()


    第二步,调用启动器对象的 launch() 方法。


    比如跳转一个输入文字的页面,点击保存按钮回调结果。我们替换掉原来 startActivityForResult() 的写法。


    val intent = Intent(this, InputTextActivity::class.java)
    intent.putExtra(KEY_NAME, "nickname")
    startActivityLauncher.launch(intent) { activityResult ->
    if (activityResult.resultCode == RESULT_OK) {
    data?.getStringExtra(KEY_VALUE)?.let { toast(it) }
    }
    }

    为了方便使用,有些启动器会增加一些更易用的 launch() 方法。比如这个例子能改成下面更简洁的写法。


    startActivityLauncher.launch(KEY_NAME to "nickname") { resultCode, data ->
    if (resultCode == RESULT_OK) {
    data?.getStringExtra(KEY_VALUE)?.let { toast(it) }
    }
    }

    由于输入文字页面可能有多个地方需要跳转复用,我们可以用前面的封装思路,自定义实现一个 InputTextLauncher 类,进一步简化调用的代码,只关心输入值和输出值,不用再处理跳转和解析过程。


    inputTextLauncher.launch("nickname") { value ->
    if (value != null) {
    toast(value)
    }
    }

    通常要对返回值进行判断,因为可能会有取消操作,要判断是不是被取消了。比如返回的 Boolean 要为 true,返回的 Uri 不为 null,返回的数组不为空数组等。


    还有一些常用的功能,比如调用系统相机拍照和跳转系统相册选择图片,已适配 Android 10,可以直接得到 uri 来加载图片和用 file 进行上传等操作。


    takePictureLauncher.launch { uri, file ->
    if (uri != null && file != null) {
    // 上传或取消等操作后建议把缓存文件删除,调用 file.delete()
    }
    }

    pickContentLauncher.launchForImage(
    onActivityResult = { uri, file ->
    if (uri != null && file != null) {
    // 上传或取消等操作后建议把缓存文件删除,调用 file.delete()
    }
    },
    onPermissionDenied = {
    // 拒绝了读取权限且不再询问,可引导用户到设置里授权该权限
    },
    onExplainRequestPermission = {
    // 拒绝了一次读取权限,可弹框解释为什么要获取该权限
    }
    )

    个人也新增了些功能,比如裁剪图片,通常上传头像要裁剪成 1:1 比例,已适配 Android 11。


    cropPictureLauncher.launch(inputUri) { uri, file ->
    if (uri != null && file != null) {
    // 上传或取消等操作后建议把缓存文件删除,调用 file.delete()
    }
    }

    还有开启蓝牙功能,能更容易地开启蓝牙和确保蓝牙功能是可用的(需要授权定位权限和确保定位已打开)。


    enableBluetoothLauncher.launchAndEnableLocation(
    "为保证蓝牙正常使用,请开启定位", // 已授权权限但未开启定位,会跳转对应设置页面,并吐司该字符串
    onLocationEnabled= { enabled ->
    if (enabled) {
    // 已开启了蓝牙,并且授权了位置权限和打开了定位
    }
    },
    onPermissionDenied = {
    // 拒绝了位置权限且不再询问,可引导用户到设置里授权该权限
    },
    onExplainRequestPermission = {
    // 拒绝了一次位置权限,可弹框解释为什么要获取该权限
    }
    )

    更多的用法请查看 Wiki 文档


    原本 Activity Result API 已经有很多默认的协议类,都封装了对应的启动器类。大家可能不会用到所有类,开了混淆会自动移除没使用到的类。


    彩蛋


    个人之前封装过一个 startActivityForResult() 拓展函数,可以直接在后面写回调逻辑。


    startActivityForResult(intent, requestCode) { resultCode, data ->
    // Handle result
    }

    下面是实现的代码,使用一个 Fragment 来分发 onActivityResult 的结果。代码量不多,逻辑应该比较清晰,感兴趣的可以了解一下,Activity Result API 的实现原理应该也是类似的。


    inline fun FragmentActivity.startActivityForResult(
    intent:
    Intent,
    requestCode:
    Int,
    noinline callback: (resultCode: Int, data: Intent?) -> Unit
    )
    =
    DispatchResultFragment.getInstance(this).startActivityForResult(intent, requestCode, callback)

    class DispatchResultFragment : Fragment() {
    private val callbacks = SparseArray<(resultCode: Int, data: Intent?) -> Unit>()

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    retainInstance = true
    }

    fun startActivityForResult(
    intent:
    Intent,
    requestCode:
    Int,
    callback: (
    resultCode: Int, data: Intent?) -> Unit
    )
    {
    callbacks.put(requestCode, callback)
    startActivityForResult(intent, requestCode)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    val callback = callbacks.get(requestCode)
    if (callback != null) {
    callback.invoke(resultCode, data)
    callbacks.remove(requestCode)
    }
    }

    companion object {
    private const val TAG = "dispatch_result"

    fun getInstance(activity: FragmentActivity): DispatchResultFragment =
    activity.run {
    val fragmentManager = supportFragmentManager
    var fragment = fragmentManager.findFragmentByTag(TAG) as DispatchResultFragment?
    if (fragment == null) {
    fragment = DispatchResultFragment()
    fragmentManager.beginTransaction().add(fragment, TAG).commitAllowingStateLoss()
    fragmentManager.executePendingTransactions()
    }
    fragment
    }
    }
    }

    如果觉得 Activity Result API 比较复杂,也可以拷贝这个去用。不过 requestCode 处理得不够好,而且很多功能需要自己额外去实现,用起来可能没那么方便。



    收起阅读 »

    最优解前端面试题答法

    1. JS事件冒泡和事件代理(委托) 1. 事件冒泡 会从当前触发的事件目标一级一级往上传递,依次触发,直到document为止。 <body> <div id="parentId"> 查看消息信息 <div id="chi...
    继续阅读 »

    1. JS事件冒泡和事件代理(委托)


    1. 事件冒泡


    会从当前触发的事件目标一级一级往上传递,依次触发,直到document为止。


    <body>    <div id="parentId"> 查看消息信息 <div id="childId1"> 删除消息信息 </div>    </div></body><script>    let parent = document.getElementById('parentId');    let childId1 = document.getElementById('childId1');    parent.addEventListener('click', function () {        alert('查看消息信息');    }, false);    childId1.addEventListener('click', function () {        alert('删除消息信息');    }, false);     // 如出发消息列表里的删除按钮, 先执行了删除操作, 在向上冒泡执行‘ 查看消息信息’。        // 打印:删除消息信息 查看消息信息</script>

    原生js取消事件冒泡


       try{
    e.stopPropagation();//非IE浏览器
    }
    catch(e){
    window.event.cancelBubble = true;//IE浏览器
    }

    vue.js取消事件冒泡


    <div @click.stop="doSomething($event)">vue取消事件冒泡</div>

    2. 事件代理(委托)


    a. 为什么要用事件委托:


    比如ul下有100个li,用for循环遍历所有的li,然后给它们添加事件,需要不断的与dom节点进行交互,访问dom的次数越多,引起浏览器重绘与重排的次数也就越多,就会延长整个页面的交互就绪时间,这就是为什么性能优化的主要思想之一就是减少DOM操作的原因;


    如果要用事件委托,就会将所有的操作放到js程序里面,与dom的操作就只需要交互一次,这样就能大大的减少与dom的交互次数,提高性能;


    b. 事件委托的原理


    事件委托:利用事件冒泡的特性,将本应该注册在子元素上的处理事件注册在父元素上,这样点击子元素时发现其本身没有相应事件就到父元素上寻找作出相应。这样做的优势有:


    1、减少DOM操作,提高性能。


    2、随时可以添加子元素,添加的子元素会自动有相应的处理事件。


    <div id="box">
    <input type="button" id="add" value="添加" />
    <input type="button" id="remove" value="删除" />
    <input type="button" id="move" value="移动" />
    <input type="button" id="select" value="选择" />
    </div>
    方式一:需要4次dom操作
    window.onload = function () {
    var Add = document.getElementById("add");
    var Remove = document.getElementById("remove");
    var Move = document.getElementById("move");
    var Select = document.getElementById("select");
    Add.onclick = function () { alert('添加'); }; Remove.onclick = function () { alert('删除'); }; Move.onclick = function () { alert('移动'); }; Select.onclick = function () { alert('选择'); } }
    方式二:委托它们父级代为执行事件
    window.onload = function(){
    var oBox = document.getElementById("box");
    oBox.onclick = function (ev) {
    var ev = ev || window.event;
    var target = ev.target || ev.srcElement;
    if(target.nodeName.toLocaleLowerCase() == 'input'){
    switch(target.id){
    case 'add' :
    alert('添加');
    break;
    case 'remove' :
    alert('删除');
    break;
    case 'move' :
    alert('移动');
    break;
    case 'select' :
    alert('选择');
    break;
    }
    }
    }

    }
    用事件委托就可以只用一次dom操作就能完成所有的效果,比上面的性能肯定是要好一些的

    3. 事件捕获


    会从document开始触发,一级一级往下传递,依次触发,直到真正事件目标为止。


        <div> <button>            <p>点击捕获</p>        </button></div>    <script>        var oP = document.querySelector('p');        var oB = document.querySelector('button');        var oD = document.querySelector('div');        var oBody = document.querySelector('body');        oP.addEventListener('click', function () {            console.log('p标签被点击')        }, true);        oB.addEventListener('click', function () {            console.log("button被点击")        }, true);        oD.addEventListener('click', function () {            console.log('div被点击')        }, true);        oBody.addEventListener('click', function () {            console.log('body被点击')        }, true);    </script>    点击<p>点击捕获</p>,打印的顺序是:body=>div=>button=>p</body>

    流程:先捕获,然后处理,然后再冒泡出去。


    2. 原型链



    1. 原型对象诞生原因和本质


    为了解决无法共享公共属性的问题,所以要设计一个对象专门用来存储对象共享的属性,那么我们叫它「原型对象


    原理:构造函数加一个属性叫做prototype,用来指向原型对象,我们把所有实例对象共享的属性和方法都放在这个构造函数的prototype属性指向的原型对象中,不需要共享的属性和方法放在构造函数中。实现构造函数生成的所有实例对象都能够共享属性。


    构造函数:私有属性
    原型对象:共有属性

    2.  彼此之间的关系


    构造函数中一属性prototype:指向原型对象,而原型对象一constructor属性,又指回了构造函数。

    每个构造函数生成的实例对象都有一个proto属性,这个属性指向原型对象。


    那原型对象的_proto_属性指向谁?-> null


    3. 原型链是什么?


    顾名思义,肯定是一条链,既然每个对象都有一个_proto_属性指向原型对象,那么原型对象也有_proto_指向原型对象的原型对象,直到指向上图中的null,这才到达原型链的顶端。


    4. 原型链和继承使用场景


    原型链主要用于继承,实现代码复用。因为js算不上是面向对象的语言,继承是基于原型实现而不是基于类实现的,


    a. 判断函数的原型是否在对象的原型链上


    对象 instanceof 函数(不推荐使用)


    b. 创建一个新对象,该新对象的隐式原型指向指定的对象


    Object.create(对象)


    var obj = Object.create(Object.prototype);


    obj.__proto__ === Object.prototype


    c. new的实现


    d. es6的class A extends B 


    因为es6-没有类和继承的概念。js实现继承本质是把js中的对象构造函数在自己的脑中抽象成一个类,然后使用构造函数的protptype属性封装出一个类(另一个构造函数),使之完美继承前一构造函数的所有属性和方法。因为构造函数能new出一个具体的对象实例,这就在js中实现了现代化的面向对象和继承。


    3. 闭包和垃圾回收机制


    闭包的概念


      function f1(){    var n=999;    function f2(){      alert(n);    }    return f2;  }  var result=f1();  result(); // 999

    在上面的代码中,函数f2就被包括在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。但是反过来就不行,f2内部的局部变量,对f1就是不可见的。这就是Javascript语言特有的"链式作用域"结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。


    既然f2可以读取f1中的局部变量,那么只要把f2作为返回值,我们不就可以在f1外部读取它的内部变量了吗!


    作用:一是前面提到的可以读取函数内部的变量,二是让这些变量的值始终保持在内存中,主要用来封装私有变量, 提供一些暴露的接口


    垃圾回收


    **垃圾回收机制:JavaScript 引擎中有一个后台进程称为垃圾回收器,它监视所有对象,并删除那些不可访问的对象

    **


    **注意点:**对于内存的管理,Javascript与C语言等底层语言JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理,


    实现的原理:由于 f2 中引用了 相对于自己的全局变量 n ,所以 f2 会一直存在内存中,又因为 n 是 f1 中的局部变量,也就是说 f2 依赖 f1,所以说 f1 也会一直存在内存中,并不像普通函数那样,调用后变量便被垃圾回收了。


    所以说,在setTimeout中的函数引用了外层 for循环的变量 i,导致 i 一直存在内存中,不被回收,所以等到JS队列执行 函数时,i 已经是 10了,所以最终打印 10个10。


    五、使用闭包的注意点


    1)由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。


    for (var i=1; i<=5; i++) {
    setTimeout( function timer() {
    console.log( i );
    }, i*1000 );
    }
    打印:5个6,原因js事件执行机制
    办法一:
    for (var i=1; i<=5; i++) {
    (function(j) {
    setTimeout( function timer() {
    console.log( j );
    }, j*1000 );
    })(i);
    }
    打印:依次输出1到5
    原因:因为实际参数跟定时器内部的i有强依赖。通过闭包,将i的变量驻留在内存中,当输出j时,
    引用的是外部函数的变量值i,i的值是根据循环来的,执行setTimeout时已经确定了里面的的输出了。办法二:
    for (let i=1; i<=5; i++) {
    setTimeout( function timer() {
    console.log( i );
    }, i*1000 );
    }
    打印:依次输出1到5因为for循环头部的let不仅将i绑定到for循环中,事实上它将其重新绑定到循环体的每一次迭代中,
    确保上一次迭代结束的值重新被赋值。
    setTimeout里面的function()属于一个新的域,
    通过var定义的变量是无法传入到这个函数执行域中的,
    通过使用let来声明块变量能作用于这个块,所以function就能使用i这个变量了;
    这个匿名函数的参数作用域和for参数的作用域不一样,是利用了这一点来完成的。
    这个匿名函数的作用域有点类似类的属性,是可以被内层方法使用的。

    4. js事件执行机制


    事件循环的过程如下:



    1. JS引擎(唯一主线程)按顺序解析代码,遇到函数声明,直接跳过,遇到函数调用,入栈;

    2. 如果是同步函数调用,直接执行得到结果,同步函数弹出栈,继续下一个函数调用;

    3. 如果是异步函数调用,分发给Web API(多个辅助线程),异步函数弹出栈,继续下一个函数调用;

    4. Web API中,异步函数在相应辅助线程中处理完成后,即异步函数达到触发条件了(比如setTimeout设置的10s后),如果异步函数是宏任务,则入宏任务消息队列,如果是微任务,则入微任务消息队列;

    5. Event Loop不停地检查主线程的调用栈与回调队列,当调用栈空时,就把微任务消息队列中的第一个任务推入栈中执行,执行完成后,再取第二个微任务,直到微任务消息队列为空;然后

      去宏任务消息队列中取第一个宏任务推入栈中执行,当该宏任务执行完成后,在下一个宏任务执行前,再依次取出微任务消息队列中的所有微任务入栈执行。

    6. 上述过程不断循环,每当微任务队列清空,可作为本轮事件循环的结束。




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

    收起阅读 »

    项目中实用的前端性能优化

    一、CDN 1. CDN的概念 CDN(Content Delivery Network,内容分发网络)是指一种通过互联网互相连接的电脑网络系统,利用最靠近每位用户的服务器,更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户,来提供高性能、可扩展性...
    继续阅读 »

    一、CDN


    1. CDN的概念


    CDN(Content Delivery Network,内容分发网络)是指一种通过互联网互相连接的电脑网络系统,利用最靠近每位用户的服务器,更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户,来提供高性能、可扩展性及低成本的网络内容传递给用户。


    典型的CDN系统由下面三个部分组成:



    • 分发服务系统: 最基本的工作单元就是Cache设备,cache(边缘cache)负责直接响应最终用户的访问请求,把缓存在本地的内容快速地提供给用户。同时cache还负责与源站点进行内容同步,把更新的内容以及本地没有的内容从源站点获取并保存在本地。Cache设备的数量、规模、总服务能力是衡量一个CDN系统服务能力的最基本的指标。

    • 负载均衡系统: 主要功能是负责对所有发起服务请求的用户进行访问调度,确定提供给用户的最终实际访问地址。两级调度体系分为全局负载均衡(GSLB)和本地负载均衡(SLB)。全局负载均衡主要根据用户就近性原则,通过对每个服务节点进行“最优”判断,确定向用户提供服务的cache的物理位置。本地负载均衡主要负责节点内部的设备负载均衡

    • **运营管理系统:**运营管理系统分为运营管理和网络管理子系统,负责处理业务层面的与外界系统交互所必须的收集、整理、交付工作,包含客户管理、产品管理、计费管理、统计分析等功能。


    2. CDN的作用


    CDN一般会用来托管Web资源(包括文本、图片和脚本等),可供下载的资源(媒体文件、软件、文档等),应用程序(门户网站等)。使用CDN来加速这些资源的访问。


    (1)在性能方面,引入CDN的作用在于:



    • 用户收到的内容来自最近的数据中心,延迟更低,内容加载更快

    • 部分资源请求分配给了CDN,减少了服务器的负载


    (2)在安全方面,CDN有助于防御DDoS、MITM等网络攻击:



    • 针对DDoS:通过监控分析异常流量,限制其请求频率

    • 针对MITM:从源服务器到 CDN 节点到 ISP(Internet Service Provider),全链路 HTTPS 通信


    除此之外,CDN作为一种基础的云服务,同样具有资源托管、按需扩展(能够应对流量高峰)等方面的优势。


    3. CDN的原理


    CDN和DNS有着密不可分的联系,先来看一下DNS的解析域名过程,在浏览器输入 http://www.test.com 的解析过程如下:


    (1) 检查浏览器缓存


    (2)检查操作系统缓存,常见的如hosts文件


    (3)检查路由器缓存


    (4)如果前几步都没没找到,会向ISP(网络服务提供商)的LDNS服务器查询


    (5)如果LDNS服务器没找到,会向根域名服务器(Root Server)请求解析,分为以下几步:



    • 根服务器返回顶级域名(TLD)服务器如.com.cn.org等的地址,该例子中会返回.com的地址

    • 接着向顶级域名服务器发送请求,然后会返回次级域名(SLD)服务器的地址,本例子会返回.test的地址

    • 接着向次级域名服务器发送请求,然后会返回通过域名查询到的目标IP,本例子会返回http://www.test.com的地址

    • Local DNS Server会缓存结果,并返回给用户,缓存在系统中


    CDN的工作原理:


    (1)用户未使用CDN缓存资源的过程:



    1. 浏览器通过DNS对域名进行解析(就是上面的DNS解析过程),依次得到此域名对应的IP地址

    2. 浏览器根据得到的IP地址,向域名的服务主机发送数据请求

    3. 服务器向浏览器返回响应数据


    (2)用户使用CDN缓存资源的过程:



    1. 对于点击的数据的URL,经过本地DNS系统的解析,发现该URL对应的是一个CDN专用的DNS服务器,DNS系统就会将域名解析权交给CNAME指向的CDN专用的DNS服务器。

    2. CND专用DNS服务器将CND的全局负载均衡设备IP地址返回给用户

    3. 用户向CDN的全局负载均衡设备发起数据请求

    4. CDN的全局负载均衡设备根据用户的IP地址,以及用户请求的内容URL,选择一台用户所属区域的区域负载均衡设备,告诉用户向这台设备发起请求

    5. 区域负载均衡设备选择一台合适的缓存服务器来提供服务,将该缓存服务器的IP地址返回给全局负载均衡设备

    6. 全局负载均衡设备把服务器的IP地址返回给用户

    7. 用户向该缓存服务器发起请求,缓存服务器响应用户的请求,将用户所需内容发送至用户终端。


    如果缓存服务器没有用户想要的内容,那么缓存服务器就会向它的上一级缓存服务器请求内容,以此类推,直到获取到需要的资源。最后如果还是没有,就会回到自己的服务器去获取资源。


    image


    CNAME(意为:别名):在域名解析中,实际上解析出来的指定域名对应的IP地址,或者该域名的一个CNAME,然后再根据这个CNAME来查找对应的IP地址。


    4. CDN的使用场景



    • **使用第三方的CDN服务:**如果想要开源一些项目,可以使用第三方的CDN服务

    • **使用CDN进行静态资源的缓存:**将自己网站的静态资源放在CDN上,比如js、css、图片等。可以将整个项目放在CDN上,完成一键部署。

    • **直播传送:**直播本质上是使用流媒体进行传送,CDN也是支持流媒体传送的,所以直播完全可以使用CDN来提高访问速度。CDN在处理流媒体的时候与处理普通静态文件有所不同,普通文件如果在边缘节点没有找到的话,就会去上一层接着寻找,但是流媒体本身数据量就非常大,如果使用回源的方式,必然会带来性能问题,所以流媒体一般采用的都是主动推送的方式来进行。


    二、懒加载


    1. 懒加载的概念


    懒加载也叫做延迟加载、按需加载,指的是在长网页中延迟加载图片数据,是一种较好的网页性能优化的方式。在比较长的网页或应用中,如果图片很多,所有的图片都被加载出来,而用户只能看到可视窗口的那一部分图片数据,这样就浪费了性能。


    如果使用图片的懒加载就可以解决以上问题。在滚动屏幕之前,可视化区域之外的图片不会进行加载,在滚动屏幕时才加载。这样使得网页的加载速度更快,减少了服务器的负载。懒加载适用于图片较多,页面列表较长(长列表)的场景中。


    2. 懒加载的特点



    • 减少无用资源的加载:使用懒加载明显减少了服务器的压力和流量,同时也减小了浏览器的负担。

    • 提升用户体验: 如果同时加载较多图片,可能需要等待的时间较长,这样影响了用户体验,而使用懒加载就能大大的提高用户体验。

    • 防止加载过多图片而影响其他资源文件的加载 :会影响网站应用的正常使用。


    3. 懒加载的实现原理


    图片的加载是由src引起的,当对src赋值时,浏览器就会请求图片资源。根据这个原理,我们使用HTML5 的data-xxx属性来储存图片的路径,在需要加载图片的时候,将data-xxx中图片的路径赋值给src,这样就实现了图片的按需加载,即懒加载。


    注意:data-xxx 中的xxx可以自定义,这里我们使用data-src来定义。


    懒加载的实现重点在于确定用户需要加载哪张图片,在浏览器中,可视区域内的资源就是用户需要的资源。所以当图片出现在可视区域时,获取图片的真实地址并赋值给图片即可。


    使用原生JavaScript实现懒加载:


    知识点:


    (1)window.innerHeight 是浏览器可视区的高度


    (2)document.body.scrollTop || document.documentElement.scrollTop 是浏览器滚动的过的距离


    (3)imgs.offsetTop 是元素顶部距离文档顶部的高度(包括滚动条的距离)


    (4)图片加载条件:img.offsetTop < window.innerHeight + document.body.scrollTop;


    图示:


    image


    代码实现:


    <div>
    <img src="loading.gif" data-src="pic.png">
    <img src="loading.gif" data-src="pic.png">
    <img src="loading.gif" data-src="pic.png">
    <img src="loading.gif" data-src="pic.png">
    <img src="loading.gif" data-src="pic.png">
    <img src="loading.gif" data-src="pic.png">
    </div>
    <script>
    var imgs = document.querySelectorAll('img');
    function lozyLoad(){
    var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
    var winHeight= window.innerHeight;
    for(var i=0;i < imgs.length;i++){
    if(imgs[i].offsetTop < scrollTop + winHeight ){
    imgs[i].src = imgs[i].getAttribute('data-src');
    }
    }
    }
    window.onscroll = lozyLoad;
    </script>

    4. 懒加载与预加载的区别


    这两种方式都是提高网页性能的方式,两者主要区别是一个是提前加载,一个是迟缓甚至不加载。懒加载对服务器前端有一定的缓解压力作用,预加载则会增加服务器前端压力。



    • 懒加载也叫延迟加载,指的是在长网页中延迟加载图片的时机,当用户需要访问时,再去加载,这样可以提高网站的首屏加载速度,提升用户的体验,并且可以减少服务器的压力。它适用于图片很多,页面很长的电商网站的场景。懒加载的实现原理是,将页面上的图片的 src 属性设置为空字符串,将图片的真实路径保存在一个自定义属性中,当页面滚动的时候,进行判断,如果图片进入页面可视区域内,则从自定义属性中取出真实路径赋值给图片的 src 属性,以此来实现图片的延迟加载。

    • 预加载指的是将所需的资源提前请求加载到本地,这样后面在需要用到时就直接从缓存取资源。 通过预加载能够减少用户的等待时间,提高用户的体验。我了解的预加载的最常用的方式是使用 js 中的 image 对象,通过为 image 对象来设置 scr 属性,来实现图片的预加载。


    三、回流与重绘


    1. 回流与重绘的概念及触发条件


    (1)回流


    当渲染树中部分或者全部元素的尺寸、结构或者属性发生变化时,浏览器会重新渲染部分或者全部文档的过程就称为回流


    下面这些操作会导致回流:



    • 页面的首次渲染

    • 浏览器的窗口大小发生变化

    • 元素的内容发生变化

    • 元素的尺寸或者位置发生变化

    • 元素的字体大小发生变化

    • 激活CSS伪类

    • 查询某些属性或者调用某些方法

    • 添加或者删除可见的DOM元素


    在触发回流(重排)的时候,由于浏览器渲染页面是基于流式布局的,所以当触发回流时,会导致周围的DOM元素重新排列,它的影响范围有两种:



    • 全局范围:从根节点开始,对整个渲染树进行重新布局

    • 局部范围:对渲染树的某部分或者一个渲染对象进行重新布局


    (2)重绘


    当页面中某些元素的样式发生变化,但是不会影响其在文档流中的位置时,浏览器就会对元素进行重新绘制,这个过程就是重绘


    下面这些操作会导致回流:



    • color、background 相关属性:background-color、background-image 等

    • outline 相关属性:outline-color、outline-width 、text-decoration

    • border-radius、visibility、box-shadow


    注意: 当触发回流时,一定会触发重绘,但是重绘不一定会引发回流。


    2. 如何避免回流与重绘?


    减少回流与重绘的措施:



    • 操作DOM时,尽量在低层级的DOM节点进行操作

    • 不要使用table布局, 一个小的改动可能会使整个table进行重新布局

    • 使用CSS的表达式

    • 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。

    • 使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素

    • 避免频繁操作DOM,可以创建一个文档片段documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中

    • 将元素先设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。

    • 将DOM的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制


    浏览器针对页面的回流与重绘,进行了自身的优化——渲染队列


    浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。


    上面,将多个读操作(或者写操作)放在一起,就会等所有的读操作进入队列之后执行,这样,原本应该是触发多次回流,变成了只触发一次回流。


    3. 如何优化动画?


    对于如何优化动画,我们知道,一般情况下,动画需要频繁的操作DOM,就就会导致页面的性能问题,我们可以将动画的position属性设置为absolute或者fixed,将动画脱离文档流,这样他的回流就不会影响到页面了。


    4. documentFragment 是什么?用它跟直接操作 DOM 的区别是什么?


    MDN中对documentFragment的解释:



    DocumentFragment,文档片段接口,一个没有父对象的最小文档对象。它被作为一个轻量版的 Document使用,就像标准的document一样,存储由节点(nodes)组成的文档结构。与document相比,最大的区别是DocumentFragment不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的重新渲染,且不会导致性能等问题。



    当我们把一个 DocumentFragment 节点插入文档树时,插入的不是 DocumentFragment 自身,而是它的所有子孙节点。在频繁的DOM操作时,我们就可以将DOM元素插入DocumentFragment,之后一次性的将所有的子孙节点插入文档中。和直接操作DOM相比,将DocumentFragment 节点插入DOM树时,不会触发页面的重绘,这样就大大提高了页面的性能。


    四、节流与防抖


    1. 对节流与防抖的理解



    • 函数防抖是指在事件被触发 n 秒后再执行回调,如果在这 n 秒内事件又被触发,则重新计时。这可以使用在一些点击请求的事件上,避免因为用户的多次点击向后端发送多次请求。

    • 函数节流是指规定一个单位时间,在这个单位时间内,只能有一次触发事件的回调函数执行,如果在同一个单位时间内某事件被触发多次,只有一次能生效。节流可以使用在 scroll 函数的事件监听上,通过事件节流来降低事件调用的频率。


    防抖函数的应用场景:



    • 按钮提交场景:防⽌多次提交按钮,只执⾏最后提交的⼀次

    • 服务端验证场景:表单验证需要服务端配合,只执⾏⼀段连续的输⼊事件的最后⼀次,还有搜索联想词功能类似⽣存环境请⽤lodash.debounce


    节流函数的****适⽤场景:



    • 拖拽场景:固定时间内只执⾏⼀次,防⽌超⾼频次触发位置变动

    • 缩放场景:监控浏览器resize

    • 动画场景:避免短时间内多次触发动画引起性能问题


    2. 实现节流函数和防抖函数


    函数防抖的实现:


    function debounce(fn, wait) {
    var timer = null;

    return function() {
    var context = this,
    args = [...arguments];

    // 如果此时存在定时器的话,则取消之前的定时器重新记时
    if (timer) {
    clearTimeout(timer);
    timer = null;
    }

    // 设置定时器,使事件间隔指定事件后执行
    timer = setTimeout(() => {
    fn.apply(context, args);
    }, wait);
    };
    }

    函数节流的实现:


    // 时间戳版
    function throttle(fn, delay) {
    var preTime = Date.now();

    return function() {
    var context = this,
    args = [...arguments],
    nowTime = Date.now();

    // 如果两次时间间隔超过了指定时间,则执行函数。
    if (nowTime - preTime >= delay) {
    preTime = Date.now();
    return fn.apply(context, args);
    }
    };
    }

    // 定时器版
    function throttle (fun, wait){
    let timeout = null
    return function(){
    let context = this
    let args = [...arguments]
    if(!timeout){
    timeout = setTimeout(() => {
    fun.apply(context, args)
    timeout = null
    }, wait)
    }
    }
    }

    五、图片优化


    1. 如何对项目中的图片进行优化?



    1. 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。

    2. 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。

    3. 小图使用 base64 格式

    4. 将多个图标文件整合到一张图片中(雪碧图)

    5. 选择正确的图片格式:





      • 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好

      • 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替

      • 照片使用 JPEG




    2. 常见的图片格式及使用场景


    (1)BMP,是无损的、既支持索引色也支持直接色的点阵图。这种图片格式几乎没有对数据进行压缩,所以BMP格式的图片通常是较大的文件。


    (2)GIF是无损的、采用索引色的点阵图。采用LZW压缩算法进行编码。文件小,是GIF格式的优点,同时,GIF格式还具有支持动画以及透明的优点。但是GIF格式仅支持8bit的索引色,所以GIF格式适用于对色彩要求不高同时需要文件体积较小的场景。


    (3)JPEG是有损的、采用直接色的点阵图。JPEG的图片的优点是采用了直接色,得益于更丰富的色彩,JPEG非常适合用来存储照片,与GIF相比,JPEG不适合用来存储企业Logo、线框类的图。因为有损压缩会导致图片模糊,而直接色的选用,又会导致图片文件较GIF更大。


    (4)PNG-8是无损的、使用索引色的点阵图。PNG是一种比较新的图片格式,PNG-8是非常好的GIF格式替代者,在可能的情况下,应该尽可能的使用PNG-8而不是GIF,因为在相同的图片效果下,PNG-8具有更小的文件体积。除此之外,PNG-8还支持透明度的调节,而GIF并不支持。除非需要动画的支持,否则没有理由使用GIF而不是PNG-8。


    (5)PNG-24是无损的、使用直接色的点阵图。PNG-24的优点在于它压缩了图片的数据,使得同样效果的图片,PNG-24格式的文件大小要比BMP小得多。当然,PNG24的图片还是要比JPEG、GIF、PNG-8大得多。


    (6)SVG是无损的矢量图。SVG是矢量图意味着SVG图片由直线和曲线以及绘制它们的方法组成。当放大SVG图片时,看到的还是线和曲线,而不会出现像素点。这意味着SVG图片在放大时,不会失真,所以它非常适合用来绘制Logo、Icon等。


    (7)WebP是谷歌开发的一种新图片格式,WebP是同时支持有损和无损压缩的、使用直接色的点阵图。从名字就可以看出来它是为Web而生的,什么叫为Web而生呢?就是说相同质量的图片,WebP具有更小的文件体积。现在网站上充满了大量的图片,如果能够降低每一个图片的文件大小,那么将大大减少浏览器和服务器之间的数据传输量,进而降低访问延迟,提升访问体验。目前只有Chrome浏览器和Opera浏览器支持WebP格式,兼容性不太好。



    • 在无损压缩的情况下,相同质量的WebP图片,文件大小要比PNG小26%;

    • 在有损压缩的情况下,具有相同图片精度的WebP图片,文件大小要比JPEG小25%~34%;

    • WebP图片格式支持图片透明度,一个无损压缩的WebP图片,如果要支持透明度只需要22%的格外文件大小。


    六、Webpack优化


    1. 如何提⾼webpack的打包速度**?**


    (1)优化 Loader


    对于 Loader 来说,影响打包效率首当其冲必属 Babel 了。因为 Babel 会将代码转为字符串生成 AST,然后对 AST 继续进行转变最后再生成新的代码,项目越大,转换代码越多,效率就越低。当然了,这是可以优化的。


    首先我们优化 Loader 的文件搜索范围


    module.exports = {
    module: {
    rules: [
    {
    // js 文件才使用 babel
    test: /\.js$/,
    loader: 'babel-loader',
    // 只在 src 文件夹下查找
    include: [resolve('src')],
    // 不会去查找的路径
    exclude: /node_modules/
    }
    ]
    }
    }

    对于 Babel 来说,希望只作用在 JS 代码上的,然后 node_modules 中使用的代码都是编译过的,所以完全没有必要再去处理一遍。


    当然这样做还不够,还可以将 Babel 编译过的文件缓存起来,下次只需要编译更改过的代码文件即可,这样可以大幅度加快打包时间


    loader: 'babel-loader?cacheDirectory=true'

    (2)HappyPack


    受限于 Node 是单线程运行的,所以 Webpack 在打包的过程中也是单线程的,特别是在执行 Loader 的时候,长时间编译的任务很多,这样就会导致等待的情况。


    HappyPack 可以将 Loader 的同步执行转换为并行的,这样就能充分利用系统资源来加快打包效率了


    module: {
    loaders: [
    {
    test: /\.js$/,
    include: [resolve('src')],
    exclude: /node_modules/,
    // id 后面的内容对应下面
    loader: 'happypack/loader?id=happybabel'
    }
    ]
    },
    plugins: [
    new HappyPack({
    id: 'happybabel',
    loaders: ['babel-loader?cacheDirectory'],
    // 开启 4 个线程
    threads: 4
    })
    ]

    (3)DllPlugin


    DllPlugin 可以将特定的类库提前打包然后引入。这种方式可以极大的减少打包类库的次数,只有当类库更新版本才有需要重新打包,并且也实现了将公共代码抽离成单独文件的优化方案。DllPlugin的使用方法如下:


    // 单独配置在一个文件中
    // webpack.dll.conf.js
    const path = require('path')
    const webpack = require('webpack')
    module.exports = {
    entry: {
    // 想统一打包的类库
    vendor: ['react']
    },
    output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].dll.js',
    library: '[name]-[hash]'
    },
    plugins: [
    new webpack.DllPlugin({
    // name 必须和 output.library 一致
    name: '[name]-[hash]',
    // 该属性需要与 DllReferencePlugin 中一致
    context: __dirname,
    path: path.join(__dirname, 'dist', '[name]-manifest.json')
    })
    ]
    }

    然后需要执行这个配置文件生成依赖文件,接下来需要使用 DllReferencePlugin 将依赖文件引入项目中


    // webpack.conf.js
    module.exports = {
    // ...省略其他配置
    plugins: [
    new webpack.DllReferencePlugin({
    context: __dirname,
    // manifest 就是之前打包出来的 json 文件
    manifest: require('./dist/vendor-manifest.json'),
    })
    ]
    }

    (4)代码压缩


    在 Webpack3 中,一般使用 UglifyJS 来压缩代码,但是这个是单线程运行的,为了加快效率,可以使用 webpack-parallel-uglify-plugin 来并行运行 UglifyJS,从而提高效率。


    在 Webpack4 中,不需要以上这些操作了,只需要将 mode 设置为 production 就可以默认开启以上功能。代码压缩也是我们必做的性能优化方案,当然我们不止可以压缩 JS 代码,还可以压缩 HTML、CSS 代码,并且在压缩 JS 代码的过程中,我们还可以通过配置实现比如删除 console.log 这类代码的功能。


    (5)其他


    可以通过一些小的优化点来加快打包速度



    • resolve.extensions:用来表明文件后缀列表,默认查找顺序是 ['.js', '.json'],如果你的导入文件没有添加后缀就会按照这个顺序查找文件。我们应该尽可能减少后缀列表长度,然后将出现频率高的后缀排在前面

    • resolve.alias:可以通过别名的方式来映射一个路径,能让 Webpack 更快找到路径

    • module.noParse:如果你确定一个文件下没有其他依赖,就可以使用该属性让 Webpack 不扫描该文件,这种方式对于大型的类库很有帮助


    2. 如何减少 Webpack 打包体积


    (1)按需加载


    在开发 SPA 项目的时候,项目中都会存在很多路由页面。如果将这些页面全部打包进一个 JS 文件的话,虽然将多个请求合并了,但是同样也加载了很多并不需要的代码,耗费了更长的时间。那么为了首页能更快地呈现给用户,希望首页能加载的文件体积越小越好,这时候就可以使用按需加载,将每个路由页面单独打包为一个文件。当然不仅仅路由可以按需加载,对于 loadash 这种大型类库同样可以使用这个功能。


    按需加载的代码实现这里就不详细展开了,因为鉴于用的框架不同,实现起来都是不一样的。当然了,虽然他们的用法可能不同,但是底层的机制都是一样的。都是当使用的时候再去下载对应文件,返回一个 Promise,当 Promise 成功以后去执行回调。


    (2)Scope Hoisting


    Scope Hoisting 会分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中去。


    比如希望打包两个文件:


    // test.js
    export const a = 1
    // index.js
    import { a } from './test.js'

    对于这种情况,打包出来的代码会类似这样:


    [
    /* 0 */
    function (module, exports, require) {
    //...
    },
    /* 1 */
    function (module, exports, require) {
    //...
    }
    ]

    但是如果使用 Scope Hoisting ,代码就会尽可能的合并到一个函数中去,也就变成了这样的类似代码:


    [
    /* 0 */
    function (module, exports, require) {
    //...
    }
    ]

    这样的打包方式生成的代码明显比之前的少多了。如果在 Webpack4 中你希望开启这个功能,只需要启用 optimization.concatenateModules 就可以了:


    module.exports = {
    optimization: {
    concatenateModules: true
    }
    }

    (3)Tree Shaking


    Tree Shaking 可以实现删除项目中未被引用的代码,比如:


    // test.js
    export const a = 1
    export const b = 2
    // index.js
    import { a } from './test.js'

    对于以上情况,test 文件中的变量 b 如果没有在项目中使用到的话,就不会被打包到文件中。


    如果使用 Webpack 4 的话,开启生产环境就会自动启动这个优化功能。


    3. 如何⽤webpack来优化前端性能?


    ⽤webpack优化前端性能是指优化webpack的输出结果,让打包的最终结果在浏览器运⾏快速⾼效。



    • 压缩代码:删除多余的代码、注释、简化代码的写法等等⽅式。可以利⽤webpack的 UglifyJsPlugin 和 ParallelUglifyPlugin 来压缩JS⽂件, 利⽤ cssnano (css-loader?minimize)来压缩css

    • 利⽤CDN加速: 在构建过程中,将引⽤的静态资源路径修改为CDN上对应的路径。可以利⽤webpack对于 output 参数和各loader的 publicPath 参数来修改资源路径

    • Tree Shaking: 将代码中永远不会⾛到的⽚段删除掉。可以通过在启动webpack时追加参数 --optimize-minimize 来实现

    • Code Splitting: 将代码按路由维度或者组件分块(chunk),这样做到按需加载,同时可以充分利⽤浏览器缓存

    • 提取公共第三⽅库: SplitChunksPlugin插件来进⾏公共模块抽取,利⽤浏览器缓存可以⻓期缓存这些⽆需频繁变动的公共代码


    4. 如何提⾼webpack的构建速度?



    1. 多⼊⼝情况下,使⽤ CommonsChunkPlugin 来提取公共代码

    2. 通过 externals 配置来提取常⽤库

    3. 利⽤ DllPlugin 和 DllReferencePlugin 预编译资源模块 通过 DllPlugin 来对那些我们引⽤但是绝对不会修改的npm包来进⾏预编译,再通过 DllReferencePlugin 将预编译的模块加载进来。

    4. 使⽤ Happypack 实现多线程加速编译

    5. 使⽤ webpack-uglify-parallel 来提升 uglifyPlugin 的压缩速度。 原理上 webpack-uglify-parallel 采⽤了多核并⾏压缩来提升压缩速度

    6. 使⽤ Tree-shaking 和 Scope Hoisting 来剔除多余代码

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

    收起阅读 »

    『前端BUG』—— 本地代理导致会话cookie中的数据丢失

    vue
    问题在本地用代理请求服务端接口,解决跨域问题后,发生了一件极其诡异的事情,明明登录成功了,但是请求每个接口都返回未登录的报错信息。原因该套系统是采用会话cookie进行登录用户的身份认证,故查看每个请求的Request Headers中的cookie的值,发现...
    继续阅读 »

    问题

    在本地用代理请求服务端接口,解决跨域问题后,发生了一件极其诡异的事情,明明登录成功了,但是请求每个接口都返回未登录的报错信息。

    原因

    该套系统是采用会话cookie进行登录用户的身份认证,故查看每个请求的Request Headers中的cookie的值,发现原本如下图中的红框区域的SESSION不见了。

    image.png

    而明明登录接口的Response Headers中是存在set-cookie。

    image.png

    set-cookie会是把其值中的SESSION存储到浏览器的cookie中,存储成功后,每次请求服务端时,都会去浏览器中的cookie中读取SESSION,然后通过Request Headers中的cookie传递到服务端,完成身份认证。

    另外set-cookie的值是服务端设置的,我们来认真观察一下set-cookie的值

    SESSION=NjE1MTNmZWI1N2ExNDYyZGE4MWE0YmZjNjgwMmFmZGY=; Path=/api/operation/; HttpOnly; SameSite=Lax
    复制代码

    里面除SESSION,还有PathHttpOnlySameSite,而Path就是导致SESSION无法存储到客户端中的元凶,其中Path的值/api/operation/表示该cookie只有在用请求路径的前缀为/api/operation/才能使用。

    回到代理配置中一看,

    proxy: getProxy({
    '/dev': {
    target: 'https://xxx.xxx.com',
    pathRewrite: { '^/dev': '/api' },
    secure: false,
    changeOrigin: true
    }
    }),
    复制代码

    代理后,请求服务端的地址为xxx.xxx.com/dev/operati… ,其路径为 dev/operation/xxx,自然与/api/operation/不匹配,导致该cookie无法使用,自然无法将SESSION保存到浏览器的cookie中。

    解决

    找到原因了,问题很好解决,只要更改一下代理配置。

    proxy: getProxy({
    '/api': {
    target: 'https://xxx.xxx.com',
    pathRewrite: { '^/api': '/api' },
    secure: false,
    changeOrigin: true
    }
    }),
    复制代码

    此外不要忘记更改 axios 的配置中的baseURL,将其改为/api/


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

    收起阅读 »

    「自我检验」输入URL发生了啥?希望你顺便懂这15个知识点

    输入URL发生了啥? 1、浏览器的地址栏输入URL并按下回车。 2、浏览器查找当前URL是否存在缓存,并比较缓存是否过期。 3、DNS解析URL对应的IP。 4、根据IP建立TCP连接(三次握手)。 5、HTTP发起请求。 6、服务器处理请求,浏览器接收HT...
    继续阅读 »

    输入URL发生了啥?



    • 1、浏览器的地址栏输入URL并按下回车。

    • 2、浏览器查找当前URL是否存在缓存,并比较缓存是否过期。

    • 3、DNS解析URL对应的IP。

    • 4、根据IP建立TCP连接(三次握手)。

    • 5、HTTP发起请求。

    • 6、服务器处理请求,浏览器接收HTTP响应。

    • 7、渲染页面,构建DOM树。

    • 8、关闭TCP连接(四次挥手)。


    永恒钻石


    1. 浏览器应该具备什么功能?



    • 1、网络:浏览器通过网络模块来下载各式各样的资源,例如HTML文本,JavaScript代码,CSS样式表,图片,音视频文件等。网络部分尤为重要,因为它耗时长,而且需要安全访问互联网上的资源

    • 2、资源管理:从网络下载,或者本地获取到的资源需要有高效的机制来管理他们。例如如何避免重复下载,资源如何缓存等等

    • 3、网页浏览:这是浏览器的核心也是最基本的功能,最重要的功能。这个功能决定了如何将资源转变为可视化的结果

    • 4、多页面管理

    • 5、插件与管理

    • 6、账户和同步

    • 7、安全机制

    • 8、开发者工具


    浏览器的主要功能总结起来就是一句话:将用户输入的url转变成可视化的图像


    2. 浏览器的内核


    在浏览器中有一个最重要的模块,它主要的作用是把一切请求回来的资源变成可视化的图像,这个模块就是浏览器内核,通常他也被称为渲染引擎


    下面是浏览器内核的总结:



    • 1、IE:Trident

    • 2、Safari:WebKit。WebKit本身主要是由两个小引擎构成的,一个正是渲染引擎“WebCore”,另一个则是javascript解释引擎“JSCore”,它们均是从KDE的渲染引擎KHTML及javascript解释引擎KJS衍生而来。

    • 3、Chrome:Blink。在13年发布的Chrome 28.0.1469.0版本开始,Chrome放弃Chromium引擎转而使用最新的Blink引擎(基于WebKit2——苹果公司于2010年推出的新的WebKit引擎),Blink对比上一代的引擎精简了代码、改善了DOM框架,也提升了安全性。

    • 4、Opera:2013年2月宣布放弃Presto,采用Chromium引擎,又转为Blink引擎

    • 5、Firefox:Gecko


    3. 进程和线程



    • 1、进程:程序的一次执行,它占有一片独有的内存空间,是操作系统执行的基本单元

    • 2、线程:是进程内的一个独立执行单元,是CPU调度的最小单元,程序运行基本单元

    • 3、一个进程中至少有一个运行的线程:主线程。它在进程启动后自动创建

    • 4、一个进程可以同时运行多个线程,我们常说程序是多线程运行的,比如你使用听歌软件,这个软件就是一个进程,而你在这个软件里听歌收藏歌点赞评论,这就是一个进程里的多个线程操作

    • 5、一个进程中的数据可以供其中的多个线程直接共享,但是进程与进程之间的数据时不能共享

    • 6、JS引擎是单线程运行


    4. 浏览器渲染引擎的主要模块



    • 1、HTML解析器:解释HTML文档的解析器,主要作用是将HTML文本解释为DOM树

    • 2、CSS解析器:它的作用是为DOM中的各个元素对象计算出样式信息,为布局提供基础设施

    • 3、JavaScript引擎:JavaScript引擎能够解释JavaScript代码,并通过DOM接口和CSS接口来修改网页内容 和样式信息,从而改变渲染的结果

    • 4、布局(layout):在DOM创建之后,WebKit需要将其中的元素对象同样式信息结合起来,计算他们的大小位置等布局信息,形成一个能表达着所有信息的内部表示模型

    • 5、绘图模块(paint):使用图形库将布局计算后的各个网页的节点绘制成图像结果


    5. 大致的渲染过程


    第1题的第7点,渲染页面,构建DOM树,接下来说说大致的渲染过程



    • 1、浏览器会从上到下解析文档

    • 2、遇见HTML标记,调用HTML解析器解析为对应的token(一个token就是一个标签文本的序列化)并构建DOM树(就是一块内存,保存着tokens,建立他们之间的关系)

    • 3、遇见style/link标记调用相应解析器处理CSS标记,并构建出CSS样式树

    • 4、遇见script标记,调用JavaScript引擎处理script标记,绑定事件,修改DOM树/CSS树等

    • 5、将DOM树与CSS合并成一个渲染树

    • 6、根据渲染树来渲染,以计算每个节点的几何信息(这一过程需要依赖GPU)

    • 7、最终将各个节点绘制在屏幕上


    02_浏览器渲染过程的副本.png


    至尊星耀


    6. CSS阻塞情况以及优化



    • 1、style标签中的样式:由HTML解析器进行解析,不会阻塞浏览器渲染(可能会产生“闪屏现象”),不会阻塞DOM解析

    • 2、link引入的CSS样式:由CSS解析器进行解析,阻塞浏览器渲染,会阻塞后面的js语句执行,不阻塞DOM的解析

    • 3、优化:使用CDN节点进行外部资源加速,对CSS进行压缩,优化CSS代码(不要使用太多层选择器)


    注意:看下图,HTMLCSS是并行解析的,所以CSS不会阻塞HTML解析,但是,会阻塞整体页面的渲染(因为最后要渲染必须CSS和HTML一起解析完并合成一处)
    02_浏览器渲染过程的副本.png


    7. JS阻塞问题



    • 1、js会阻塞后续DOM的解析,原因是:浏览器不知道后续脚本的内容,如果先去解析了下面的DOM,而随后的js删除了后面所有的DOM,那么浏览器就做了无用功,浏览器无法预估脚本里面具体做了什么操作,例如像document.write这种操作,索性全部停住,等脚本执行完了,浏览器再继续向下解析DOM

    • 2、js会阻塞页面渲染,原因是:js中也可以给DOM设置样式,浏览器等该脚本执行完毕,渲染出一个最终结果,避免做无用功。

    • 3、js会阻塞后续js的执行,原因是维护依赖关系,例如:必须先引入jQuery再引入bootstrap


    8. 资源加载阻塞


    无论css阻塞,还是js阻塞,都不会阻塞浏览器加载外部资源(图片、视频、样式、脚本等)


    原因:浏览器始终处于一种:“先把请求发出去”的工作模式,只要是涉及到网络请求的内容,无论是:图片、样式、脚本,都会先发送请求去获取资源,至于资源到本地之后什么时候用,由浏览器自己协调。这种做法效率很高。


    9. 为什么CSS解析顺序从右到左


    如果是从左到右的话:



    • 1、第一次从爷节点 -> 子节点 -> 孙节点1

    • 2、第一次从爷节点 -> 子节点 -> 孙节点2

    • 3、第一次从爷节点 -> 子节点 -> 孙节点3


    如果三次都匹配不到的话,那至少也得走三次:爷节点 -> 子节点 -> 孙节点,这就做了很多无用功啊。


    截屏2021-07-18 下午9.33.13.png


    如果是从右到左的话:



    • 1、第一次从孙节点1,找不到,停止

    • 2、第一次从孙节点2,找不到,停止

    • 3、第一次从孙节点3,找不到,停止


    这样的话,尽早发现找不到,尽早停止,可以少了很多无用功。


    截屏2021-07-18 下午9.37.16.png


    最强王者


    10. 什么是重绘回流



    • 1、重绘:重绘是一个元素外观的改变所触发的浏览器行为,例如改变outline、背景色等属性。浏览器会根据元素的新属性重新绘制,使元素呈现新的外观。重绘不会带来重新布局,所以并不一定伴随重排。

    • 2、回流:渲染对象在创建完成并添加到渲染树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排,或回流

    • 3、"重绘"不一定需要"重排",比如改变某个网页元素的颜色,就只会触发"重绘",不会触发"重排",因为布局没有改变。

    • 4、"重排"大多数情况下会导致"重绘",比如改变一个网页元素的位置,就会同时触发"重排"和"重绘",因为布局改变了。


    11. 触发重绘的属性


    * color * background * outline-color * border-style * background-image * outline * border-radius * background-position * outline-style * visibility * background-repeat * outline-width * text-decoration * background-size * box-shadow


    12. 触发回流的属性


    * width * top * text-align * height * bottom * overflow-y * padding * left * font-weight * margin * right * overflow * display * position * font-family * border-width * float * line-height * border * clear * vertival-align * min-height * white-space


    13. 常见触发重绘回流的行为



    • 1、当你增加、删除、修改 DOM 结点时,会导致 Reflow , Repaint。

    • 2、当你移动 DOM 的位置

    • 3、当你修改 CSS 样式的时候。

    • 4、当你Resize窗口的时候(移动端没有这个问题,因为移动端的缩放没有影响布局视口)

    • 5、当你修改网页的默认字体时。

    • 6、获取DOM的height或者width时,例如clientWidth、clientHeight、clientTop、clientLeft、offsetWidth、offsetHeight、offsetTop、offsetLeft、scrollWidth、scrollHeight、scrollTop、scrollLeft、scrollIntoView()、scrollIntoViewIfNeeded()、getComputedStyle()、getBoundingClientRect()、scrollTo()


    14. 针对重绘回流的优化方案



    • 1、元素位置移动变换时尽量使用CSS3的transform来代替top,left等操作

    • 2、不要使用table布局

    • 3、将多次改变样式属性的操作合并成一次操作

    • 4、利用文档素碎片(documentFragment),vue使用了该方式提升性能

    • 5、动画实现过程中,启用GPU硬件加速:transform:tranlateZ(0)

    • 6、为动画元素新建图层,提高动画元素的z-index

    • 7、编写动画时,尽量使用requestAnimationFrame


    15. 浏览器缓存分类


    image.png



    1. 强缓存

      1. 不会向服务器发送请求,直接从本地缓存中获取数据

      2. 请求资源的的状态码为: 200 ok(from memory cache)

      3. 优先级:cache-control > expires



    2. 协商缓存

      1. 向服务器发送请求,服务器会根据请求头的资源判断是否命中协商缓存

      2. 如果命中,则返回304状态码通知浏览器从缓存中读取资源

      3. 优先级:Last-Modified与ETag是可以一起使用的,服务器会优先验证ETag,一致的情况下,才会继续比对Last-Modified,最后才决定是否返回304
    链接:https://juejin.cn/post/6986416221323264030

    收起阅读 »

    今天聊:大厂如何用一道编程题考察候选人水平

    进入正题 面试环节对面试官的一些挑战 面试官和候选人的知识结构可能有差异 => 可能会错过优秀的人 遇到「面霸」,频繁面试刷题,但是实际能力一般 => 招到不合适的人 要在短短半个小时到一个小时内判断一个人,其实很难 相对靠谱的做法 笔试:"...
    继续阅读 »

    进入正题


    面试环节对面试官的一些挑战



    • 面试官和候选人的知识结构可能有差异 => 可能会错过优秀的人

    • 遇到「面霸」,频繁面试刷题,但是实际能力一般 => 招到不合适的人

    • 要在短短半个小时到一个小时内判断一个人,其实很难


    相对靠谱的做法



    • 笔试:"Talk is cheap, show me the code"


    笔试常见的问题



    • 考通用算法,Google 能直接搜到,失去考察意义

    • 题目难度设计有问题。要么满分,要么零分,可能错过还不错的同学

    • 和实际工作内容脱节


    我认为好的笔试题



    • 上手门槛低,所有人多多少少都能写一点,不至于开天窗

    • 考点多,通过一道题可以基本摸清候选人的代码综合素养

    • 给高端的人有足够的发挥空间。同样的结果,不同的实现方式可以看出候选人的技术深度


    我常用的一道笔试题


    很普通的一道题


    // 假设本地机器无法做加减乘除运算,需要通过远程请求让服务端来实现。
    // 以加法为例,现有远程API的模拟实现

    const addRemote = async (a, b) => new Promise(resolve => {
    setTimeout(() => resolve(a + b), 1000)
    });

    // 请实现本地的add方法,调用addRemote,能最优的实现输入数字的加法。
    async function add(...inputs) {
    // 你的实现
    }

    // 请用示例验证运行结果:
    add(1, 2)
    .then(result => {
    console.log(result); // 3
    });


    add(3, 5, 2)
    .then(result => {
    console.log(result); // 10
    })

    答案一
    最基本的答案,如果写不出来,那大概率是通过不了了


    async function add(...args) {
    let res = 0;
    if (args.length <= 2) return res;

    for (const item of args) {
    res = await addRemote(res, item);
    }
    return res;
    }

    递归版本


    async function add(...args) {
    let res = 0;
    if (args.length === 0) return res;
    if (args.length === 1) return args[0];

    const a = args.pop();
    const b = args.pop();
    args.push(await addRemote(a, b));
    return add(...args);
    }

    常见的问题:



    • 没有判断入参个数

    • 仍然用了本地加法


    答案二
    有候选人的答案如下:


    // Promise链式调用版本
    async function add(...args) {
    return args.reduce((promiseChain, item) => {
    return promiseChain.then(res => {
    return addRemote(res, item);
    });
    }, Promise.resolve(0));

    }

    从这个实现可以看出:



    • 对 Array.prototype.reduce 的掌握

    • 对于 Promise 链式调用的理解

    • 考察候选人对 async function 本质的理解


    这个版本至少能到 70 分


    答案三
    之前的答案结果都是对的,但是我们把耗时打出来,可以看到耗时和参数个数成线性关系,因为所有计算都是串行的,显然不是最优的解



    更好一点的答案:


    function add(...args) {
    if (args.length <= 1) return Promise.resolve(args[0])
    const promiseList = []
    for (let i = 0; i * 2 < args.length - 1; i++) {
    const promise = addRemote(args[i * 2], args[i * 2 + 1])
    promiseList.push(promise)
    }

    if (args.length % 2) {
    const promise = Promise.resolve(args[args.length - 1])
    promiseList.push(promise)
    }

    return Promise.all(promiseList).then(results => add(...results));
    }


    可以看到很明显的优化。


    答案四
    还能再优化吗?
    有些同学会想到加本地缓存


    const cache = {};

    function addFn(a, b) {
    const key1 = `${a}${b}`;
    const key2 = `${b}${a}`;
    const cacheVal = cache[key1] || cache[key2];

    if (cacheVal) return Promise.resolve(cacheVal);

    return addRemote(a, b, res => {
    cache[key1] = res;
    cache[key2] = res;
    return res;
    });
    }

    加了缓存以后,我们再第二次执行相同参数加法时,可以不用请求远端,直接变成毫秒级返回



    还能再优化吗?交给大家去思考


    其他考察点


    有些时候会让候选人将代码提交到 Github 仓库,以工作中一个实际的模块标准来开发,可以考察:



    • git 操作,commit 规范

    • 工程化素养

    • 是否有单元测试

    • 覆盖率是否达标

    • 依赖的模块版本如何设置

    • 如何配置 ci/cd

    • 文档、注释

    • ...


    更加开放的一种笔试形式



    • 给一道题目,让候选人建一个 Github 仓库来完成

    • 题目有一定难度,但是可以 Google,也可以用三方模块,和我们平时做项目差不多

    • 通常面向级别较高的候选人


    实际题目


    // 有一个 10G 文件,每一行是一个时间戳,
    // 现在要在一台 2C4G 的机器上对它进行排序,输出排序以后的文件

    // 案例输入
    // 1570593273487
    // 1570593273486
    // 1570593273488
    // …

    // 输出
    // 1570593273486
    // 1570593273487
    // 1570593273488
    // …



    先看一个答案,看看哪里有问题


    async function sort(inputFile, outputFile) {
    const input = fs.createReadStream(inputFile);
    const rl = readline.createInterface({ input });
    const arr = [];
    for await (const line of rl) {
    const item = Number(line);
    arr.push(item);
    }
    arr.sort((a, b) => a - b);

    fs.writeFileSync(outputFile, arr.join('\n'));
    }

    10GB 的文件无法一次性放进内存里处理,内存只有 4GB


    再看一个神奇的答案,只有一行代码,而且从结果来说是正确的。但不是我们笔试想要的答案。


    const cp = require('child_process');

    function sort(inputFile, outputFile) {
    cp.exec(`sort -n ${inputFile} > ${outputFile}`);
    }

    解题思路



    • 既然没办法一次性在内存中排序,那我们能否将 10GB 的文件拆分成若干个小文件

    • 小文件先分别排序,然后再合并成一个大的文件


    再将问题拆解



    • 拆分大文件到小文件

    • 小文件在内存里排序

    • 合并所有小文件成一个整体排序过的大文件


    本题最难的点在于如何合并所有小文件。代码如何实现?



    • 这里需要用到一种数据结构:堆

    • 堆:就是用数组实现的一个二叉树

    • 堆分为:最大堆和最小堆,下面是一个最小堆(父节点小于它的子节点)


    image.png


    堆有一些特性:



    • 对于一个父节点来说

      • 左节点位置:父节点位置 * 2 + 1

      • 右节点位置:父节点位置 * 2 + 2



    • 很容易查找最大值 / 最小值


    我们尝试把下面数组构造成一个最小堆


    image.png



    • 从最后一个非叶子节点开始往前处理

    • 10 比 5 大,所以交换它们的位置


    image.png



    • 然后是节点 2,符合要求不需要处理

    • 最后到顶点 3,它比左子节点大,所以要交换


    image.png


    完整的实现参考:github.com/gxcsoccer/e…
    image.png







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

    收起阅读 »

    【环信IM集成指南】Android 端常见问题整理

    1、如何修改系统通知中的头像和用户名系统通知是在主module中自己写的,demo中是AgreeMsgDelegate,InviteMsgDelegate,OtherMsgDelegate中去修改头像和用户名。2. 如何修改会话列表中系统消息的头像和消息里的环...
    继续阅读 »

    1、如何修改系统通知中的头像和用户名

    系统通知是在主module中自己写的,demo中是AgreeMsgDelegate,InviteMsgDelegate,OtherMsgDelegate中去修改头像和用户名。



    2. 如何修改会话列表中系统消息的头像和消息里的环信ID?

    在创建系统消息的时候,去修改设设置的username。
    系统消息的创建一般在好友监听和群监听中。



    3. app端使用token登录,怎么获取单个用户的token?

    获取单个用户的token需要调rest接口去获取。
    文档中的Request Body要修改一下使用:

    {"grant_type": "password",

    "username": "omg2",

    "password": "123456"}

    参考文档:http://docs-im.easemob.com/im/server/ready/user#获取管理员权限


    4. 设置群组全员禁言后,怎么获取该群组是否是全员禁言状态?
    调用获取群组详情的api拿到EMGroup对象,然后再调用isAllMemberMuted去获取是否全员禁言。

    EMGroup group = EMClient.getInstance().groupManager().getGroupFromServer(groupId);

    group.isAllMemberMuted();


    5. 登录成功后获取不到群组信息,后台查看用户是在群内,怎么解决?
    检查下是否是从本地获取的群组信息,当用户打开app时,需要先从服务器拉取群组信息(放子线程中)。




    6. 何时调用本地群组信息,何时调用从服务器获取群组信息?
    打开App,从服务器拉取(从服务器拉取之后,sdk会保存在本地,再调用本地获取能拿到信息),当群成员更新的时候,从服务器获取。


    7. 如何设置群组永久禁言?
    将禁言时间设置为-1即可。
    EMClient.getInstance().groupManager().muteGroupMembers(groupId, muteMembers, -1);


    8. 获取群成员数总是来回变化?

    检查看下是否使用的是获取完整的群成员列表。
    参考文档:https://docs-im.easemob.com/im/android/basics/group#获取完整的群成员列表


    9. 消息能发送成功,但是接收不到别人发给我的消息。
    检查下注册的环信id是否含有大写字母,如果含有大写字母的话,需要在登录的时候去转换成小写,发送消息等操作都是需要使用小写字母的。


    10. 创建聊天室,显示you hava no permission to do this
    创建聊天室,只能使用rest接口去创建。
    参考文档:https://docs-im.easemob.com/im/server/basics/chatroom#创建聊天室

    11. 设置的自动同意添加好友,为什么添加之后好友列表里查找不到?

    回答:用户A添加 用户 B为好友,如果用户B是离线状态的话,用户A的好友列表是里不显示用户B的,用户 B上线之后,用户A的好友列表中会出现用户B。


    12. 怎么去设置头像和昵称?
    EaseIMkit设置头像和昵称:
    1.头像和昵称是Easeimkit处理的,我们只需要在application中调用easeimkit的setUserProvider根据环信id,将本地存储的头像和昵称封装到EaseUser中返回给Easeimkit即可(具体可参考demo)
    2.getUserInfo中的处理逻辑是:根据username先去本地获取,如果本地获取不到,再从服务器获取,并保存到本地或者做一个三级缓存,然后,再刷新会话列表。(注:getuserinfo是同步的)


    13. 如何设置是听筒模式还是扬声器?
    回答:easeimkit中isSpeakerOpened()回调中去设置。



    14. 怎么发送红包消息?
    1.发送自定义消息并携带扩展字段(扩展字段用来判断是显示已领取还是未领取),设置红包的自定义布局(已领取和未领取),默认未领取。
    2.当用户点击领取红包之后(修改扩展字段的为已领取状态),将未领取隐藏显示已领取。并发送cmd消息,在接收到cmd消息后 ,修改扩展字段的为已领取状态,并刷新ui。
    3.当用户杀掉app进来之后,根据扩展字段来决定显示已领取还是未领取。


    15. 发送文件大小超过20M怎么办?
    发送视频文件(在EMOption中设置不上传不下载),先去上传到你们的服务器上保存,拿到url放在消息里发送给对方(setRemoteUrl),对方收到之后解析消息里的url去下载视频文件。(注:这种方法会导致所有的附件都不下载不上传)



    16. demo中使用的是百度地图,我想使用高德地图,怎么使用?
    需要重写聊天页面地图的点击事件,跳转到高德就可以。



    17. 在聊天页面点击视频,没有反应?
    需要去自定义一个fragment去继承EaseChatFragment,重写EaseChatFragment中的selectVideoFromLocal。(具体实现可以参考demo中的ChatFragment)



    18. 拉取漫游数据后,展示的时间是乱的,没有按时间排序?
    1. 先打印下漫游数据和本地缓存中的数据是否一致?
    2.检查下在EMOption中是否设置
    option.setSortMessageByServerTime(false)



    19.EMMessage怎么区分离线消息还是在线消息?
    EMMessage中没有方法可以去判断是离线消息还是在线消息。
    实现方法:
    可以在接收消息的监听中,获取到消息的时间戳与本地时间戳做对比,超过一定的时间就算离线消息。 但是前提需要保证本地的时间戳是对的。



    20.集成了环信SDK后的安卓App,只要授予了定位权限,一启动就会访问用户的位置信息,如果启动App不访问用户位置信息?
    在EMOptions中设置setEnableStatistics为false。



    21. gradle的形式引入easeimkit,怎么去监听发送消息成功或者失败?

    在easeimkit中没有将发送成功的事件,回调到fragment中,如果用户需要在自定义view中用的话,继承EaseChatRow并重写onMessageSuccess()。



    22. 如何实现一键已读功能?
    可以调用将所有消息置为已读的api:

    EMClient.getInstance().chatManager().markAllConversationsAsRead();
    参考文档链接:http://docs-im.easemob.com/im/android/basics/message#未读消息数清零



    23. 怎么收不到oppo推送?
    1.检查下是否已经安装官网的文档去集成了oppo推送,在控制台搜索
    EMPushHelper,是否有[EMPushHelper] uploadTokenInternal success输出,如果没有输出 ,请检查下oppo的集成。
    2.在完成第一步的情况下,还是收不到离线推送,请检查下密钥上传的是否正确,console后台上传密钥是:master secret ,在 app中上传的密钥的是app secret。如果不正确请删除重新上传并提交工单提供appkey+证书id,让环信技术支持在后台解禁。


    24.如何设置在线消息免打扰?
    单个会话的免打扰模式您可以自己去实现,要自己去维护一个免打扰list集合,当监听到有消息时,去判断下是否是免打扰用户,如果是免打扰用户,就不去提醒。
    群组免打扰:可以使用rest去设置。("notification_ignore_群组id": true)


    25.搜索会话列表如何根据昵称搜索(或者根据其他某个字段去搜索)。
    可以把会话显示的昵称(或某个字段)放在会话的扩展里,搜索的时候遍历会话扩展里的昵称。





    26.群公告的长度有限制吗?

    群公告不能超过512 字符。


    27.如何设置群扩展字段

    1.通过EMGroupOptions的extField设置的扩展字段。
    2.从服务器获取群组信息,获取getExtension
    EMGroup group = EMClient.getInstance().groupManager().getGroupFromServer(groupId);group.getExtension();


    28.oppo推送报空指针
    Process: com.example.is, PID: 24696
    java.lang.NullPointerException: Attempt to invoke virtual method 'android.content.pm.PackageManager android.content.Context.getPackageManager()' on a null object reference
    at com.heytap.mcssdk.d.a(Unknown Source:7)
    at com.heytap.mcssdk.d.l(Unknown Source:6)
    at com.heytap.mcssdk.d.n(Unknown Source:0)
    at com.heytap.msp.push.HeytapPushManager.isSupportPush(Unknown Source:4)
    at com.hyphenate.push.platform.oppo.a.b(Unknown Source:0)
    at com.hyphenate.push.platform.a.a(Unknown Source:6)
    at com.hyphenate.push.EMPushHelper.a(Unknown Source:145)
    at com.hyphenate.push.EMPushHelper.register(Unknown Source:35)
    at com.hyphenate.chat.EMClient$7.run(Unknown Source:204)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
    at java.lang.Thread.run(Thread.java:919)

    需要在application中对oppo进行初始化(如下图)。



    29、android端本地在构造图片消息时 可以设置缩略图大小

    ( EMImageMessageBody.setThumbnailSize()) 也可以在console后台进行设置.

    30、接收方接收到图片消息后 为什么remoteUrl和thumbnailUrl是一样的

    服务端只存储原图,如果需要下载缩略图得话,在header中添加thumbnail: true”,当服务器看到过来的请求的 header 中包括这个的时候,就会返回缩略图,否则返回原始大图。


    31、环信即时推送一次性可以给多少用户推送消息?

    回答:一次可以推100个


    32、漫游功能可以配置过滤cmd、已读回执等消息 

    需要联系环信工作人员进行配置


    33、如果是内网环境、物联网定向流量卡等 需要配置域名白名单或者ip白名单 允许访问环信接口 

    (以上需要先对接商务验证身份后会提供对应的域名或者ip地址)


    34、Android端如果有使用okhttp三方网络框架,没有做特殊处理可能会遇见SSL无法获取到信任证书问题 

    可以参考这篇文章解决:https://www.imgeek.org/article/825359148


    35、批量获取用户属性一次性最多获取100个id属性 超过的可以分段获取


    36、开通敏感词后发现默认词库过滤了自己想要的词汇 

    导致消息发送失败或者以*号展示 

    这种情况下可以联系环信这边配置敏感词白名单进行过滤(需要提供appkey 和 需要添加白名单的词汇)


    37、某些情况下客户端只能允许https协议 

    这个有2种解决方式 

    1、如果是已经上线的 可以直接找环信这边修改dnsconfig配置 全部换成https协议 

    2、如果是开发中的 可以在sdk初始化的时候在EMoptions里面设置onlyhttps


    38、关于Android第三方推送对接 

    比如极光厂商对接、阿里云推送厂商对接、友盟厂商对接 这些目前来看都是可以实现的 只不过需要客户侧做一些特殊处理,对应推送厂商的sdk可以不用在重复
    进行依赖,环信这边只需要在console后台上传证书信息并在端上初始化sdk的时候配置push信息、获取到第三方推送厂商返回的devicetoken并上传给环信进行绑定即可。



    41、rest 发送消息可以设置ip白名单

    也就是说可以配置自己服务器的ip,除了该服务器可以访问环信其它ip的请求全部过滤,防止重要信息泄露后有人故意往调用发送消息接口(这个在console后台安全配置可以设置)


    42、目前android这边发现有些情况下会出现自己只登录了一个设备却老是被其它设备踢下线

    查看登录记录后发现是来自同一个设备踢下线的事件,这种情况下就得分析一下是什么原因导致的自踢:
    1、集成问题多进程多次初始化sdk会出现类似现象 

    2、网络问题a账号登录服务端记录了状态之后,a账号突然没网服务端没法在心跳触发之前即时更新离线状态,这时候网络又恢复进行了重连又进行了登录,这时候服务端本身记录是登录状态,再次登录会把之前的踢下线,这种现象会造成自踢(解决方案 可以联系环信人员配置不自踢)


    43、推送扩展字段结构 其中e为完全用户自定义扩展

    而数据来源为em_apns_ext字段和em_apns_ext.extern两者有其优先级。

    {
    "payload":{
    "ext":{
    "em_apns_ext":{
    "em_push_title":"您有一条新消息",
    "em_push_content":"您有一条新消息",
    "test1":"1",
    "test2":"2",
    "extern":{
    "test3":"3",
    "test4":"4"
    }
    }
    }
    }
    }

    自定义负载支持方式为,主动构建如下结构
    {
    "t":"toUsename",
    "f":"fromUsername",
    "m":"msg_id",
    "g":"group_id",
    "e":{}
    }


    1、当extern不存在时,e内容为em_apns_ext下push服务未使用字段。具体为移除em_push_title,em_push_content,em_push_name,em_push_channel_id,em_huawei_push_badge_class字段后剩余所有。如上则为

    {
    "e":{"test1":"1","test2":"2"}
    }


    2、当extern存在时,使用extern下字段。如上则为

    {
    "e":{"test1":"3","test2":"4"}
    }



    44.发送语音消息,想要吧amr修改成wav
    回答:可以在录制的时候去修改EaseVoiceRecorder


    45.用户隐私协议
    回答:https://www.easemob.com/protocol
    方案:需要将第三方的初始化写在application的public方法中,加一个判断,判断是 第一次安装时,不初始化,当用户点击了同意协议之后,再执行application中的public的初始化。


    46.聊天室异常退出2分钟才算离开聊天室,这个能缩短时间么?比如几秒钟
    回答:可以设置成0,断线立刻退出聊天室(可以提工单让环信工作人员配置)


    47.音视频聊天设置声音外放
    protected void openSpeakerOn() {
    try {
    if (!audioManager.isSpeakerphoneOn())
    audioManager.setSpeakerphoneOn(true);
    audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
    } catch (Exception e) {
    e.printStackTrace();
    }
    }


    48.发送图片成功后,如何获取发送的原图?

    回答:1.需要在发送图片的时候设置发送原图;

    2.发送图片成功后,可以获取到远程的服务器路径,获取到路径自己下载





    49. 如何添加自定义的表情(类似于demo中的兔斯基)
    回答:
    1.在聊天页面( ChatFragment )中添加:
    //添加扩展表情
    chatLayout.getChatInputMenu().getEmojiconMenu().addEmojiconGroup(EmojiconExampleGroupData.getData());
    2. 在applicaiton中注册一下Delegate。。
    EaseMessageTypeSetManager.getInstance()
    .addMessageType(EaseExpressionAdapterDelegate.class) //自定义表情


    50.出现下图报报错


    解决办法:升级一个根目录build.gradle 里面的classpath 'com.huawei.agconnect:agcp:1.4.1.300'



    51. 在聊天页面不显示地图的图片?
    回答:百度地图没有缩略图的api,如果使用高德的话,应该是可以的


    52. 自定义的布局没有已读未读?
    回答:需要去发送一个已读ack,参考 EaseTextViewHolder #handleReceiveMessage去写 。

    53. 环信管理后台查询到replaced是什么意思?
    回答:replaced在后台msync的定义就是旧连接被新连接踢掉了


    54. 使用了极光的厂商推送,在环信中怎么使用?
    回答:产生冲突的原因:极光和环信使用的都是厂商推送
    (一)用户既使用环信的离线推送,又使用极光的厂商推送的情况下:
    解决思路:
    1.小米,vivo,oppo,魅族,华为需要在极光中获取到token时调用环信的api上传给环信;
    2.在application中先初始化极光,再初始化环信(要保证环信的初始化是在主进程中);
    3.通过EMOptions设置各个厂商的证书appId和appKey。
    4.在配置清单中修改下各个厂商的service
    (二)不使用环信的离线推送,可以开通实时回调功能,将离线消息都配置到客户的服务器,客户使用极光推给用户。


    55. 离线推送可以跳转到指定的页面吗 ?
    回答:小米的在onNotificationMessageClicked里去解析MiPushMessage的content,拿到对应字段去自行跳转页面
    vivo的在onNotificationMessageClicked里解析UPSNotificationMessage,拿到对应字段去自行跳转页面
    OPPO的跟华为是一样的,在启动页的onCreate里去获取参数跳转
    默认点击打开应用首页,可以在客户端首页获取到。在onCreate里去调用
    Bundle bundle = getIntent().getExtras();
    if(bundle != null){
    String f = bundle.getString("f");
    String t = bundle.getString("t");
    }

    字段对应的含义
    f:from
    t:to
    m:msgid
    g:groupid



    56. 看文档fcm集成成功了,怎么没有收到推送?
    回答:fcm是唤醒应用,环信服务器会将离线消息下发给客户端,接收到消息之后,自己做本地通知 。
    接收消息的监听:在application中初始化环信成功之后,注册一个接收消息的监听,自己做一个判断是否运行在后台,如果运行在后台,本地通知,https://docs-im.easemob.com/im/android/basics/message#接收消息


    57、 安卓和iOS音视频不通?
    回答:看下安卓的sdk版本和ios的环信sdk版本,
    3.7.5之前的版本使用的是环信的音视频,3.8.0之后的版本使用的是声网的音视频,需要确保各端在相同的版本下。


    58. 在聊天页面点击大图崩溃?
    回答:需要在配置清单中配置下EaseShowBigImageActivity。
    android:screenOrientation="portrait"/>

    59、
    Android端账号被踢下线,消息接收不到。

    账号在其他设备上登录,将当前设备踢下线的时候,当前设备需要在监听到被踢的时候调用退出的api,并传false。




    60、Android 11报崩溃异常,报错如下:

    java.lang.RuntimeException:Unable to start receiver com.hyphenate.chat.EMMonitorReceiver: java.lang.IllegalStateException: Not allowed to start service Intent { cmp=com.ant.health/com.hyphenate.chat.EMChatService }: app is in background uid UidRecord{ab5b452 u0a320 RCVR idle change:uncached procs:1 seq(0,0,0)}


    A:去掉以下这几个方法,这几个方法对于安卓低版本保活的, 对于高版本 ,这个保活可以去掉。 高版本对保活有限制。



    61、添加回调规则添加失败。
    A:检查下回调规则名称是不是用的汉字,回调规则只能是数字、字母,不能用汉字。


    62、对方离线了之后,发送的消息,上线后如何获取?
    A:对方离线,消息会进入离线队列,如果没有集成第三方厂商离线推送,用户上线后,服务器下发给客户端。


    63、调用SDK 方法报错: Cannot read property 'lookup' of undefined?
    A:因为未登陆成功就调用了SDK 的api,需要在onOpened 链接成功回调执行后再去调用SDK 的api。


    64、聊天室如何获取历史消息?
    A:两种方式:1、环信服务器端主动推,需要联系商务开通服务,默认10条,数量可以调整。2、通过消息漫游接口自己去拉取历史消息,各端都有提供拉取漫游消息接口。


    65、拉取消息漫游,conversationId是怎么获取的?
    A:单聊的话,conversationId 就是对方用户的环信id。
    群聊或聊天室的话,conversationId 就是groupid 或者chatroomid。


    66、如何实现只有好友才可以发消息?
    A:可以使用环信的发送前回调服务,消息先回调给配置的回调服务器,然后去判断收发双方是否是好友关系,如果是好友关系,那么下发消息,如果是非好友关系,则不下发消息,客户端ui可以根据不下发返回的code做提示。


    67、调rest接口报401是什么原因?
    A:调环信rest接口,需要管理员权限的token,确认下请求是否有token,且是在有效期,token的有效期以请求时服务器返回的时间为准。


    68、调修改群信息报错如下
    System.Net.WebException:“远程服务器返回错误: (400) 错误的请求。
    A:检查下请求体,看下参数格式是否正确,比如"membersonly",,"allowinvites" 这两个参数的值为布尔值。


    69、注册用户username是纯数字可以吗。

    调restapi是可以的,serversdk的话,为了让用户使用更规范的名字,命名规则更严格一些,要求首位是字母。


    70、自定义铃声只支持华为和小米
    华为:
    https://docs-im.e·asemob.com/im/other/integrationcases/appimnotifi#自定义推送铃声
    小米:
    直接调用rest接口去创建小米的通道,同时设置下铃声,https://dev.mi.com/console/doc/detail?pId=1163#_11
    小米铃声 需要通道id 。。


    自定义铃声
    自定义播放铃声需要携带扩展字段 em_apns_ext 下面携带的就是标题和内容 em_android_push_ext 下 就是存放小米的通道id的 直接调用rest接口去创建小米的通道,同时设置下铃声


    71、push推送后 点击通知栏后在哪里 设置跳转页面?
    小米的在onNotificationMessageClicked里去解析MiPushMessage的content,拿到对应字段去自行跳转页面vivo的在onNotificationMessageClicked里解析UPSNotificationMessage,拿到对应字段去自行跳转页面OPPO的跟华为是一样的,在启动页的onCreate里去获取参数跳转
    Bundle bundle = getIntent().getExtras();
    if(bundle != null){
    String f = bundle.getString(f);
    String t = bundle.getString(t);}
    字段对应的含义f:fromt:tom:msgidg:groupid.


    72、在线push推送:
    在线推送的话 3.8.7sdk已经封装在线push推送 如果想收到推送消息的话,需要自己做cmd消息 接收本地通知
    3.8.5的话 需要 自己 去发一条 cmd消息 携带扩展字段, 端上接收到以后 做一个本地通知 。


    73、 根据搜索框查找全局消息:
    android 端调这个接口,不传from字段,实现全局搜索,EMClient.getInstance().chatManager().searchMsgFromDB
    可以先拿到本地所有的会话列表 去搜索每一个会话的消息内容 , 然后存放到一个大集合,展示的话直接展示大集合里面的数据, 这个操作需要自己去实现的,


    74、Dcoumentfile

    自己加一个Documentfile的远程依赖,就可以了 百度连接 :https://developer.android.google.cn/jetpack/androidx/releases/documentfile?hl=zh-cn如果添加配置还是有问题的话,需要在gradle.properties中添加一下 android.enableJetifier=true


    75、 头像昵称 用url获取:
    只需要保证你返回得easeuser对象得数据是需要展示得ui得数据即可,不需要去管会话列表。。。可以打印在setUserProvider,看下返回得username数据,加入会话列表有十个,那么就会返回这个十个会话得username,然后,你需要根据返回得username去本地数据库查询对应得头像和昵称,如果有,就返回,如果没有就从网络请求并存数据,并去刷新会话列表,刷新会话列表得原因就是因为只要刷新就会再次执行setUserProvider,就又走一边这个逻辑,从数据库取
    如果用环信的room数据库进行存储直接将demo中的db文件下所有类拉入自己的项目中



    76、发送视频体积10mb:
    相机是直接调用的系统的,跟随的是系统的大小,我拍摄15s视频大概18m左右。环信系统默认的是只能发送10M的视频文件,您需要在发送视频之前做下压缩 ,在调用环信发送视频方法之前去做判断,超过10m的话,压缩下再发送

     
    77、用户A给B发送自定义消息,B可以收到,后台拿用户A的账号给用户B发送,B也可以收到消息,但是用户A的聊天页面不显示发送消息的内容
    服务端:A send custom message B,B 看到自定义消息 C send cmd message A,携带自定义消息内容、A、B客户端:A receive cmd message ---> 解析message ---> 向B的会话插入以A身份发的自定义消息


    78、 解决 UTF-8
    解决问题的步骤:

    步骤1、首先,在项目build.gradle文件中添加如下代码:
    buildscript {
    tasks.withType(JavaCompile) {//解决编码错误: 编码UTF-8的不可映射字
    options.encoding = "UTF-8"}}


    步骤2、如果导入AS后,文件代码注释出现乱码问题。将AS右下角 “file encoding”编码格式,先改为“GB2312”,弹出对话框,选择“reload”,此时注释乱码消失。接着再改为“UTF-8”,弹出对话框,选择“convert”,即可。


    79、 用户在上线后报218
    218 是指当前SDK已经登录了一个id,如果没有退出,然后再使用另外一个id 登录的话会提示这个错误
    一般开启了自动登录的话,SDK 初始化后会自动登录上次的ID,可以排查一下这块的逻辑。
    如果开启了自动登录,不需要再调用登录接口,如果没开启自动登录,可以在应用打开时调用登录api
    这种情况其实再调用下logout 再调用login 其实也是可以的

    80、 进入聊天页面后,拉取聊天页面之后,未读数+1
    检查下调用conversation.getMessage(string, bool)时,传的是不是true。


    81、 小米手机如何设置自定义铃声?

    回答:1).需要先去小米厂商申请通道并设置添加上自定义铃声;

    2) .安卓端在发送消息时,需要携带上在小米厂商申请的通道名称。


    82、 华为手机如果设置自定义铃声?
    在发送消息之前去设置扩展字段,
    "em_android_push_ext":{
    //指定自定义渠道
    "em_push_channel_id":"Channel id",
    "em_push_sound":"/raw/appsound"
    }


    注意事项:
    (1)目前只支持华为EMUI 10以上的系统。
    (2)华为EMUI 10以上自定义推送铃声,需要设置channel_id,通过em_push_channel_id进行设置。
    需要注意的是,即使指定了渠道标识(channel id),消息最终能否展示在应用渠道上,受用户终端上该渠道是否创建以及渠道的开关策略控制。
    a、如果本地已经创建该渠道,且已设置了对应的自定义铃声,收到推送消息时会播放自定义铃声。
    b、如果本地没有创建指定的渠道,则华为会对消息进行智能分类,根据消息设置的级别及智能分类的结果,两者取低,根据级别下发到服务提醒,普通通知与营销通知三个中的一个通知渠道,如果该通知渠道之前没有创建且不是营销通知,则设置自定义铃声有效。
    (3)对于华为EMUI 10以上系统,需要添加em_push_name和em_push_content参数,否则容易被华为通知智能分类分到营销通知渠道,从而不能播放自定义铃声。
    (4)由于铃声是通知渠道的属性,因此铃声仅在渠道创建时有效,渠道创建后,即使设置自定义铃声也不会播放,而使用创建渠道时设置的铃声。



    83、fcm离线push 通知栏是正常显示 但是 EMFCMMSGService 的onMessageReceived 不走?
    设置下这个data数据,onMessageReceived没有执行的原因是没有data数据。




    84、撤回消息提示,自己撤回的本地能加载出来,对方撤回的消息,杀了app再打开这条提示就没了。

    在插入消息时,先设置to,再设置from




    85、从服务器端获取会话列表,没有获取到rest发过来的消息?
    联系下环信工作人员,或者提交工单,让工作人员配置下。



    86、push推送消息成功了,但是在环信后台管理界面查不到?
    把single改成list;




    87、 怎么全局搜索?
    调这个接口,不传from字段,实现全局搜索,

    EMClient.getInstance().chatManager().searchMsgFromDB


    88、 地图能正常显示,怎么显示缩略图?
    百度地图没有缩略图的api,如果使用高德的话,是可以的。


    89、EMMessage怎么区分离线消息还是在线消息?

    EMMessage中没有方法可以去判断是离线消息还是在线消息。
    实现方法:
    可以在接收消息的监听中,获取到消息的时间戳与本地时间戳做对比,超过一定的时间就算离线消息。 但是前提需要保证本地的时间戳是对的。


    90、在使用环信时,偶尔发送消息会出现500,是什么原因?
    1.检查下用户是否登录?
    2.检查下网络是否正常?


    91、如何删除和某个用户的所有聊天记录?
    可以直接调用api去删除EMClient.getInstance().chatManager().getConversation("环信id").clearAllMessages();


    92、何时调本地取群组信息,何时调从服务器取群组信息?
    回答:打开app,从服务器拉取(从服务器拉取之后,sdk会保存在本地,再调用本地获取能拿到信息),当群成员更新的时候,从服务器获取


    93、如何设置群扩展字段?
    1.通过EMGroupOptions的extField设置的扩展字段。
    2.从服务器获取群组信息,获取getExtension
    EMGroup group = EMClient.getInstance().groupManager().getGroupFromServer(groupId);group.getExtension();


    94、Android 百度地图怎么替换为高德地图?
    1.将easeimkit中关于百度地图的集成去掉,改成高德地图;
    2.在chatfragment中重写位置的点击事件方法startMapLocation或者是直接在EaseChatFragment中直接修改点击事件startMapLocation跳转到高德地图;
    3.在调用环信api去发送地理位置消息时,传入高德获取到的经纬度。


    95、Rest发消息会话列表为什么获取不到?
    默认rest 消息不写会话列表,如果需要的话,可以联系对接商务开通该项服务。



    96、appkey获取会话列表服务是开通状态,但获取不到会话列表?
    检查下用户id是不是大小写混写了,大小写混写会导致获取不到会话列表,如果自己应用有区分大小,登陆环信时建议全部转为小写。


    97、拉黑与被拉黑发送消息返回什么错误码?
    A 拉黑B,A可以给B正常发消息,B给A发消息会提示报错,3.6.3的返回604错误码,Android返回210错误码


    98、不注册环信id可以收发消息吗?业务场景支持游客模式,不需要注册的。
    使用环信必须要注册环信id,对于环信来说,收发消息的双方是环信id,环信并不关心该用户是客户业务系统里的哪个用户,游客也是客户业务的定义,环信本身不存在游客的说法,任何身份的用户对于环信来说就是一个环信id。


    99、不登录可以发消息吗?业务场景不需要登录,只是授权。
    发消息是必须要登录环信的,客户自己的应用可以实现为不用登录就可以发消息,但在进入自己应用的时候或者其他时机,比如点击发消息、咨询等icon时,底层需要去调用环信的登录方法,去登录环信,对于客户的用户来说是没有登录这个操作。


    100、聊天室支持全员禁言吗?
    A:支持,群组、聊天室全局禁言都是支持,调以下api去设置。
    聊天室:
    全员禁言:POST 'http://{url}/{org}/{app}/chatrooms/{chatroomId}/ban'
    解除全员禁言:DETELE 'http://{url}/{org}/{app}/chatrooms/{chatroomId}/ban'
    群组:
    全员禁言:POST 'http://{url}/{org}/{app}/chatgroups/{chatgroupId}/ban'
    解除全员禁言:DETELE 'http://{url}/{org}/{app}/ chatgroups/{ chatgroupId}/ban






    收起阅读 »

    ios--离屏渲染详解

    目录:1.图像显示原理2.图像显示原理2.1 图像到屏幕的流程2.2 显示器显示的流程3.卡顿、掉帧3.1 垂直同步 Vsync + 双缓冲机制 Double Buffering2.3 掉帧和屏幕卡顿的本质4.离屏渲染4.1 什么事离屏渲染、离屏渲染的过程4....
    继续阅读 »

    目录:

    • 1.图像显示原理
    • 2.图像显示原理
      • 2.1 图像到屏幕的流程
      • 2.2 显示器显示的流程
    • 3.卡顿、掉帧
      • 3.1 垂直同步 Vsync + 双缓冲机制 Double Buffering
      • 2.3 掉帧和屏幕卡顿的本质
    • 4.离屏渲染
      • 4.1 什么事离屏渲染、离屏渲染的过程
      • 4.2 既然离屏渲染影响界面,为什么还要用
    • 5.触发离屏渲染
    • 6.如何优化
    1.引言

    先来聊聊为什么要了解离屏渲染?
    看看现在app开发的大环境,14年的时候在深圳,基本上每个公司都要做一个app。不做一个app你都不一定能拉倒更多的投资。再看看现在,死了一大半,现在的用户也不想去下载太多的app。一般手机上只留一些常用的,基本全是大厂的app。然后ios这行问的也就越来越难。性能优化这个绝对会问,在网上也有许多性能优化的总结,但是你不能不知道为什么这么做能优化,要知道其为什么。那么,这时候你就需要知道界面是怎么渲染的,什么时候会掉帧,什么时候会卡顿,这些都使得我们非常有必要去了解离屏渲染。
    离屏渲染过程

    2.图像显示原理
    2.1 图像到屏幕的流程

    先来看一张图,我们结合这张图来说


    首先要明白的一个东西是Render Server 进程,app本身其实并不负责渲染,渲染是有独立的进程负责的,它就是Render Server 。

    当我们在代码里设置修改了UI界面的时候,其实它本质是通过Core Animation修改CALayer。在后续的核心动画总结中 我们会说到UIView和CALayer的关系,以及核心动画的设置等等,这个知识点有点多,需要单独详细的总结出来。所以最后按照图片中的流程显示。

    • 首先,有app处理事件(Handle Events),例如:用户点击了一个按钮,它会触发其他的视图的一个动画等
    • 其次,app通过CPU完成对显示内容的计算 例如:视图的创建,视图的布局 图片文本的绘制等。在完成了对显示内容的计算之后,app对图层进行打包,并在下一次Runloop时,将其发送至Render Server
    • 上面我们提到,Render Server负责渲染。Render Server通过执行Open GL、Core Graphics Metal相关程序。 调用GPU
    • GPU在物理层完成了对图像的渲染。

    说到这我们就要停一下,我们来看下一个图




    上面的流程图 细化了GPU到控制器的这一个过程。
    GPU 拿到位图后执行顶点着色、图元装配、光栅化、片段着色等,最后将渲染的结果交到了Frame Buffer(帧缓存区当中)
    然后视频控制器从帧缓存区中拿到要显示的对象,显示到屏幕上
    图片中的黄色虚线暂时不用管,下面在说垂直同步信号的时候,就明白了。
    这是从我们代码中设置UI,然后到屏幕的一个过程。

    2.2 显示器显示的过程

    现在从帧缓存中拿到了渲染的视图,又该怎么显示到显示器上面呢?

    先来看一张图



    从图中我们也能大致的明白显示的一个过程。

    显示器的电子束从屏幕的左上方开始逐行显示,当第一行扫描完之后接着第二行 又是从左到右,就这样一直到屏幕的最下面扫描完成。我们都知道。手机它是有屏幕的刷新次数的。安卓的现在好多是120的,ios是60。1秒刷新60次,当我们扫描完成以后,屏幕刷新,然后视图就会显示出来。

    3.UI卡顿 掉帧
    3.1垂直同步 Vsync + 双缓冲机制 Double Buffering

    首先我们了解了上面渲染的过程以后,需要考虑遇到一些特别的情况下,该怎么办?在我们代码里写了一个很复杂的UI视图,然后CPU计算布局、GPU渲染,最后放到缓存区。如果在电子束开始扫描新的一帧时,位图还没有渲染好,而是在扫描到屏幕中间时才渲染完成,被放入帧缓冲器中 。
    那么已扫描的部分就是上一帧的画面,而未扫描的部分就是新一帧的图像,这样是不是就造成了屏幕撕裂了。

    但是,在我们平常开发的过程遇到过屏幕撕裂的问题吗?没有吧,这是为什么呢?
    显然是苹果做了优化操作了。也就是垂直同步 Vsync + 双缓冲机制 Double Buffering。

    垂直同步 Vsync
    垂直同步 Vsync相当于给帧缓存加了锁,还记得上面说到的那个黄色虚线嘛,在我们扫描完一帧以后,就会发出一个垂直同步的信号,通知开始扫描下一帧的图像了。他就像一个位置秩序的,你得给我排队一个一个来,别插队。插队的后果就是屏幕撕裂。
    双缓冲机制 Double Buffering
    扫描显示排队进行了,这样在进行下一帧的位图传入的时候,也就意味着我要立刻拿到位图。不能等CPU+GPU计算渲染后再给位图,这样就影响性能。要怎么解决这个问题呢?肯定是 在你快要渲染之前你就要把这些都完成了。你就像排队打针一样,为了节省时间肯定事先都会挽起袖子,到医生那时,直接一针下去了事。扯远了 哈哈。想预先渲染好,就需要另外一个缓存来放下一帧的位图,在它需要扫描的时候,再把渲染好的位图给了帧缓存,帧缓存拿到以后 开始快乐的扫描 显示。
    一个图解释




    3.2 掉帧卡顿

    垂直同步和双缓存机制完美的解决了屏幕撕裂的问题,但是又引出一个新的问题:掉帧。
    掉帧是什么意思呢?从网上copy了一份图



    其实很好理解,上面我们说了ios的屏幕刷新是60次,那么在一次刷新的过程中,我们CPU+GPU它没有把新渲染的位图放到帧缓存区,这时候是不是还是显示的原来的图像。当下刷新下一帧的时候,拿到了新的位图,这里是不是就丢失了一帧。

    卡顿的根本原因:
    CPU和GPU渲染流水线耗时过长 掉帧
    我们平常写界面的时候,通过一些开源的库或者自己使用runloop写的库来检测界面卡顿的时候,屏幕刷新率在50以上就很可以了。一般人哪能体验到掉了10帧。你要刷新率是30,那卡顿想过就很明显了。

    4 离屏渲染
    4.1什么是离屏渲染 离屏渲染的过程

    是指在GPU在当前屏幕缓冲区以外开辟一个缓冲区进行渲染操作.
    过程:首先会创建一个当前屏幕缓冲区以外的新缓存区,屏幕渲染会有一个上下文环境,离屏渲染的过程就是切花上下文环境,现充当前屏幕切换到离屏,等结束以后又将上下文切换回来。所以需要更长的时间来处理。时间一长就可能造成掉帧。
    并且 Offscreen Buffer离屏缓存 本身就需要额外的空间,大量的离屏渲染可能造成内存过大的压力。而且离屏缓存区并不是没有限制大小的,它是不能超过屏幕总像素的2.5倍。

    4.2为什么要使用离屏渲染

    1.一些特殊效果需要使用额外的 Offscreen Buffer 来保存渲染的中间状态,所以不得不使用离屏渲染。
    2.处于效率目的,可以将内容提前渲染保存在 Offscreen Buffer 中,达到复用的目的。
    当使用圆角,阴影,遮罩的时候,图层属性的混合体被指定为在未预合成之前(下一个VSync信号开始前)不能直接在屏幕中绘制,所以就需要屏幕外渲染。

    5.触发离屏渲染
    1. 为图层设置遮罩(layer.mask)
    2. 图层的layer. masksToBounds/view.clipsToBounds属性设置为true
    3. 将图层layer. allowsGroupOpacity设置为yes和layer. opacity<1.0
    4. 为图层设置阴影(layer.shadow)
    5. 为图层设置shouldRasterize光栅化
      6 复杂形状设置圆角等
      7 渐变
      8 文本(任何种类,包括UILabel,CATextLayer,Core Text等)
      9 使用CGContext在drawRect :方法中绘制大部分情况下会导致离屏渲染,甚至仅仅是一个空的实现。
    6 离屏渲染的优化
    1 圆角优化

    方法一

    iv.layer.cornerRadius = 30;
    iv.layer.masksToBounds = YES;
    方法二
    利用mask设置圆角,利用贝塞斯曲线和CAShapeLayer来完成

    CAShapeLayer *mask1 = [[CAShapeLayer alloc] init];
    mask1.opacity = 0.5;
    mask1.path = [UIBezierPath bezierPathWithOvalInRect:iv.bounds].CGPath;
    iv.layer.mask = mask1;
    方法三
    利用CoreGraphics画一个圆形上下文,然后把图片绘制上去

    - (void)setCircleImage
    {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    UIImage * circleImage = [image imageWithCircle];
    dispatch_async(dispatch_get_main_queue(), ^{
    imageView.image = circleImage;
    });
    });
    }


    #import "UIImage+Addtions.h"
    @implementation UIImage (Addtions)
    //返回一张圆形图片
    - (instancetype)imageWithCircle
    {
    UIGraphicsBeginImageContextWithOptions(self.size, NO, 0);
    UIBezierPath *path = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0, 0, self.size.width, self.size.height)];
    [path addClip];
    [self drawAtPoint:CGPointZero];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
    }
    }
    shadows(阴影)

    设置阴影后,设置CALayer的shadowPath

    view.layer.shadowPath = [UIBezierPath pathWithCGRect:view.bounds].CGPath;

    mask(遮罩)

    不使用mask
    使用混合图层 使用混合图层,在layer上方叠加相应mask形状的半透明layer

    sublayer.contents = (id)[UIImage imageNamed:@"xxx"].CGImage;
    [view.layer addSublayer:sublayer];
    allowsGroupOpacity(组不透明)

    关闭 allowsGroupOpacity 属性,按产品需求自己控制layer透明度

    edge antialiasing(抗锯齿)

    不设置 allowsEdgeAntialiasing 属性为YES(默认为NO)

    当视图内容是静态不变时,设置 shouldRasterize(光栅化)为YES,此方案最为实用方便


    view.layer.shouldRasterize = true;
    view.layer.rasterizationScale = view.layer.contentsScale;

    如果视图内容是动态变化的,例如cell中的图片,这个时候使用光栅化会增加系统负荷。

    作者:Harry__Li
    链接:https://www.jianshu.com/p/3c3383bdeb71


    收起阅读 »

    iOS-分页控制器

    使用:1、创建方法1.1 导入头文件#import "XLPageViewController.h"1.2 遵守协议@interface ViewController ()<XLPageViewControllerDelegate, XLPageView...
    继续阅读 »




    使用:

    1、创建方法

    1.1 导入头文件

    #import "XLPageViewController.h"
    1.2 遵守协议
    @interface ViewController ()<XLPageViewControllerDelegate, XLPageViewControllerDataSrouce>
    1.3 创建外观配置类

    注:config负责所有的外观配置,defaultConfig方法设定了默认参数,使用时可按需配置。 →Config属性列表

      XLPageViewControllerConfig *config = [XLPageViewControllerConfig defaultConfig];

    1.4 创建分页控制器

    注:需要把pageViewController添加为当前视图控制器的子视图控制器,才能实现子视图控制器中的界面跳转。

      XLPageViewController *pageViewController = [[XLPageViewController alloc] initWithConfig:config];
    pageViewController.view.frame = self.view.bounds;
    pageViewController.delegate = self;
    pageViewController.dataSource = self;
    [self.view addSubview:pageViewController.view];
    [self addChildViewController:pageViewController];
    2、协议

    2.1 XLPageViewControllerDelegate

    //回调切换位置
    - (void)pageViewController:(XLPageViewController *)pageViewController didSelectedAtIndex:(NSInteger)index;

    2.2 XLPageViewControllerDataSrouce

    @required

    //根据index创建对应的视图控制器,每个试图控制器只会被创建一次。
    - (UIViewController *)pageViewController:(XLPageViewController *)pageViewController viewControllerForIndex:(NSInteger)index;
    //根据index返回对应的标题
    - (NSString *)pageViewController:(XLPageViewController *)pageViewController titleForIndex:(NSInteger)index;
    //返回分页数
    - (NSInteger)pageViewControllerNumberOfPage;

    @optional

    //标题cell复用方法,自定义标题cell时用到
    - (__kindof XLPageTitleCell *)pageViewController:(XLPageViewController *)pageViewController titleViewCellForItemAtIndex:(NSInteger)index;

    3、自定义标题cell

    3.1 创建一个XLPageTitleCell的子类

    #import "XLPageTitleCell.h"

    @interface CustomPageTitleCell : XLPageTitleCell

    @end

    3.2 注册cell、添加创建cell

    //需要先注册cell
    [self.pageViewController registerClass:CustomPageTitleCell.class forTitleViewCellWithReuseIdentifier:@"CustomPageTitleCell"];
    //自定义标题cell创建方法
    - (XLPageTitleCell *)pageViewController:(XLPageViewController *)pageViewController titleViewCellForItemAtIndex:(NSInteger)index {
    CustomPageTitleCell *cell = [pageViewController dequeueReusableTitleViewCellWithIdentifier:@"CustomPageTitleCell" forIndex:index];
    return cell;
    }

    3.3 复写cell父类方法

    //通过此父类方法配置标题cell是否被选中样式
    - (void)configCellOfSelected:(BOOL)selected {

    }

    //通过此父类方法配置标题cell动画;type:区分是当前选中cell/将要被选中的cell;progress:动画进度0~1
    - (void)showAnimationOfProgress:(CGFloat)progress type:(XLPageTitleCellAnimationType)type {

    }

    4、特殊情况处理

    4.1 和子view手势冲突问题

    pageViewController的子视图中存在可滚动的子view,例如UISlider、UIScrollView等,如果子view和pageViewController发生滚动冲突时,可设置子view的xl_letMeScrollFirst属性为true。

      UISlider *slider = [[UISlider alloc] init];
    slider.xl_letMeScrollFirst = true;
    [childVC.view addSubview:slider];

    4.2 全屏返回手势问题

    pageViewController和全屏返回手势一起使用时,需要将其它手势的delegate的类名添加到respondOtherGestureDelegateClassList属性中。当滚动到第一个分页时,向右滑动会优先响应全屏返回。以FDFullscreenPopGesture为例:

    self.pageViewController.respondOtherGestureDelegateClassList = @[@"_FDFullscreenPopGestureRecognizerDelegate"];

    5、注意事项

    使用时需注意标题不要重复标题是定位ViewController的唯一ID。


    源码下载:XLPageViewController-master.zip 

    常见问题及demo:https://github.com/mengxianliang/XLPageViewController





    收起阅读 »

    iOS - 呼吸动画库

    先看效果

    先看效果



    需求和实现思路

    具体要求

    • 内部头像呼吸放大缩小 无限循环
    • 每次放大同时需要背景还有一张图也放大 并且透明
    • 点击缩放整个背景视图


    实现思路

    首先 需要使用创建一个Layer 装第一个无限放大缩小的呼吸的图 背景也需要一个Layer 做 放大+透明度渐变的动画组并且也放置一张需要放大渐变的图片

    最后点击触发. 添加一个一次性的缩放动画即可

    呼吸动画layer和动画

    呼吸layer

    CALayer *layer = [CALayer layer];
    layer.position = CGPointMake(kHeartSizeWidth/2.0f, kHeartSizeHeight/2.0f);
    layer.bounds = CGRectMake(0, 0, kHeartSizeWidth/2.0f, kHeartSizeHeight/2.0f);
    layer.backgroundColor = [UIColor clearColor].CGColor;
    layer.contents = (__bridge id _Nullable)([UIImage imageNamed:@"breathImage"].CGImage);
    layer.contentsGravity = kCAGravityResizeAspect;
    [self.heartView.layer addSublayer:layer];
    复制代码

    kHeartSizeHeight 和kHeartSizeWidth 是常量 demo中写好了100

    加帧动画

    CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale"];
    animation.values = @[@1.f, @1.4f, @1.f];
    animation.keyTimes = @[@0.f, @0.5f, @1.f];
    animation.duration = 1; //1000ms
    animation.repeatCount = FLT_MAX;
    animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    [animation setValue:kBreathAnimationKey forKey:kBreathAnimationName];
    [layer addAnimation:animation forKey:kBreathAnimationKey];
    复制代码

    差值器也可以自定义 例如:

    [CAMediaTimingFunction functionWithControlPoints:0.33 :0 :0.67 :1]
    复制代码

    这里我做的持续时常1秒

    放大渐变动画group

    创建新layer

    CALayer *breathLayer = [CALayer layer];
    breathLayer.position = layer.position;
    breathLayer.bounds = layer.bounds;
    breathLayer.backgroundColor = [UIColor clearColor].CGColor;
    breathLayer.contents = (__bridge id _Nullable)([UIImage imageNamed:@"breathImage"].CGImage);
    breathLayer.contentsGravity = kCAGravityResizeAspect;
    [self.heartView.layer insertSublayer:breathLayer below:layer];
    //[self.heartView.layer addSublayer:breathLayer];
    复制代码

    这里用的是放在 呼吸layer后边 如果想放在呼吸layer前边 就把里面注释打开 然后注掉 inert那行代码

    动画组 包含 放大 渐变


    //缩放
    CAKeyframeAnimation *scaleAnimation = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale"];
    scaleAnimation.values = @[@1.f, @2.4f];
    scaleAnimation.keyTimes = @[@0.f,@1.f];
    scaleAnimation.duration = animation.duration;
    scaleAnimation.repeatCount = FLT_MAX;
    scaleAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
    //透明度
    CAKeyframeAnimation *opacityAnimation = [CAKeyframeAnimation animation];
    opacityAnimation.keyPath = @"opacity";
    opacityAnimation.values = @[@1.f, @0.f];
    opacityAnimation.duration = 0.4f;
    opacityAnimation.keyTimes = @[@0.f, @1.f];
    opacityAnimation.repeatCount = FLT_MAX;
    opacityAnimation.duration = animation.duration;
    opacityAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];

    //动画组
    CAAnimationGroup *scaleOpacityGroup = [CAAnimationGroup animation];
    scaleOpacityGroup.animations = @[scaleAnimation, opacityAnimation];
    scaleOpacityGroup.removedOnCompletion = NO;
    scaleOpacityGroup.fillMode = kCAFillModeForwards;
    scaleOpacityGroup.duration = animation.duration;
    scaleOpacityGroup.repeatCount = FLT_MAX;
    [breathLayer addAnimation:scaleOpacityGroup forKey:kBreathScaleName];
    复制代码

    点击缩放动画

    跟第一个一样 只不过 执行次数默认一次 执行完就可以了

    - (void)shakeAnimation {
    CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale"];
    animation.values = @[@1.0f, @0.8f, @1.f];
    animation.keyTimes = @[@0.f,@0.5f, @1.f];
    animation.duration = 0.35f;
    animation.timingFunctions = @[[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut],[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]];
    [self.heartView.layer addAnimation:animation forKey:@""];
    }
    复制代码

    手势触发的时候 调用一下 

    源码及demo地址:https://github.com/sunyazhou13/BreathAnimation