注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

fishhook--终于被我悟透了

iOS
fishhook 作为一个 hook 工具在 iOS 开发中有着高频应用,理解 fishhook 的基本原理对于一个高级开发应该是必备技能。很遗憾,在此之前虽然对 fishhook 的基本原理有过多次探索但总是一知半解,最近在整理相关知识点对 fishhook...
继续阅读 »

fishhook 作为一个 hook 工具在 iOS 开发中有着高频应用,理解 fishhook 的基本原理对于一个高级开发应该是必备技能。很遗憾,在此之前虽然对 fishhook 的基本原理有过多次探索但总是一知半解,最近在整理相关知识点对 fishhook 又有了全新的认识。如果你跟我一样对 fishhook 的原理不甚了解那这篇文章会适合你。


需要强调的是本文不会从 fishhook 的使用基础讲起,也会不对照源码逐行讲解,只会着重对一些比较迷惑知识点进行重点阐述,建议先找一些相关系列文章进行阅读,补充一些基本知识再回过头来阅读本文。



注1:所有代码均以 64 位 CPU 架构为例,后文不再进行特别说明


注2:请下载 MachOView 打开任意 Mach-O 文件同步进行验证


注3:Mach-O 结构头文件地址



MachO 文件结构


image.png


0x01


Mach-O 文件结构有三部分,第一部分是 header,描述 Mach-O 文件的关键信息。其数据结构如下:


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

如上面结构体所示,Mach-O 文件 header 的关键信息包括:



  • cputype:当前文件支持的 CPU 类型

  • filetype:当前 MachO 的文件类型

  • ncmds: Load Command 的数量

  • sizeofcmds:所有 Command 的总大小


每个 iOS 的可执行文件、动态库都会从 header 开始加载到内存中。


0x02


第二部分是 Load Commands,Load Commands 有不同的类型,有的用于描述不同类型数据结构(在文件中的位置、大小、类型、限权等),有的单纯用来记录信息,比如记录:dyld 的路径、main 函数的地址、UUID 等,用于记录信息的 Command 一般不会出现在数据区(Data)内。


不同的类型的 Load Command 对应着不同的结构体,但所有 Load Command 的前两个字段(cmd/cmdsize)都是相同的。所以,所有的 Load Command 都可以通过类型强转为 load_command 结构体类型。


有了 load_command 就可以通过每一个 Load Command 的 cmdsize 计算出下一个 Load Command 的位置。


struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize;` /* total size of command in bytes */
};

struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* includes sizeof section_64 structs */
char segname[16]; /* segment name */
uint64_t vmaddr; /* memory address of this segment */
uint64_t vmsize; /* memory size of this segment */
uint64_t fileoff; /* file offset of this segment */
uint64_t filesize; /* amount to map from the file */
vm_prot_t maxprot; /* maximum VM protection */
vm_prot_t initprot; /* initial VM protection */
uint32_t nsects; /* number of sections in segment */
uint32_t flags; /* flags */
};

有文章说 load_command 是所有 Command 的基类,你也可以这样理解(虽然在代码语法层面不是)。


segment_command_64 作为一个 Load Command 重点类型,一般用来描述 __PAGEZERO、__TEXT、__DATA、__DATA_CONST 、__LINKEDIT 等包含实际代码数据的段(位于 Data 部分)。


因此对于 segment_command_64 类型的 Load Command 也称之为: segment


segment 内部还包含一个重要的类型:section,section 用于描述一组相同类型的数据。例如:所有代码逻辑都位于名为 __text 的 section 内,所有 OC 类名称都位于名为 __objc_classname 的 section 内,而这两个 section 均位于 __TEXT 段(segment)。


image.png


segment_command_64 关键字段介绍:



  • segname: 当前 segment 名称,可为 __PAGEZERO、__TEXT、__DATA、__DATA_CONST 、__LINKEDIT 之一

  • vmaddr: 当前 segment 加载到内存后的虚拟地址(实际还要加上 ALSR 偏移才是真实的虚拟地址)

  • vmsize: 当前 segment 占用的虚拟内存大小

  • fileoff: 当前 segment 在 Mach-O 文件中的偏移量,实际位置 = header 开始的地址 + fileoff

  • filesize: 当前 segment 在 Mach-O 文件中的实际大小,考虑到内存对齐 vmsize >= filesize

  • nsects: 当前 segment_command_64 下面包含的 section 个数



关于随机地址偏移(ALSR) 的相关容内可自行查找相关资料进行了解,这里不再贅述



section 只有一种类型,其结构体定义如下:


struct section_64 { /* for 64-bit architectures */
char sectname[16]; /* name of this section */
char segname[16]; /* segment this section goes in */
uint64_t addr; /* memory address of this section */
uint64_t size; /* size in bytes of this section */
uint32_t offset; /* file offset of this section */
uint32_t align; /* section alignment (power of 2) */
uint32_t reloff; /* file offset of relocation entries */
uint32_t nreloc; /* number of relocation entries */
uint32_t flags; /* flags (section type and attributes)*/
uint32_t reserved1; /* reserved (for offset or index) */
uint32_t reserved2; /* reserved (for count or sizeof) */
uint32_t reserved3; /* reserved */
};

section 关键字段介绍:



  • sectname: section 的名称,可以为 __text,__const,__bss 等

  • segname: 当前 section 所在 segment 名称

  • addr: 当前 section 在虚拟内存中的位置(实际还要加上 ALSR 偏移才是真实的虚拟地址)

  • size: 当前 section 所占据的大小(磁盘大小与内存大小)

  • reserved1: 不同 section 类型有不同的意义,一般代表偏移量与索引值

  • flags: 类型&属性标记位,fishhook 使用此标记查找懒加载表&非懒加载表


需要注意:有且仅有 segment_command_64 类型的 Command 包含 section。


0x03


最后是数据区(Data),就是 Mach-O 文件所包含的代码或者数据;所有代码或者数据都根据 Load Command 的描述进行组织、排列。其中由segment_command_64 描述的数据或代码在 Data 部分中均以 section 为最小单位进行组织,并且这部分内容占大头。segment 再加上其它类型 Load Command (其实就是 __LINKEDIT segement)描述的数据共同组成了数据区。


注意:虽然名称为 __LINKEDIT (类型为:segment_command_64) 的 segment 下面所包含的 section 数量为 0,但根据其 fileoff,filesize 计算发现:


__LINKEDIT 的 segement 所指向的文件范围其实包含其它 Load Command (包含但不限于:LC_DYLD_INFO_ONLY、LC_FUNCTION_STARTS、LC_SYMTAB、LC_DYSYMTAB、LC_CODE_SIGNATURE)所指向的位置范围。


推导过程如下:


image.png


image.png


如上图所示在 Load Commands 中 __LINKEDIT 在 Mach-O 文件的偏移:0x394000 大小为:0x5B510。而 Mach-O header 的开始地址为 0x41C000。所以 __LINKEDIT 在 Mach-O 文件中的地址范围是:{header + fileoffset, header + fileoffset + filesize},代入上式就是 {0x41C000+0x394000, 0x41C000+0x394000+0x5B510},最终得到 {0x7B0000,0x80B510} 的地址范围。


从下图看,segment 最后一个 section 结束后的第一个地址就是上面的开始的范围,文件的结束地址也是上面计算结果的结束范围(最后一个数据地址占 16 字节)。


image.png


image.png


所以可以这样理解:名称为 __LINKEDIT Load Command 是一个虚拟 Command。它用来指示LC_DYLD_INFO_ONLY、LC_FUNCTION_STARTS、LC_SYMTAB、LC_DYSYMTAB、LC_CODE_SIGNATURE 等这些 Command 描述的数据在「文件与内存」中的总范围,而这些 Command 自己本身又描述了自各的范围,从地址范围来看 __LINKEDIT 是这些 Command 在数据部分的父级,尽管它本身并没有 section。


yuque_diagram (1).png


fishhook 的四个关键表


fishhook 的实现原理涉及到四个「表」,理解这四个表之间的关系便能理解 fishhook 的原理,且保证过目不忘。



  • 符号表(Symbol Table)

  • 间接符号表(Indirect Symbol Table)

  • 字符表(String Table)

  • 懒加载和非懒加载表(__la_symbol_ptr/__non_la_symbol_ptr)


符号表&字符表


yuque_diagram (2).png


其中符号表(Symbol Table)与字符表(String Table)在 LC_SYMTAB 类型的 Load Command 中描述。


struct symtab_command {
uint32_t cmd; /* LC_SYMTAB */
uint32_t cmdsize; /* sizeof(struct symtab_command) */

uint32_t symoff; /* 符号表(Symbol Table)在文件中相对 header 的偏移 */
uint32_t nsyms; /* 符号表(Symbol Table)数量 */

uint32_t stroff; /* 字符表(String Table)在文件中相对 header 的偏移 */
uint32_t strsize; /* 字符串(String Table)表总大小*/
};

符号表(Symbol Table)内容的数据结构用 nlist_64 表示:


struct nlist_64 {
union {
uint32_t n_strx; /* index int0 the string table */
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};

nlist_64 的第一个成员 n_un 代表当前符号的名称在字符表(String Table)中的相对位置,其它成员变量这里不需关注。


字符表(String Table)是一连串的字符 ASCII 码数据,每个字符串之间用 '\0' 进行分隔。


间接符号表


而间接符号表(Indirect Symbol Table)在 dysymtab_command 结构体的 Load Command(类型为LC_DYSYMTAB)中描述。


struct dysymtab_command {
uint32_t cmd; /* LC_DYSYMTAB */
uint32_t cmdsize; /* sizeof(struct dysymtab_command) */

/* 省略部分字段 */

uint32_t indirectsymoff; /* 间接符号表相对 header 的偏移 */
uint32_t nindirectsyms; /* 间接符号表中符号数量 */

/* 省略部分字段 */
};

间接符号表本质是由 int32 为元素组成的数组,元素中存储的数值代表当前符号在符号表(Symbol Table)中的相对位置。


懒加载和非懒加载表


懒加载与非懒加载表位于 __DATA/__DATA_CONST segment 下面的 section 中。


image.png


image.png


懒加载与非懒加载表有如下特点:



  • 当前可执行文件或动态库引用外部动态库符号时,调用到对应符号时会跳转到懒加载与非懒加载表指定的地址执行

  • 懒加载表在符号第一次被调用时绑定,绑定之前指向桩函数,由桩函数完成符号绑定,绑定完成之后即为对应符号真实代码地址

  • 非懒加载表在当前 Mach-O 被加载到内存时由 dyld 完成绑定,绑定之前非懒加载表内的值为 0x00,绑定之后同样也为对应符号的真实代码地址


敲黑板知识点:fishhook 的作用就是改变懒加载和非懒加载表内保存的函数地址


由于懒加载和非懒加载表没有包含任何符号字符信息,我们并不能直接通过懒加载表和非懒加载表找到目标函数在表中对应的位置,也就无法进行替换。因此,需要借助间接符号表(Indirect Symbol Table)、符号表(Symbol Table)、字符表(String Table)三个表之间的关系找到表中与之对应的符号名称来确认它的位置。


如何找到目标函数地址


这里借用一下 fishhook 官方给的示意图,可以先自行理解一下再往下看:


image.png


引用外部函数时需要通过符号名称来确定函数地址在懒加载和非懒加载表的位置,具体过程如下:



  1. 懒加载表与非懒加载表中函数地址的索引与间接符号表(Indirect Symbol Table)中的位置对应;


    以表中第 i 个函数地址为例,对应关系可以用伪公式来表述:


    间接符号表的偏移 = 间接符号表开始地址 + 懒加载表或非懒加载表指定的偏移(所在 section 的 reserved1 字段)+ i


  2. 间接符号表中保存的 int32 类型的数组,以上一步计算到的「间接符号表的偏移」为索引取数组内的值得到符到号中的位置


    同样得到一个等效伪公式:符号表的偏移 = 间接符号表开始地址 + 间接符号表的偏移


  3. 符号表中保存的数据是 nlist_64 类型,该第一个字段(n_un.n_strx)的值就是当前符号名称在字符表中的偏移


    等效伪公式:符号名称在字符表中的偏移 = (符号表的开始地址 + 符号表的偏移).n_un.n_strx


  4. 按照上面得到的偏移,去字符表中取出对应字符串(以 \0)结尾


    等效伪公式:懒加载表与非懒加载表中第 i 个函数名 = 字符表的开始地址 + 符号名称在字符表中的偏移



到这里我们从下至上进行公式代入,合并三个伪公式得到:


懒加载表或非懒加载表中第 i 个函数名 = 字符表的开始地址 + (符号表的开始地址 + 间接符号表开始位置 + 懒加载表或非懒加载表指定的偏移(所在 section 的 reserved1 字段)+ i).n_un.n_strx


现在,上面这个公式里还不知道的是三个开始地址:



  • 字符表(String Table)的开始地址

  • 符号表(Symbol)的开始地址

  • 间接符号表(Indirect Symbol Table)开始地址


而懒加载表或非懒加载表中函数地址个数也可以通过对应 section 的 size 字段(详情查看上文 section_64 结构体中的描述)计算而得到,公式:(section->size / sizeof(void *))。


到这里 fishhook 四个表的关系应该非常清楚了,fishhook 所做的无非是通过这个公式在懒加载表与非懒加载表中找到与目标函数名匹配的外部函数,一旦发现匹配则将其地址改为自定义的函数地址。


何为 linkedit_base


如果不考虑其它因素,实际上面三个表的开始地址可以直接通过 Mach-O 的 header 地址 + 对应的偏移就可以直接得到。以符号表(Symbol Table)为例:


image.png


Mach-O header 的开始地址如上文所述为:0x41C000,计算 0x41C000 + 0x3BECD8 = 0x7DACD8;再用 MachOView 查看这个地址,确实是符号表在文件中的位置:


image.png


同时上面的的推导也证明了 symtab_command->symoff symtab_command->stroff 是相对 Mach-O header 的偏移,并不是相对 __LINKEDIT 的偏移;


而 fishhook 源码中计算符号表开始地址的方式是:


nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);

导致不少博文说 linkedit_base 是 __LINKEDIT 段的基地址,symoff 是相对 __LINKEDIT segment 的偏移,这完全是错误的,在此可以明确的是:



  • linkedit_base 并不是 __LINKEDIT segment 在内存中的开始地址

  • linkedit_base 并不是 __LINKEDIT segment 在内存中的开始地址

  • linkedit_base 并不是 __LINKEDIT segment 在内存中的开始地址


fishhook 中计算 linkedit_base 的计算方式如下:


uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;

忽略掉随机地址偏移(ALSR)值: slide 后:


linkedit_base = linkedit_segment->vmaddr - linkedit_segment->fileoff;


linkedit_segment->vmaddr:代表 __LINKEDIT segment 在「虚拟内存」中的相对开始位置
linkedit_segment->fileoff:代表 __LINKEDIT segment 在「文件」中的相对开始位置


那这两个相减有什么意义呢?


要解答这个问题先来看 MachOView 给出的信息:


image.png


如上图,在 __LINKEDIT segment 之前的几个 segment (红框标记)可以解析出几个事实:



  • 每个 segment 的在「 Mach-O 文件」中的开始地址都等于上一个 segment 的 File Offset + File Size,第一个 segment 从 0 开始

  • 同理,每个 segment 在「虚拟内存」中的位置都等于上一个 segment 的 VM Address + VM Size,第一个 segment 从 0 开始

  • __PAGEZERO_DATAVM Size > File Size,而其它 segment 中这两个值相等,意味着两个 segment 加载到虚拟内存中后有一部分「空位」(因内存对齐而出现)

  • __PAGEZERO 不占 Mach-O 文件的存储空间,但在虚拟内存在占 16K 的空间


用图形表示即为:


image.png


故而 linkedit_base = linkedit_segment->vmaddr - linkedit_segment->fileoff 的意义为:



  • Mach-O 加载到内存后 __LINKEDIT 前面的 segment 因内存对齐后多出来的空间(空位)

  • Mach-O 加载到内存后 __LINKEDIT 前面的 segment 因内存对齐后多出来的空间(空位)

  • Mach-O 加载到内存后 __LINKEDIT 前面的 segment 因内存对齐后多出来的空间(空位)


这才是 linkedit_base 在物理上的真正意义,任何其它的定义都是错误的。


image.png


__LINKEDIT 本身的 VM Size == File Size 说明它包含的符号表、字符表与间接符号表三个表本身是内存对齐的,它们之间没有空位,所以它们本身在文件中的偏移 + linkedit_base 即为在内存中的实际位置。


  // 符号表在内存中的开始位置
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
// 字符表在内存中的开始位置
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// 间接符号表在内存中的开始位置
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);

最后


fishhook 在 APM、防逆向、性能优化等方向均有较多的应用,从本质上来看 fishhook 是对 Mach-O 文件结构的深度应用。相信在了解完原理之后再去看 Mach-O 文件的结构就比较简单了,与 Mach-O 文件结构相关的应用还有符号表的还原。下篇文章再与大家共同学习符号表还原的具体过程(虽然文件夹还没有创建 😂)。


如对本文有任何疑问,我们评论区交流 😀


作者:码不理
来源:juejin.cn/post/7360980866796388362
收起阅读 »

Flutter - 危!3.24版本苹果审核被拒!

iOS
欢迎关注微信公众号:FSA全栈行动 👋 一、概述 最近准备使用 Flutter 的 3.24 版本打包上架 APP,结果前天看到有人提了一个 issue: github.com/flutter/flu… ,说分别使用 3.24.3 和 3.24.4 提交苹果...
继续阅读 »

欢迎关注微信公众号:FSA全栈行动 👋



一、概述


最近准备使用 Flutter3.24 版本打包上架 APP,结果前天看到有人提了一个 issue: github.com/flutter/flu… ,说分别使用 3.24.33.24.4 提交苹果审核时,都惨遭被拒~


苹果反馈的信息如下:


Guideline 2.5.1 - Performance - Software Requirements

The app uses or references the following non-public or deprecated APIs:

Frameworks/Flutter.framework/Flutter

Symbols:

• _kCTFontPaletteAttribute
• _kCTFontPaletteColorsAttribute

The use of non-public or deprecated APIs is not permitted, as they can lead to a poor user experience should these APIs change and are otherwise not supported on Apple platforms.

可以看到,是说 Flutter 使用了未公开的 API,并且他使用 strings 命令也验证了这一点。


3.24.x


strings Runner.app/Frameworks/Flutter.framework/Flutter | grep kCT
SkCTMShader
kCTFontVariationAxisHiddenKey
kCTFontPaletteAttribute
kCTFontPaletteColorsAttribute

3.22.3


strings Runner.app/Frameworks/Flutter.framework/Flutter | grep kCT
SkCTMShader
kCTFontVariationAxisHiddenKey

我先在 Flutter 引擎源码中搜索,结果压根就搜索不到,随后打开了前几日编译好的引擎调试项目,结果一搜一个准,在 third_party 依赖下的 Skia 代码中,很快就定位到了引入未公开 API 的相关提交记录 skia-review.googlesource.com/c/skia/+/86…


我一看完就啪的一声敲起来了,很快啊!上来就是一个 Revert skia-review.googlesource.com/c/skia/+/91…


目前此次受影响的 Flutter 版本范围暂时是 3.24.0 ~ 3.24.4,得等待新版本的发布才可以解决。建议还没用上 3.24 的小伙伴先不要升级,那如果已经是 3.24 或者是一定要用 3.24.4 及以下版本的小伙伴要怎么办呢?那就跟我一起来自编译引擎吧~


二、编译引擎


环境



注意:全程需要科学上网环境,请自行查找和配置



首先拉取最新的 depot_tools,放到一个合适的位置,比如我放在 ~/development 目录下


cd ~/development
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

depot_tools 添加至环境变量,在你的终端配置文件里补充如下内容



终端配置文件因人而异,如:~/.bash_profile~/.zshrc~/.zprofile,请自行判断



export PATH = "$HOME/development/depot_tools":$PATH

然后 source ~/.zshrc(这里请根据自身情况修改终端配置文件路径)


拉源码


找个合适的目录,创建 engine 目录并进入


mkdir engine
cd engine

开始拉取源码


fetch flutter

它会在当前目录下创建 .gclient 文件,写好配置,并执行 gclient sync


solutions = [
{
"custom_deps": {},
"deps_file": "DEPS",
"managed": False,
"name": "src/flutter",
"safesync_url": "",
"url": "https://github.com/flutter/engine.git",
},
]

如果在拉取代码的过程中遇到如下问题


remote: Enumerating objects: 835563, done.
remote: Counting objects: 100% (1612/1612), done.
remote: Compressing objects: 100% (1011/1011), done.
error: RPC failed; curl 92 HTTP/2 stream 5 was not closed cleanly: CANCEL (err 8)
error: 1481 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output

src/flutter (ERROR)
----------------------------------------
[0:00:00] Started.

别慌,执行下方命令让其接着拉,直至完成


gclient sync

拉取完成后,去查看我们使用的 Flutter 版本对应的引擎版本,这里以 3.24.4 为例,打开链接:github.com/flutter/flu… ,拿到 db49896cf25ceabc44096d5f088d86414e05a7aa


执行如下命令进行切换


cd src/flutter
git checkout db49896cf25ceabc44096d5f088d86414e05a7aa

执行完成会输出如下内容


Previous HEAD position was b0a4ca92c4 Add FlPointerManager to process pointer events from GTK in a form suitable for Flutter. (#56443)
HEAD is now at db49896cf2 [CP-stable]Add xcprivacy privacy manifest to macOS framework (#55366)
post-checkout: The engine source tree has been updated.

You may need to run "gclient sync -D"

按照提示执行


gclient sync -D

调整源码


按路径 engine/src/flutter/third_party/skia/src/ports/SkTypeface_mac_ct.cpp 打开文件,按下方内容进行修改(红:删除,绿:新增)


static CFStringRef getCTFontPaletteAttribute() {
- static CFStringRef* kCTFontPaletteAttributePtr =
- static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteAttribute"));
- return *kCTFontPaletteAttributePtr;
+ return nullptr;
+ //static CFStringRef* kCTFontPaletteAttributePtr =
+ // static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteAttribute"));
+ //return *kCTFontPaletteAttributePtr;
}
static CFStringRef getCTFontPaletteColorsAttribute() {
- static CFStringRef* kCTFontPaletteColorsAttributePtr =
- static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteColorsAttribute"));
- return *kCTFontPaletteColorsAttributePtr;
+ return nullptr;
+ //static CFStringRef* kCTFontPaletteColorsAttributePtr =
+ // static_cast<CFStringRef*>(dlsym(RTLD_DEFAULT, "kCTFontPaletteColorsAttribute"));
+ //return *kCTFontPaletteColorsAttributePtr;
}

...

static bool apply_palette(CFMutableDictionaryRef attributes,
const SkFontArguments::Palette& palette) {
bool changedAttributes = false;
- if (palette.index != 0 || palette.overrideCount) {
+ if ((palette.index != 0 || palette.overrideCount) && getCTFontPaletteAttribute()) {
SkUniqueCFRef<CFNumberRef> paletteIndex(
CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &palette.index));
CFDictionarySetValue(attributes, getCTFontPaletteAttribute(), paletteIndex.get());
changedAttributes = true;
}

- if (palette.overrideCount) {
+ if (palette.overrideCount && getCTFontPaletteColorsAttribute()) {
SkUniqueCFRef<CFMutableDictionaryRef> overrides(

...

相应修改来自: skia-review.googlesource.com/c/skia/+/91…


编译


来到 engine/src 目录,使用 gn 编译生成 ninja 构建文件


./flutter/tools/gn --runtime-mode release --mac-cpu arm64
./flutter/tools/gn --ios --runtime-mode release

使用 ninja 编译引擎的最终产物


ninja -C out/host_release_arm64
ninja -C out/ios_release

如果你当前是 MacOS 15Sequoia 系统,在执行 ninja -C out/host_release_arm64 时会遇到如下错误


COPY '/System/Library/Fonts/A...arty/txt/assets/Apple Color Emoji.ttc'
FAILED: gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc
ln -f '/System/Library/Fonts/Apple Color Emoji.ttc' 'gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc' 2>/dev/null || (rm -rf 'gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc' && cp -af '/System/Library/Fonts/Apple Color Emoji.ttc' 'gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc')
cp: chflags: gen/flutter/third_party/txt/assets/Apple Color Emoji.ttc: Operation not permitted
[18/4139] SOLINK libvk_swiftshader.dylib libvk_swiftshader.dylib.TOC
ninja: build stopped: subcommand failed.

别急,打开 engine/src/build/toolchain/mac/BUILD.gn,做如下修改,修改完再执行 gnninja


    tool("copy") {
- command = "ln -f {{source}} {{output}} 2>/dev/null || (rm -rf {{output}} && cp -af {{source}} {{output}})"
+ command = "ln -f {{source}} {{output}} 2>/dev/null || (rsync -a --delete {{source}} {{output}})"
description = "COPY {{source}} {{output}}"
}

相应的 issue: #152978


好了,静静等待编译完成。


请注意,这将是个十分漫长且全程 CPU 占用率为 100% 的过程~


建议使用一台空闲的 Mac 电脑去做这个事!否则你将啥活也干不了~


验证


进入 engine/src/out/ios_release


strings Flutter.framework/Flutter | grep kCT 

SkCTMShader
kCTFontVariationAxisHiddenKey

可以看到,没有 kCTFontPaletteAttributekCTFontPaletteColorsAttribute


使用本地引擎


执行如下命令对项目进行编译


flutter build ipa \
--local-engine-src-path=/Users/lxf/engine/src \
--local-engine=ios_release \
--local-engine-host=host_release_arm64

如果你有使用 realm 的话,可能会遇到如下错误


Installing realm (1.0.3)
[!] /bin/bash -c
set -e
source "/Users/lxf/app/ios/Flutter/flutter_export_environment.sh" && cd "$FLUTTER_APPLICATION_PATH" && "$FLUTTER_ROOT/bin/flutter" pub run realm install --target-os-type ios --flavor flutter

You must specify --local-engine or --local-web-sdk if you are using a locally built engine or web sdk.

你需要对该文件
/Users/lxf/app/ios/.symlinks/plugins/realm/ios/realm.podspec 进行修改,在 \"$FLUTTER_ROOT/bin/flutter\"pub 中间加上引擎相关参数。如下所示


s.prepare_command           = "source \"#{project_dir}/Flutter/flutter_export_environment.sh\" && cd \"$FLUTTER_APPLICATION_PATH\" && \"$FLUTTER_ROOT/bin/flutter\" --local-engine-src-path /Users/lxf/engine/src --local-engine ios_release --local-engine-host host_release_arm64 pub run realm install --target-os-type ios --flavor flutter"

:script => 'source "$PROJECT_DIR/../Flutter/flutter_export_environment.sh" && cd "$FLUTTER_APPLICATION_PATH" && "$FLUTTER_ROOT/bin/flutter" --local-engine-src-path /Users/lxf/engine/src --local-engine ios_release --local-engine-host host_release_arm64 pub run realm install --target-os-type ios --flavor flutter',

如果你只是想对项目进行配置,则将 ipa 改为 ios,并加上 --config-only 参数即可。


flutter build ios \
--local-engine-src-path=/Users/lxf/engine/src \
--local-engine=ios_release \
--local-engine-host=host_release_arm64 \
--config-only

以前使用本地引擎只需要 --local-engine 参数,现在要求结合 --local-engine-host 一块使用,这里附上相关 issuegithub.com/flutter/flu… ,想了解的可以点开看看


三、最后


过程不难,麻烦的是拉源码和编译真的好慢,而且空间占用还大~


好了,本篇到此结束,感谢大家的支持,我们下次再见! 👋



如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有 iOS 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~



作者:LinXunFeng
来源:juejin.cn/post/7436567770907017257
收起阅读 »

使用 uni-app 开发 APP 并上架 IOS 全过程

iOS
教你用 uni-app 开发 APP 上架 IOS 和 Android 介绍 本文记录了我使用uni-app开发构建并发布跨平台移动应用的全过程,旨在帮助新手开发者掌握如何使用uni-app进行APP开发并最终成功上架。通过详细讲解从注册开发者账号、项目创建、...
继续阅读 »

教你用 uni-app 开发 APP 上架 IOS 和 Android


介绍


本文记录了我使用uni-app开发构建并发布跨平台移动应用的全过程,旨在帮助新手开发者掌握如何使用uni-app进行APP开发并最终成功上架。通过详细讲解从注册开发者账号、项目创建、打包发布到应用商店配置的每一步骤,希望我的经验分享能为您提供实用的指导和帮助,让您在开发之旅中少走弯路,顺利实现自己的应用开发目标。


环境配置


IOS 环境配置


注册开发者账号 


如果没有开发者账号需要注册苹果开发者账号,并且加入 “iOS Developer Program”,如果是公司项目那么可以将个人账号邀请到公司的项目中。


获取开发证书和配置文件



登录Apple Developer找到创建证书入口



申请证书的流程可以参考Dcloud官方的教程,申请ios证书教程


开发证书和发布证书都申请好应该是这个样子



创建App ID


创建一个App ID。App ID是iOS应用的唯一标识符,稍后你会在uni-app项目的配置文件中使用它。



配置测试机


第一步打开开发者后台点击Devices



第二步填写UDID



第三步重新生成开发证书并且勾选新增的测试机,建议一次性将所有需要测试的手机加入将来就不用一遍遍重复生成证书了




Android 环境配置


生成证书


Android平台签名证书(.keystore)生成指南: ask.dcloud.net.cn/article/357…


uni-app 项目构建配置


基础配置



版本号versionCode 前八位代表年月日,后两位代表打包次数


APP 图标设置



APP启动界面配置



App模块配置


注意这个页面用到什么就配置什么不然会影响APP审核



App隐私弹框配置



注意根据工业和信息化部关于开展APP侵害用户权益专项整治要求应用启动运行时需弹出隐私政策协议,说明应用采集用户数据,这里将详细介绍如何配置弹出“隐私协议和政策”提示框



详细内容可参考Uni官方文档
注意!androidPrivacy.json不要添加注释,会影响隐私政策提示框的显示!!!


在app启动界面配置勾选后会在项目中自动添加androidPrivacy.json文件,可以双击打开自定义配置以下内容:


{
"version" : "1",
"prompt" : "template",
"title" : "服务协议和隐私政策",
"message" : "  请你务必审慎阅读、充分理解“服务协议”和“隐私政策”各条款,包括但不限于:为了更好的向你提供服务,我们需要收集你的设备标识、操作日志等信息用于分析、优化应用性能。<br/>  你可阅读<a href="https://xxx.xxx.com/userPolicy.html">《服务协议》</a>和<a href="https://xxxx.xxxx.com/privacyPolicy.html">《隐私政策》</a>了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
"buttonAccept" : "同意并接受",
"buttonRefuse" : "暂不同意",
"hrefLoader" : "system|default",
"backToExit" : "false",
"second" : {
"title" : "确认提示",
"message" : "  进入应用前,你需先同意<a href="https://xxx.xxxx.com/userPolicy.html">《服务协议》</a>和<a href="https://xxx.xxxx.com/userPolicy.html">《隐私政策》</a>,否则将退出应用。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "退出应用"
},
"disagreeMode" : {
"loadNativePlugins" : false,
"showAlways" : false
},
"styles" : {
"backgroundColor" : "#fff",
"borderRadius" : "5px",
"title" : {
"color" : "#fff"
},
"buttonAccept" : {
"color" : "#22B07D"
},
"buttonRefuse" : {
"color" : "#22B07D"
},
"buttonVisitor" : {
"color" : "#22B07D"
}
}
}

我的隐私协议页面是通过vite打包生成的多入口页面进行访问,因为只能填一个地址所以直接使用生产环境的例如:xxx.xxxx.com/userPolicy.…


构建打包


使用HBuilderX进行云打包


IOS打包


构建测试包


第一步 点击发行->原生app云打包



第二步配置打包变量



运行测试包

打开HbuildX->点击运行->运行到IOS App基座



选择设备->使用自定义基座运行



构建生产包


和构建测试包基本差不多,需要变更的就是ios证书的profile文件和密钥证书



构建成功后的包在dist目录下release文件夹中



上传生产包


上传IOS安装包的方式有很多我们选择通过transporter软件上传,下载transporter并上传安装包



确认无误后点击交付,点击交付后刷新后台,一般是5分钟左右就可以出现新的包了。



App store connect 配置


上传截屏

只要传6.5和5.5两种尺寸的就可,注意打包的时候千万不能勾选支持ipad选项,不然这里就会要求上传ipad截屏



填写app信息


配置发布方式

自动发布会在审核完成后直接发布,建议选手动发布



配置销售范围


配置隐私政策


配置完之后IOS就可以提交审核了,不管审核成功还是失败Apple都会发一封邮件通知你审核结果


安卓打包


构建测试包


a3_mosaic_mosaic.png


构建的包在dist/debug目录下



运行测试包

如果需要运行的话,点击运行 -> 运行到Android App底座




构建生产包



构建后的包在dist目录下release文件夹中



构建好安卓包之后就可以在国内的各大手机厂商的应用商店上架了,由于安卓市场平台五花八门就不给大家一一列举了。


参考链接:



结语


本文介绍了使用uni-app开发并发布跨平台移动应用的完整流程,包括注册开发者账号、项目创建、打包发布以及应用商店配置,帮助开发者高效地将应用上架到iOS和Android平台。感谢您的阅读,希望本文能对您有所帮助。


作者:饼饼饼
来源:juejin.cn/post/7379958888909029395
收起阅读 »

哪位 iOS 开发还不知道,没有权限也能发推送?

iOS
这里每天分享一个 iOS 的新知识,快来关注我吧 前言 在 iOS App 开发中,推送通知是一个非常有效地触答和吸引用户的措施,通知可以成为让用户保持用户的参与度。 但大家都知道,苹果上每个 App 想要发推送给用户,都需要首先申请对应的权限,只有用户明确...
继续阅读 »

这里每天分享一个 iOS 的新知识,快来关注我吧


前言




在 iOS App 开发中,推送通知是一个非常有效地触答和吸引用户的措施,通知可以成为让用户保持用户的参与度。


但大家都知道,苹果上每个 App 想要发推送给用户,都需要首先申请对应的权限,只有用户明确点了允许之后才可以。


大部分的 App 都是在启动时直接申请权限,这样的话,用户可能会因为不了解 App 的情况而拒绝授权,就会导致 App 无法发送通知。


其实在 iOS 12 中有个方案叫做临时通知。这功能允许应用在没有申请到权限的情况下发送通知。


今天就来聊聊这个不为人知的隐藏功能。


请求临时授权


要请求临时授权,我们需要使用与请求完全授权相同的方法 requestAuthorization(options:completionHandler:),但需要添加 provisional 选项。


let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound, .badge, .provisional]) { isSuccess, error in
if let error {
print("Error requesting notification authorization: \(error)")
} else if isSuccess {
print("Requesting notification authorization is successed")
} else {
print("Requesting notification authorization is failed")
}
}


如果不加 provisional 选项,那么当你调用这个方法时,会直接弹出授权弹窗:



provisional 选项后这段代码不会触发对话框来提示用户允许通知。它会在首次调用时静默地授予我们的应用通知权限。


由于用户无感知,所以我们不必等待合适的时机来请求授权,可以在应用一启动时就调用。


发送通知


为了展示我们应用通知对用户的确是有价值的,我们可以开始通过本地或远程通知来定位用户。这里我们将发送一个本地通知作为示例,但如果你想尝试远程推送通知,可以查看我之前的几篇文章。


使用 iOS 模拟器测试推送


Xcode 14 模拟器支持远程推送


为了测试临时通知流程,以下是发送一个将在设置后 10 秒触发的本地通知的示例:


func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound, .badge, .provisional]) { isSuccess, error in
if let error {
print("Error requesting notification authorization: \(error)")
} else if isSuccess {
print("Requesting notification authorization is successed")
self.scheduleTestNotification()
} else {
print("Requesting notification authorization is failed")
}
}

return true
}

func scheduleTestNotification() {
let content = UNMutableNotificationContent()
content.title = "发现新事物!"
content.body = "点击探索你还未尝试的功能。"

let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: 10,
repeats: false
)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)

UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error scheduling notification: \(error)")
}
}
}


启动 App 后,我们退回到后台,等待 10 秒后,会看到我们发的通知已经出现在了通知中心中了。



此时可以看到这条通知中下边会出现两个按钮,如果用户想继续接受,就会点击继续接收按钮,如果不想继续接受,就会点击停止按钮。


如果用户点了停止按钮,那么就相当于我们应用的通知权限被用户拒绝了,相反的,如果用户点击了继续接收按钮,那么就相当于我们应用的通知权限被用户接受了。


鼓励用户完全授权


因此这条通知决定了用户是否继续接收我们 App 的通知,那么我们就需要慎重考虑这条通知的文案和时机,在用户体验到我们通知的好处之后,再发送这个通知,这样用户大概率就会选择继续接收通知。


如果用户仍然选择拒绝授权,我们还可以在 App 内的合适位置引导用户到设置页面去手动开启。


我这里写一个简单的示例,大家可以参考,先判断是否有权限,然后引导用户去设置页面。


class EnableNotificationsViewController: UIViewController {

private let titleLabel: UILabel = {
let label = UILabel()
label.text = "启用通知提示"
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: 20, weight: .bold)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()

private let descriptionLabel: UILabel = {
let label = UILabel()
label.text = "启用通知横幅和声音,保持最新了解我们的应用提供的一切。"
label.textAlignment = .center
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()

private let settingsButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("去设置", for: .normal)
button.setTitleColor(.white, for: .normal)
button.backgroundColor = .systemBlue
button.layer.cornerRadius = 8
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()

override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}

private func setupUI() {
view.backgroundColor = .white

view.addSubview(titleLabel)
view.addSubview(descriptionLabel)
view.addSubview(settingsButton)

NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),

descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20),
descriptionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
descriptionLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),

settingsButton.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 30),
settingsButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
settingsButton.widthAnchor.constraint(equalToConstant: 120),
settingsButton.heightAnchor.constraint(equalToConstant: 44)
])

settingsButton.addTarget(self, action: #selector(openSettings), for: .touchUpInside)
}

@objc private func openSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
}

// 检查通知权限
func checkNotificationAuthorization() {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
if settings.authorizationStatus == .authorized {
print("Notification authorization is authorized")
} else {
print("Notification authorization is not authorized")
}
}
}


最后


在我们的应用中实现临时通知是一种吸引用户的好方法,这其实也是苹果推荐的做法,创建一种尊重用户偏好的非侵入性通知体验,同时展示你应用通知的价值。


希望这篇文章对你有所帮助,如果你喜欢这篇文章,欢迎点赞、收藏、评论和转发,我们下期再见。


这里每天分享一个 iOS 的新知识,快来关注我吧



本文同步自微信公众号 “iOS新知”,每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!



作者:iOS新知
来源:juejin.cn/post/7424335565121093672
收起阅读 »

腾讯开源利器:让iOS UI调试更高效

iOS
最近逛G站,偶然发现一款 iOS UI 调试工具,那就是腾讯 QMUI团队 开源的LookinSever[1]。初步体验了一下,功能还是非常强大,简单记录并分享一下。 简介 腾讯的LookinServer[2]是一款专为...
继续阅读 »

最近逛G站,偶然发现一款 iOS UI 调试工具,那就是腾讯 QMUI团队 开源的LookinSever[1]。初步体验了一下,功能还是非常强大,简单记录并分享一下。


简介


腾讯的LookinServer[2]是一款专为iOS开发者设计的UI调试工具,类似于 Xcode 自带的 UI Inspector 工具,或者以前常用的另一款软件Reveal


LookinServer


基本功能


1、实时UI查看: LookinServer可以实时捕捉并显示iOS应用的UI层级结构。这包括所有的视图(Views)、控件(Controls)以及它们的属性(Properties)等。


2、层级视图展示: 通过图形化界面,开发者可以方便地浏览UI的层级关系。这有助于快速定位UI问题,例如某些视图被错误地覆盖或布局不正确。


3、属性编辑: 开发者可以直接在LookinServer中修改视图的属性(如frame、color等),并立即在应用中看到效果。这种所见即所得的调试方式大大加快了UI调整的效率。


4、视图调试: LookinServer支持对单个视图进行详细调试,包括查看其布局约束、事件响应链、以及性能指标等。


工作原理


1、数据抓取: LookinServer会将目标iOS应用中的UI数据抓取下来。这通常涉及到通过iOS的运行时(Runtime)机制和反射机制来获取应用当前的UI层级和视图信息。


2、通信机制: LookinServer客户端与iOS应用之间通过网络通信进行数据传输。应用中集成的LookinServer SDK会将视图层级、属性等数据打包发送到LookinServer客户端进行展示。


3、动态更新: 当开发者在LookinServer客户端中修改视图属性时,修改指令会通过通信机制发送回iOS应用,应用立即应用这些修改并更新显示。通过这种方式,实现了实时的UI调试。


使用场景


1、UI布局调试: 快速发现并修正UI布局问题,例如视图错位、层级不正确等。


2、UI性能优化: 查看每个视图的性能指标,找出性能瓶颈并进行优化。


3、快速迭代: 在开发过程中频繁修改UI时,通过LookinServer可以快速预览效果,减少编译和重启应用的时间。


优势


1、提高效率: 实时查看和修改UI,大大减少了传统调试方式的时间成本。


2、直观可视化: 图形化的视图层级展示,让开发者可以更直观地理解UI结构。


3、易于集成: LookinServer Framework易于集成到现有项目中,支持CocoaPods、 Swift Package Manager以及手动集成,支持OC和Swift,不需要对项目做大的改动。


小试牛刀


1、安装Lookin: 官网[3]下载并安装Lookin Mac客户端。


2、安装 LookinServer Framework:



  • • 通过 CocoaPods

  • • Swift项目:pod 'LookinServer', :subspecs => ['Swift'], :configurations => ['Debug']

  • • ObjC项目:pod 'LookinServer', :configurations => ['Debug']

  • • 通过 Swift Package Managergithub.com/QMUI/Lookin…

  • • 手动集成:下面以OC项目为例。 将下载的源码导入项目中,注意Swift文件夹里面的可以删除,或者将文件LKS_SwiftTraceManager.swift不加到target里参与编译。


图片


Debug模式下,打开SHOULD_COMPILE_LOOKIN_SERVER宏定义。


图片


3、简单使用: 建个项目,拖几个控件,运行,打开第一步安装的LookinMac 软件,监测到运行的项目,可以看到视图层级关系、target-action、手势、常见属性设置等,UI及时同步刷新。


图片


注意事项


1、需要在 Debug 模式下使用 


2、使用 1.0.6及以后 的版本


总结


腾讯的LookinServer是一个强大的iOS UI调试工具,其通过实时查看、编辑和调试视图层级和属性,极大地提高了UI开发和调试的效率。通过掌握其原理和使用方法,开发者可以更高效地处理UI问题,提高应用的整体质量。


引用链接


[1] LookinSever: github.com/QMUI/Lookin…

[2] LookinServer: lookin.work/

[3] 官网: lookin.work/


作者:人月神话Lee
来源:juejin.cn/post/7376586649982091301
收起阅读 »

iOS 开发们,是时候干掉 Charles 了

iOS
这里每天分享一个 iOS 的新知识,快来关注我吧 前言 一说到 mac 上的抓包工具,大家自然而然的会想到 Charles,作为老牌抓包工具,它功能很全面,也很强大。但是随着系统的不断更新迭代,Charles 的一些缺点也慢慢表露出来,比如: 卡顿,特别在一...
继续阅读 »

这里每天分享一个 iOS 的新知识,快来关注我吧


前言


一说到 mac 上的抓包工具,大家自然而然的会想到 Charles,作为老牌抓包工具,它功能很全面,也很强大。但是随着系统的不断更新迭代,Charles 的一些缺点也慢慢表露出来,比如:



  1. 卡顿,特别在一些低端 Mac 机型上比较卡,体验就很差

  2. 吃内存,时间久了总是得重启一下,不然内存吃的太多

  3. 页面老旧,感觉像是旧时代的产品


今天来介绍一个我觉得比较好用的抓包工具,Proxyman


Proxyman 配置


安装就不说了,大家可以自行去官网下载安装。


Proxyman 提供了一个免费版本,其中包含所有基本功能,平时使用应该是够了,如果重度使用,也可以考虑购买高级版本。


这是他的主页面,看起来是不是挺干净的:



安装好了之后都需要配置代理和 https 证书,这点 Proxyman 做的非常好,首先点击顶部导航上的证书,可以看到所有安装证书的选项:



教程是全中文的,而且设置步骤非常详细,比如 iOS 设置指南:



Proxyman 针对 iOS 开发还提供了一种无配置的方案,可以直接通过 Pod 或者 SPM 添加 atlantis-proxyman框架,这样可以在不进行任何配置的情况下进行代理监听:



除了监控手机的流量,也可以很方便地添加 iOS 模拟器的监控,只需要选择顶部菜单 -> 证书 -> 在 iOS 上安装证书 -> 模拟器



按照以上步骤操作即可。


使用


配置完成之后就可以在 Proxyman 主页面上看到接口请求了,接下来介绍一些常用的功能。


本地 Mock 数据


本地 Mock 数据是很常见的需求,你只需要选中某个接口后,鼠标右键,选择工具 -> 本地映射



然后在弹出的新页面中编辑相应即可,非常方便:



断点


断点工具可以让我们动态编辑请求或响应的内容。


它本地映射在同一个菜单栏里,鼠标右键,选择工具 -> 断点,然后进行对应的设置即可。


创建断点后,Proxyman 将在收到我们想要拦截的请求或响应后立即打开一个新的编辑窗口。然后我们根据需要修改数据,最后再继续即可。


导出请求和响应数据


有时候我们需要把有问题的接口保存下载给其他服务端的同学查看。选中具体的请求,点击鼠标右键,选择导出,然后再选择你要导出的格式:



不过这里导出的 Proxyman 日志需要使用 Proxyman 才能打开,也就是说,需要想查看这条请求的人的电脑上也安装 Proxyman,如果他没有安装,也可以选择拷贝 cURL。


模拟弱网


好的产品一定能够在弱网下正常使用,所以弱网测试也成为了日常开发必要的步骤,点击顶部菜单栏,选择工具 -> 网络状况,可以打开一个新页面,然后点击左下角为一个新的域名添加网络状况,这里可以根据你的需求选择不同的网络状况:



总结


从流畅度、功能引导等方面,我感觉 Proxyman 是比 Charles 好用的,除了以上介绍到的功能,还有很多更强大更全面的功能。例如远程映射、保存会话、GraphQL 调试、黑名单白名单、Protobuf、自定义脚本等等,大家可以自己试试看。


这里每天分享一个 iOS 的新知识,快来关注我吧



本文同步自微信公众号 “iOS新知”,每天准时分享一个新知识,这里只是同步,想要及时学到就来关注我吧!



作者:iOS新知
来源:juejin.cn/post/7355845238906175551
收起阅读 »

教你做事,uniapp ios App 打包全流程

iOS
背景使用uniapp 开发App端,开发完成后,ios端我们需要上架到App Store,在此之前,我们需要将App先进行打包。在HubilderX中,打包ios App我们需要四个东西,分别是:Bundle ID证书私钥密码证书私钥文件证书profile文件...
继续阅读 »

IMG_6518.PNG

背景

使用uniapp 开发App端,开发完成后,ios端我们需要上架到App Store,在此之前,我们需要将App先进行打包。

在HubilderX中,打包ios App我们需要四个东西,分别是:

  • Bundle ID
  • 证书私钥密码
  • 证书私钥文件
  • 证书profile文件

下面,我将一步步讲解,如何获取以上文件。

加入苹果开发者

image.png

  • 使用iPhone或iPad 在App Store 下载 Apple Developer

  • 进入App
  • 点击底部【账户】
  • 点击立即注册
  • 填写资料(填写的信息要与你的苹果账号对应,因为这个App需要双重认证)
  • 填完信息和资料后点击订阅
  • 付费(需要给你的手机添加付款方式)
  • 付费成功
  • 成功加入苹果开发者计划

生成p12证书和证书私钥密码

步骤:CSR文件 ➡️ cer文件 ➡️ p12文件

  1. 进入Apple Developer官网,登录成功后,点击顶部导航栏的【账户】,在【账户】页面点击【证书】 image-20230808113929505.png
  2. 进入到【Certificates, Identifiers & Profiles】页面,点击+号,开始注册证书 image-20230808121650456.png
  3. 选择【iOS Distribution (App Store and Ad Hoc)】再点击【Continue】 image-20230808121858695.png
  4. 上传证书签名(CSR文件) image-20230808122100213.png 下面会教大家如何生成CSR文件:
  • 打开Mac上的【钥匙串访问】App

  • 依次选择App顶上菜单栏的【钥匙串访问】➡️【证书助理】➡️【从证书颁发机构请求证书…】 image-20230808122243972.png
  • 打开弹窗,填写两个邮件、常用名称,选择存储到磁盘,点击【继续】 image-20230808122543912.png
  • 存储到桌面,得到【CSR文件】
  1. 回到网页,选择并上传刚刚生成的【CSR文件】,点击【Continue】 image-20230808123228957.png
  2. 到这里【cer文件】就生成好了,点击【Download】下载到桌面 image-20230808123338059.png
  3. 得到【cer文件】

接下来我们要根据这个【cer文件】导出生成为【p12文件】

  1. 双击打开【cer文件】,Mac会自动打开【钥匙串访问】,选中左侧登录 ➡️ 我的证书 ➡️ 证书文件,找到这个【cer证书】 image-20230808123644018.png
  2. 此时证书是未受信任,双击该证书,在弹窗中展开【信任】,选择【始终信任】,然后关闭输入密码保存,证书就改成受信任了 image-20230808123833153.png image-20230808123940575.png
  3. 右键选中该证书,在菜单中选择【导出】 image-20230808124140464.png
  4. 输入密码,即【证书私钥密码】(该密码就是HbuilderX发行打包App时,填写的【证书私钥密码】),之后再输入电脑密码

  5. 最终得到【p12证书】

生成Bundle ID

  1. 回到页面(Certificates, Identifiers & Profiles),选择【Identifiers】,点击+号 image-20230808125006785.png
  2. 选择【App IDs】,点击【Continue】 image-20230808125143344.png
  3. 选择【App】,点击【Continue】 image-20230808125209004.png
  4. 填写描述和Bundle ID,ID格式如:com.domainname.appname image-20230808125323834.png
  • 下面的功能如果有需要的话,需要勾选上 image-20230808125520515.png
  • 比如你的App需要Apple登录的话,则需要勾选【Sign In with Apple】

  1. 设置完成后,点击右上角的【Continue】,【Bundle ID】就生成好了 image-20230808132943388.png

生成profile文件

  1. 回到页面(Certificates, Identifiers & Profiles),选择【Profiles】,点击+号 image-20230808130252462.png
  2. 选择【App Store】,点击【Continue】 image-20230808130328340.png
  3. 选择上一步生成的【身份标识】,点击【Continue】 image-20230808130441878.png
  4. 选择第一步生成的【Certificates证书】,点击【Continue】 image-20230808131020305.png
  5. 设置【配置文件名称】,点击【Generate】生成 image-20230808131126095.png
  6. 点击【Download】下载【profile文件】 image-20230808131225658.png
  7. 得到【profile文件】

到这里,【Bundle ID】、【p12文件】【证书私钥密码】、【profile文件】就生成好了,可以去HbuilderX打包ios App了

HbuilderX 打包ios App

  1. 填入配置和文件 Snipaste_2023-08-08_13-20-10.png
  2. 点击【打包】,即可生成App image.png

到这一步,iOS App就生成好了。


作者:Jerry丶Hu
来源:juejin.cn/post/7264939254290579495
收起阅读 »

扒一扒uniapp是如何做ios app应用安装的

iOS
为何要扒 因为最近有移动端业务的需求,用uniapp做了ios、Android双端的app应用,由于没有资质上架AppStore和test flight,所以只能使用苹果的超签(需要ios用户提供uuid才能加入测试使用,并且只支持100人安装使用)。打包出来...
继续阅读 »

为何要扒


因为最近有移动端业务的需求,用uniapp做了ios、Android双端的app应用,由于没有资质上架AppStore和test flight,所以只能使用苹果的超签(需要ios用户提供uuid才能加入测试使用,并且只支持100人安装使用)。打包出来生成的是一个ipa包,并不能直接安装,要通过爱思助手这类的应用装一下ipa包。但交付到客户手上就有问题了,还需要电脑连接助手才能安装,那岂不是每次安装新版什么的,都要打开电脑搞一下。因此,才有了这次的扒一扒,目标就是为了解决只提供一个下载链接用户即可下载,不用再通过助手类应用安装ipa包。




开干


官方模板




先打开uniapp云打包一下项目看看


image-20230824112232275.png




复制地址到移动端浏览器打开看看


image-20230824112410817.png


这就对味了,都知道ios是不能直接打开ipa文件进行安装的,接下来就研究下这个页面的执行逻辑。




开扒




F12打开choromdevtools,ctrl+s保存网页html。


image.png


保存成功,接下来看看html代码(样式代码删除了)


    <!DOCTYPE html>
<!-- saved from url=(0077)https://ide.dcloud.net.cn/build/download/2425a4b0-4229-11ee-bd1b-67afccf2f6a7 -->
<html>
   <head>
       <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  <meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no, width=device-width">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
 </head>

<body>
<br><br>
   <center>
       <a class="button" href="itms-services://?action=download-manifest&amp;url=https://ide.dcloud.net.cn/build/ipa-xxxxxxxxxxx.plist">点击安装</a>
   </center>
   <br><br>
   <center>注意:只提供包名为io.dcloud.appid的包的直接下载安装,如果包名不一致请自行搭建下载服务器</center>
</body>
</html>



解析




从上面代码可以看出,关键代码就一行也就是a标签的href地址("itms-services://?action=download-manifest&url=ide.dcloud.net.cn/build/ipa-x…")


先看看itms-services是什么意思,下面是代码开发助手给的解释


image-20230824113418246.png


大概意思就是itms-services是苹果提供给开发者一个的更新或安装应用的协议,用来做应用分发的,需要指向一个可下载的plist文件地址。




什么又是plist呢,这里再请我们的代码开发助手解释一下


image-20230824113748570.png


对于没接触过ios相关开发的,连plist文件怎么写都不知道,既然如此,那接下来就来扒一下dcloud的pilst文件,看看官方是怎么写的吧。




打开浏览器,copy一下刚刚扒下来的html文件下a标签指向的地址,复制url后面plist文件的下载地址粘贴到浏览器保存到桌面。


image-20230824114108792.png


访问后会出现


image-20230824115354028.png




别担心,这时候直接按ctrl+s可以直接保存一个plist.xml文件,也可以打开devtools查看网络请求,找到ipa开头的请求


image-20230824115609551.png


直接新建一个plist文件,cv一下就好,我这里就选择保存它的plist.xml文件,接下来康康文件里到底是什么


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>items</key>
<array>
<dict>
<key>assets</key>
<array>
<dict>
<key>kind</key>
<string>software-package</string>
<key>url</key>
<string>https://bdpkg-aliyun.dcloud.net.cn/20230824/xxxxx/Pandora.ipa?xxxxxxxx</string>
</dict>
      <dict>
<key>kind</key>
<string>display-image</string>
<key>needs-shine</key>
<false/>
<key>url</key>
<string>https://qiniu-web-assets.dcloud.net.cn/unidoc/zh/uni.png</string>
</dict>
</array>
<key>metadata</key>
<dict>
<key>kind</key>
<string>software</string>
<key>bundle-identifier</key>
<string>xxxxx</string>
<key>title</key>
<string>HBuilder手机应用</string>
</dict>
</dict>
</array>
</dict>
</plist>

直接抓重点,这里存你存放ipa包的地址


image-20230824120013828.png


这里改你应用的昵称


image-20230824120453368.png


这里改图标


image-20230824120509797.png


因篇幅限制,想了解plist的自行问代码助手或者搜索引擎。




为我所用


分析完了,如何为我所用呢,首先按照分析上扒下来的plist文件修改下自身应用的信息,并且需要服务器存放ipa文件,这里我选择了unicloud,开发者可以申请一个免费的空间(想了解更多的自己去dcloud官网看看,说多了有打广告嫌疑),替换好大概如下:


image-20230824155040313.png


将plist文件放到服务器上后,拿到plist的下载地址,打开扒下来的html,将a标签上的url切换成plist文件的下载地址,如图:


image-20230824155306228.png


可以把页面上没用的信息都删掉,保存,再把html放到服务器上,用户访问这个地址,就可以直接下载描述文件安装ipa包应用了(记得需要添加用户的uuid到开发者账号上),其实至此需求已经算是落幕了,但转念想想还是有点麻烦,于是又优化了一下,将a标签中的href信息,直接加载到二维码上供用户扫描便可直接下载,相对来说更方便一点,于是我直接打开草料,生成了一个二维码,至此,本次扒拉过程结束,需求落幕!


作者:廿一c
来源:juejin.cn/post/7270799565963149324
收起阅读 »

震惊:苹果手机电池栏“黑白无常”

iOS
前言: 当程序员👨🏻‍💻遇到难以解决的bug时,大家都会说同样的口头禅:真是见了鬼了(建国后不可以) 现象: 手机电池栏左黑右白,如下图    👈🏻左边的时间是黑色的字体,右边的信号和电池是白色的字体👉🏻,这种感觉就像电池栏在呼喊: 我与你之...
继续阅读 »

前言:



当程序员👨🏻‍💻遇到难以解决的bug时,大家都会说同样的口头禅:真是见了鬼了(建国后不可以)



现象:



手机电池栏左黑右白,如下图













👈🏻左边的时间是黑色的字体,右边的信号和电池是白色的字体👉🏻,这种感觉就像电池栏在呼喊:


我与你之间虽只差一个灵动岛的距离,却已是黑白相隔


心路历程:


初步断定应该是UIStatusBarStyle的设置问题,查看App的infoplist文件发现确实有 View controller-based status bar appearance = YES的相关设置,有特殊需要的界面就需要自己手动处理一下


- (UIStatusBarStyle)preferredStatusBarStyle {
if (@avaliable(iOS 13.0,*)) {
return XXXX;
} else {
return XXXXX;
}
return XXXXXXX;
}

但是本着谁污染谁治理的原则,我没有特殊的场景我不处理,别的地方设置了也不应该影响我吧。再退一步来说,就算影响了,也不应该给我显示成这种左黑右白的鬼样子吧。不过产品说这个功能很高级,可以保留。玩笑归玩笑,问题还是得解决。


解决方案:


最先想到的肯定是给出问题的界面实现一下 preferredStatusBarStyle,效果确实不错,解决了,如图:












先解决了问题上线再说,就像罗永浩说的:












但是这该死的求知欲天天折磨着我,直到今天在搞包体积的时候,脚本检测到这个大的背景图,发现是从左往右渐变加深的,难道和图片有关系?本着试一试的原则,把图片删除的同时并且把preferredStatusBarStyle的代码注释掉,竟然好了,不可思议:












找设计师要了不带渐变的图片,又尝试了一把












对比俩种情况不难发现:


•无背景图,系统的导航栏显示的是黑色


•有背景图,系统的导航栏显示的是白色


💡💡 是不是UIKit对导航栏背景图做了监听?目的是为了让用户可以清晰的看到电池栏的信息?


带着这个猜测,去看了下去年的WWDC,果然找到了答案:



在iOS17中,default样式会根据内容的深浅调整status bar的颜色。



由于没有手动处理preferredStatusBarStyle,而背景图又是从左到右渐变加深,所以电池栏显示成了左黑右白。


后语:


由此可见:


1、遇到难以解决的问题,把锅甩给系统bug是多么的机智🐶;


2、建国后还真的是:





吴京达咩是什么梗-抖音





参考链接:


developer.apple.com/videos/play…


作者:京东云开发者
来源:juejin.cn/post/7344710026853007394
收起阅读 »

编写LLVM Pass

iOS
的基础上,编写一个简单的LLVM Pass。在llvm-project-17.0.6.src/llvm/include/llvm/Transforms/SweetWound/目录下,新建ModuleTest.h文件,并写入如下代码:// ModuleTest....
继续阅读 »

上一篇的基础上,编写一个简单的LLVM Pass。

  1. llvm-project-17.0.6.src/llvm/lib/Transforms/目录下,新建一个文件夹SweetWound


  1. 在在llvm-project-17.0.6.src/llvm/include/llvm/Transforms/目录下,新建一个文件夹SweetWound


  1. Transforms目录下的CMakeLists.txt文件末尾,增加如下代码:

...
add_subdirectory(SweetWound)


  1. llvm-project-17.0.6.src/llvm/include/llvm/Transforms/SweetWound/目录下,新建ModuleTest.h文件,并写入如下代码:

// ModuleTest.h
#ifndef _LLVM_TRANSFORMS_SWEETWOUND_H_
#define _LLVM_TRANSFORMS_SWEETWOUND_H_
#include "llvm/Pass.h"
#include "llvm/IR/PassManager.h"
#include "llvm/IR/Module.h"

namespace llvm {
class ModuleTestPass : public PassInfoMixin {
public:
bool flag;
ModuleTestPass(bool flag) {
this->flag = flag;
}
PreservedAnalyses run(Module &M, ModuleAnalysisManager &AM);
static bool isRequired() {
return true;
}
};
}

#endif


  1. llvm-project-17.0.6.src/llvm/lib/Transforms/SweetWound/目录下,创建ModuleTest.cpp文件,并写入如下代码:

// ModuleTest.cpp
#include "llvm/Transforms/SweetWound/ModuleTest.h"

using namespace llvm;

PreservedAnalyses ModuleTestPass::run(Module &M, ModuleAnalysisManager &AM) {
if (this->flag == true) {
outs() << "[SW]:" << M.getName() << "\n";
return PreservedAnalyses::none();
}
return PreservedAnalyses::all();
}


  1. llvm-project-17.0.6.src/llvm/lib/Transforms/SweetWound/目录下,创建CMakeLists.txt文件,并写入如下代码:

add_llvm_component_library(LLVMSweetWound
ModuleTest.cpp

LINK_COMPONENTS
Analysis
Core
Support
TransformUtils
)


  1. 修改llvm-project-17.0.6.src/llvm/lib/Passes/PassBuilder.cpp文件:

......
#include
//======================导入头文件======================//
#include "llvm/Transforms/SweetWound/ModuleTest.h"
......
// ======================增加编译参数 begin ======================//
static cl::opt s_sw_test("test", cl::init(false), cl::desc("test module pass."));
// ======================增加编译参数 end ========================//

PassBuilder::PassBuilder(TargetMachine *TM, PipelineTuningOptions PTO,
std::optional PGOOpt,
PassInstrumentationCallbacks *PIC)
: TM(TM), PTO(PTO), PGOOpt(PGOOpt), PIC(PIC) {
......
// 注册Pass
this->registerPipelineStartEPCallback(
[](llvm::ModulePassManager &MPM, llvm::OptimizationLevel Level) {
MPM.addPass(ModuleTestPass(s_sw_test));
}
);
}
  1. 重新执行编译脚本,成功后,替换LLVM17.0.6.xctoolchain文件。

  2. 在Xcode的Build Settings-->Other C Flags中,设置编译参数:-mllvm -test:


  1. Command + B编译(或Command + R运行):


可以看到每个编译单元都有对应的输出,即代表编写的LLVM Pass加载成功!!!
收起阅读 »

编译llvm源码

iOS
前往LLVM官网,下载LLVM17.0.6版本的源码:下载源码后,解压到任意目录:在llvm-project-17.0.6.src同级目录下,编写编译脚本build.sh:#!/bin/shpwd_path=`pwd`build_llvm=${pwd_path...
继续阅读 »
  1. 前往LLVM官网,下载LLVM17.0.6版本的源码

  1. 下载源码后,解压到任意目录:


  1. llvm-project-17.0.6.src同级目录下,编写编译脚本build.sh:

#!/bin/sh
pwd_path=`pwd`
build_llvm=${pwd_path}/build-llvm #编译目录
installprefix=${pwd_path}/install #install目录
llvm_project=${pwd_path}/llvm-project-17.0.6.src/llvm #项目目录

mkdir -p $build_llvm
mkdir -p $installprefix

cmake -G Ninja -S ${llvm_project} -B $build_llvm \
-DLLVM_ENABLE_PROJECTS="clang" \
-DLLVM_CREATE_XCODE_TOOLCHAIN=ON \
-DLLVM_INSTALL_UTILS=ON \
-DCMAKE_INSTALL_PREFIX=$installprefix \
-DCMAKE_BUILD_TYPE=Release

ninja -C $build_llvm install-xcode-toolchain


  1. 执行编译脚本:

$ chmod +x ./build.sh
$ ./build.sh

编译过程需要大约20分钟左右。

  1. 编译完成之后,即可在当前目录下的install目录下看到编译产物:



  1. LLVM17.0.6.xctoolchain文件复制到~/Library/Developer/Toolchains/目录下:


  1. 点击菜单栏Xcode——>Toolchains,选择org.llvm.17.0.6:



  1. 在Xcode的Build Settings中,关闭Enable Index-While-Building Functionality


  1. Command+B编译(或Command + R 运行):


收起阅读 »

iOS 组件开发教程——手把手轻松实现灵动岛

1、先在项目里创建一个Widget Target2、一定要勾选 Include live Activity,然后输入名称,点击完成既可。3、在 Info.plist 文件中声明开启,打开 Info.plist 文件添加 NSSupportsLiveActivi...
继续阅读 »

1、先在项目里创建一个Widget Target


2、一定要勾选 Include live Activity,然后输入名称,点击完成既可。


3、在 Info.plist 文件中声明开启,打开 Info.plist 文件添加 NSSupportsLiveActivities,并将其布尔值设置为 YES。

4、我们创建一个IMAttributes,

struct IMAttributes: ActivityAttributes {
public typealias IMStatus = ContentState

public struct ContentState: Codable, Hashable {
var callName: String
var imageStr : String
var callingTimer: ClosedRange<Date>
}

var callName: String
var imageStr : String
var callingTimer: ClosedRange<Date>
}

5、灵动岛界面配置

struct IMActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: IMAttributes.self) { context in
// 创建显示在锁定屏幕上的演示,并在不支持动态岛的设备的主屏幕上作为横幅。
// 展示锁屏页面的 UI

} dynamicIsland: { context in
// 创建显示在动态岛中的内容。
DynamicIsland {
//这里创建拓展内容(长按灵动岛)
DynamicIslandExpandedRegion(.leading) {
Label(context.state.callName, systemImage: "person")
.font(.caption)
.padding()
}
DynamicIslandExpandedRegion(.trailing) {
Label {
Text(timerInterval: context.state.callingTimer, countsDown: false)
.multilineTextAlignment(.trailing)
.frame(width: 50)
.monospacedDigit()
.font(.caption2)
} icon: {
Image(systemName: "timer")
}
.font(.title2)
}
DynamicIslandExpandedRegion(.center) {
Text("\(context.state.callName) 正在通话中...")
.lineLimit(1)
.font(.caption)
.foregroundColor(.secondary)
}

}
//下面是紧凑展示内容区(只展示一个时的视图)
compactLeading: {
Label {
Text(context.state.callName)

} icon: {
Image(systemName: "person")
}
.font(.caption2)
} compactTrailing: {
Text(timerInterval: context.state.callingTimer, countsDown: true)
.multilineTextAlignment(.center)
.frame(width: 40)
.font(.caption2)
}
//当多个Live Activities处于活动时,展示此处极小视图
minimal: {
VStack(alignment: .center) {
Image(systemName: "person")


}
}
.keylineTint(.accentColor)
}
}
}

6、在需要的地方启动的地方调用,下面是启动灵动岛的代码

        let imAttributes = IMAttributes(callName: "wqd", imageStr:"¥99", callingTimer: Date()...Date().addingTimeInterval(0))

//初始化动态数据
let initialContentState = IMAttributes.IMStatus(callName: name, imageStr: "ia.imageStr", callingTimer: Date()...Date().addingTimeInterval(0))

do {
//启用灵动岛
//灵动岛只支持Iphone,areActivitiesEnabled用来判断设备是否支持,即便是不支持的设备,依旧可以提供不支持的样式展示
if #available(iOS 16.1, *) {
if ActivityAuthorizationInfo().areActivitiesEnabled == true{

}
} else {
// Fallback on earlier versions
}
let deliveryActivity = try Activity<IMAttributes>.request(
attributes: imAttributes,
contentState: initialContentState,
pushType: nil)
//判断启动成功后,获取推送令牌 ,发送给服务器,用于远程推送Live Activities更新
//不是每次启动都会成功,当已经存在多个Live activity时会出现启动失败的情况
if deliveryActivity.activityState == .active{
_ = deliveryActivity.pushToken
}
// deliveryActivity.pushTokenUpdates //监听token变化
print("Current activity id -> \(deliveryActivity.id)")
} catch (let error) {
print("Error info -> \(error.localizedDescription)")
}
6.此处只有一个灵动岛,当一个项目有多个灵动岛时,需要判断更新对应的activity

func update(name:String) {
Task {

let updatedDeliveryStatus = IMAttributes.IMStatus(callName: name, imageStr: "ia.imageStr", callingTimer: Date()...Date().addingTimeInterval(0))

for activity in Activity<IMAttributes>.activities{
await activity.update(using: updatedDeliveryStatus)
}
}
}

7、停止灵动岛

func stop() {
Task {
for activity in Activity<IMAttributes>.activities{
await activity.end(dismissalPolicy: .immediate)
}
}
}


收起阅读 »

某运动APP的登录协议分析

iOS
前言 最近在尝试逆向方向相关的探索,针对某款运动APP的登录协议进行了分析,此文记录一下分析过程与结果,仅供学习研究,使用的工具较少,内容也比较简单,新手项,大佬请跳过。针对密码登录模块进行分析,随便输入一个手机号与密码,后续使用抓包工具分析,针对登录协议的几...
继续阅读 »

前言


最近在尝试逆向方向相关的探索,针对某款运动APP的登录协议进行了分析,此文记录一下分析过程与结果,仅供学习研究,使用的工具较少,内容也比较简单,新手项,大佬请跳过。针对密码登录模块进行分析,随便输入一个手机号与密码,后续使用抓包工具分析,针对登录协议的几个字段从学习角度还是值得看下实现逻辑的。


抓包



  1. 抓包使用 Charles,请自行安装并配置证书

  2. 抓取登陆接口,点击密码登陆。使用假账密测试抓包,能够抓包成功
    image-20230807174512922.png


Sign分析


首先能看到请求头里面有sign字段,针对该字段进行分析:



sign: b61df9a8bce7a8641c5ca986b55670e633a7ab29



整体长度为40,常用的MD5长度为32,第一反应不太像,但是也有可能md5以后再拼接其它字段,sha1散列函数的长度是40,正好吻合。那我们就一一验证,先看下是否有MD5的痕迹,直接写脚本frida试着跑下。 脚本内容比较明确,针对MD5的Init、Update、Final分别hook打印看下输入与输出,下面给到关键代码:


   // hook CC_MD5
   // unsigned char * CC_MD5(const void *data, CC_LONG len, unsigned char *md);
   Interceptor.attach(Module.findExportByName("libcommonCrypto.dylib", g_funcName), {
       onEnterfunction(args) {
           console.log(g_funcName + " begin");
           var len = args[1].toInt32();
           console.log("input:");
           dumpBytes(args[0], len);
           this.md = args[2];
      },
       onLeavefunction(retval) {
           console.log(g_funcName + " return value");
           dumpBytes(this.md, g_funcRetvalLength);

           console.log(g_funcName + ' called from:\n' +
               Thread.backtrace(this.contextBacktracer.ACCURATE)
              .map(DebugSymbol.fromAddress).join('\n') + '\n');
      }
  });

   // hook CC_MD5_Update
   // int CC_MD5_Update(CC_MD5_CTX *c, const void *data, CC_LONG len);
   Interceptor.attach(Module.findExportByName("libcommonCrypto.dylib", g_updateFuncName), {
       onEnterfunction(args) {
           console.log(g_updateFuncName + " begin");
           var len = args[2].toInt32();
           console.log("input:");
           dumpBytes(args[1], len);
      },
       onLeavefunction(retval) {
           console.log(g_updateFuncName + ' called from:\n' +
               Thread.backtrace(this.contextBacktracer.ACCURATE)
              .map(DebugSymbol.fromAddress).join('\n') + '\n');
      }
  });

   // hook CC_MD5_Final
   // int CC_MD5_Final(unsigned char *md, CC_MD5_CTX *c);
   Interceptor.attach(Module.findExportByName("libcommonCrypto.dylib", g_finalFuncName), {
       onEnterfunction(args) {
           //console.log(func.name + " begin");
           finalArgs_md = args[0];
      },
       onLeavefunction(retval) {
           console.log(g_finalFuncName + " return value");
           dumpBytes(finalArgs_md, g_funcRetvalLength);

           console.log(g_finalFuncName + ' called from:\n' +
               Thread.backtrace(this.contextBacktracer.ACCURATE)
              .map(DebugSymbol.fromAddress).join('\n') + '\n');
      }
  });

很幸运,在打印中明显看到了sign相关的内容打印,但是缺少sign的后面一部分,那就明确sign值的构成为32(md5)+8,先看下md5的数据构造过程。



b61df9a8bce7a8641c5ca986b55670e6 33a7ab29



image-20230807174427349.png
通过打印可以明确的看到,sign的MD5由三部分数据组成,分别为:bodyData+Url+Str,body数据也可从Charles获取到。



  • {"body":"5gJEXtLqe3tzRsP8a/bSwehe0ta3zQx6wG7K74sOeXQ6Auz1NI1bg68wNLmj1e5Xl7CIwWelukC445W7HXxJY6nQ0v0SUg1tVyWS5L8E2oaCgoSeC6ypFNXV2xVm8hHV"}

  • /account/v4/login/password

  • V1QiLCJhbGciOiJIUzI1NiJ9
    image-20230807174635667.png
    到这里有一个疑问,数据的第三部分:V1QiLCJhbGciOiJIUzI1NiJ9,该值是固定的字符串还是每次都变化的?猜测应该是固定的字符串,作为MD5的Salt值来使用,我们再次请求验证一下。
    image-20230807181042213.png
    新的sign值为:131329a5af4ecb025fb5088615d5e5c526dbd1a3,通过脚本打印的数据能确认第三部分为固定字符串。
    MD5({"body":"12BcOSg50nLxdbt++r7liZpeyWAVpmihTy8Zu8BmpA6a1hqdevS5PPYwnbtpjN05xgeyReSihh9idyfriR6qx1Fbo8AA0k8HQt6gJ3spWITI21GhLTzh9PDUkgjCtrEK"}/account/v4/login/passwordV1QiLCJhbGciOiJIUzI1NiJ9)
    image-20230807181119463.png


Sign尾部分析


接下来我们针对Sign的尾部数据进行分析,单纯盲猜或者挂frida脚本已经解决不了问题了,我们用IDA看下具体的实现逻辑,当然上面的MD5分析也可以直接从IDA反编译入手,通过搜索sign关键字进行定位,只是我习惯先钩一下脚本,万一直接命中就不用费时间去分析了...


通过MD5的脚本打印,我们也能看到相关的函数调用栈,这对于我们快速定位也提供了很大的方便。我们直接搜索 [KEPPostSecuritySign kep_signWithURL: body:] 方法,可以看到明显的字符串拼接的痕迹,IDA还是比较智能的,已经识别出了MD5的salt值。
1031691552576_.pic.jpg
通过分析,定位到[NSString kep_networkStringOffsetSecurity]函数,在内部进行了字符串的处理,在循环里面进行了各种判断以及移位操作,不嫌麻烦的话可以分析一下逻辑,重写一下处理流程。
1041691552577_.pic.jpg
我这边处理比较暴力,发现kep_networkStringOffsetSecurity是NSString的Catetory,那就直接调用验证一下吧,使用frida挂载以后,找到NSString类,调用方法传入md5之后的值,然后就会发现经过该函数,神奇的sign值就给到了。
image-20230809113620190.png


x-ads分析


分析完sign以后,观察到还有一个x-ads的字段,按照惯例,先用脚本试着钩一下,经常采用的加密大致就是DES、AES或RC4这些算法。
image-20230807191005439.png
针对 AES128、DES、3DES、CAST、RC4、RC2、Blowfish等加密算法进行hook,脚本的关键代码如下:


var handlers = {
   CCCrypt: {
       onEnterfunction(args) {
           var operation = CCOperation[args[0].toInt32()];
           var alg = CCAlgorithm[args[1].toInt32()].name;
           this.options = CCoptions[args[2].toInt32()];
           var keyBytes = args[3];
           var keyLength = args[4].toInt32();
           var ivBuffer = args[5];
           var inBuffer = args[6];
           this.inLength = args[7].toInt32();
           this.outBuffer = args[8];
           var outLength = args[9].toInt32();
           this.outCountPtr = args[10];
           if (this.inLength < MIN_LENGTH || this.inLength > MAX_LENGTH){
           return;
          }
           if (operation === "kCCEncrypt") {
               this.operation = "encrypt"
               console.log("***************** encrypt begin **********************");
          } else {
               this.operation = "decrypt"
               console.log("***************** decrypt begin **********************");
          }
           console.log("CCCrypt(" +
               "operation: " + this.operation + ", " +
               "CCAlgorithm: " + alg + ", " +
               "CCOptions: " + this.options + ", " +
               "keyBytes: " + keyBytes + ", " +
               "keyLength: " + keyLength + ", " +
               "ivBuffer: " + ivBuffer + ", " +
               "inBuffer: " + inBuffer + ", " +
               "inLength: " + this.inLength + ", " +
               "outBuffer: " + this.outBuffer + ", " +
               "outLength: " + outLength + ", " +
               "outCountPtr: " + this.outCountPtr + ")"
          );

           //console.log("Key: utf-8 string:" + ptr(keyBytes).readUtf8String())
           //console.log("Key: utf-16 string:" + ptr(keyBytes).readUtf16String())
           console.log("key: ");
           dumpBytes(keyBytes, keyLength);

           console.log("IV: ");
           // ECB模式不需要iv,所以iv是null
           dumpBytes(ivBuffer, keyLength);

           var isOutput = true;
           if (!SHOW_PLAIN_AND_CIPHER && this.operation == "decrypt") {
            isOutput = false;
          }

           if (isOutput){
           // Show the buffers here if this an encryption operation
            console.log("In buffer:");
            dumpBytes(inBuffer, this.inLength);
          }
           
      },
       onLeavefunction(retVal) {
       // 长度过长和长度太短的都不要输出
           if (this.inLength < MIN_LENGTH || this.inLength > MAX_LENGTH){
           return;
          }
           var isOutput = true;
           if (!SHOW_PLAIN_AND_CIPHER && this.operation == "encrypt") {
            isOutput = false;
          }
           if (isOutput) {
            // Show the buffers here if this a decryption operation
            console.log("Out buffer:");
            dumpBytes(this.outBufferMemory.readUInt(this.outCountPtr));
          }
           // 输出调用堆栈,会识别类名函数名,非常好用
           console.log('CCCrypt called from:\n' +
               Thread.backtrace(this.contextBacktracer.ACCURATE)
              .map(DebugSymbol.fromAddress).join('\n') + '\n');
      }
  },
};


if (ObjC.available) {
   console.log("frida attach");
   for (var func in handlers) {
   console.log("hook " + func);
       Interceptor.attach(Module.findExportByName("libcommonCrypto.dylib", func), handlers[func]);
  }
else {
   console.log("Objective-C Runtime is not available!");
}

查看脚本的输出日志,直接命中了AES128的加密算法,并且输出的Base64数据完全匹配,只能说运气爆棚。
image-20230807191141136.png
拿到对应的key跟iv,尝试解密看下也是没问题的。x-ads分析结束,都不用反编译看代码:)
image-20230807190921956.png


Body的分析


最后看下sign值的组成部分,body数据是怎么计算的,抱着试试的想法,直接用x-ads分析得到的算法以及对应的key、iv进行解密:



{ "body": "5gJEXtLqe3tzRsP8a/bSwXDiK0VslZZZyOEj1jBDBhtYTGGdWltuIjLbzwZ2OxMcb3mFX7bJtgH3WlqGET5W34P4dTEIDhLH6FkT3HSLaDnEXYHvEl9IZRQKf19wMG/t" }



image-20230807183413168.png
这次说不上什么运气爆棚了...只能说开发者比较懒或者安全意识有点差了,使用了AES-CBC模式,iv都不改变一下的...


总结


这次分析整体来看,没什么技术含量,大部分都是脚本直接解决了,从结果来看,也是使用的常规的加密、签名算法,这也从侧面给我们安全开发提个醒,是不是可以有策略性的改变一下,比如我们拿MD5来看下都可以做哪些改变。



opensource.apple.com/source/ppp/…



首先针对MD5Init,我们可以改变它的初始化数据:


void MD5Init (mdContext)
MD5_CTX *mdContext;
{
 mdContext->i[0] = mdContext->i[1] = (UINT4)0;

 /* Load magic initialization constants.
  */

 mdContext->buf[0] = (UINT4)0x67452301;
 mdContext->buf[1] = (UINT4)0xefcdab89;
 mdContext->buf[2] = (UINT4)0x98badcfe;
 mdContext->buf[3] = (UINT4)0x10325476;
}

其次针对Transform我们也可以改变其中的某几个数据:


static void Transform (buf, in)
UINT4 *buf;
UINT4 *in;
{
 UINT4 a = buf[0]b = buf[1], c = buf[2], d = buf[3];

 /* Round 1 */
#define S11 7
#define S12 12
#define S13 17
#define S14 22
 FF ( ab, c, d, in[ 0], S11, UL(3614090360)); /* 1 */
 FF ( d, ab, c, in[ 1], S12, UL(3905402710)); /* 2 */
 FF ( c, d, ab, in[ 2], S13, UL606105819)); /* 3 */
 FF ( b, c, d, a, in[ 3], S14, UL(3250441966)); /* 4 */
 FF ( ab, c, d, in[ 4], S11, UL(4118548399)); /* 5 */
 FF ( d, ab, c, in[ 5], S12, UL(1200080426)); /* 6 */
 FF ( c, d, ab, in[ 6], S13, UL(2821735955)); /* 7 */
 FF ( b, c, d, a, in[ 7], S14, UL(4249261313)); /* 8 */
 FF ( ab, c, d, in[ 8], S11, UL(1770035416)); /* 9 */
 FF ( d, ab, c, in[ 9], S12, UL(2336552879)); /* 10 */
 FF ( c, d, ab, in[10], S13, UL(4294925233)); /* 11 */
 FF ( b, c, d, a, in[11], S14, UL(2304563134)); /* 12 */
 FF ( ab, c, d, in[12], S11, UL(1804603682)); /* 13 */
 FF ( d, ab, c, in[13], S12, UL(4254626195)); /* 14 */
 FF ( c, d, ab, in[14], S13, UL(2792965006)); /* 15 */
 FF ( b, c, d, a, in[15], S14, UL(1236535329)); /* 16 */

 /* Round 2 */
#define S21 5
#define S22 9
#define S23 14
#define S24 20
 GG ( ab, c, d, in[ 1], S21, UL(4129170786)); /* 17 */
 GG ( d, ab, c, in[ 6], S22, UL(3225465664)); /* 18 */
 
...
 

简单的变形以后,即使脚本能hook到对应的函数,但是想直接脱机调用结果还是不可以的,此时就要不得不进行反编译分析或者动态调试,此时配合代码混淆、VMP等静态防护手段,再加上反调试等安全手段,对于攻击的门槛也相应的提高。


作者:Daemon_S
来源:juejin.cn/post/7265036888431558675
收起阅读 »

如何在 SwiftUI 中实现音频图表

iOS
前言 在可访问性方面,图表是复杂的事物之一。iOS 15 引入了一项名为“音频图表”的新功能。 下面我们将学习如何通过使用 accessibilityChartDescriptor 视图修饰符为任何 SwiftUI 视图构建音频表示,呈现类似自定义条形图视图或...
继续阅读 »

前言


在可访问性方面,图表是复杂的事物之一。iOS 15 引入了一项名为“音频图表”的新功能。


下面我们将学习如何通过使用 accessibilityChartDescriptor 视图修饰符为任何 SwiftUI 视图构建音频表示,呈现类似自定义条形图视图或图像的图表。


DataPoint 结构体


让我们从在 SwiftUI 中构建一个简单的条形图视图开始,该视图使用垂直条形显示一组数据点。


struct DataPoint: Identifiable {
let id = UUID()
let label: String
let value: Double
let color: Color
}

在这里,我们有一个 DataPoint 结构,用于描述条形图视图中的条形。它具有 id、标签、数值和填充颜色。


BarChartView 结构体


接下来,我们可以定义一个条形图视图,它接受一组 DataPoint 结构体实例并将它们显示出来。


struct BarChartView: View {
let dataPoints: [DataPoint]

var body: some View {
HStack(alignment: .bottom) {
ForEach(dataPoints) { point in
VStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(point.color)
.frame(height: point.value * 50)
Text(point.label)
}
}
}
}
}

如上例所示,我们有一个 BarChartView,它接收一组 DataPoint 实例并将它们显示为水平堆栈中不同高度的圆角矩形。


ContentView 结构体


我们能够在 SwiftUI 中轻松构建条形图视图。接下来让我们尝试使用带有示例数据的新 BarChartView


struct ContentView: View {
@State private var dataPoints = [
DataPoint(label: "1", value: 3, color: .red),
DataPoint(label: "2", value: 5, color: .blue),
DataPoint(label: "3", value: 2, color: .red),
DataPoint(label: "4", value: 4, color: .blue),
]

var body: some View {
BarChartView(dataPoints: dataPoints)
.accessibilityElement()
.accessibilityLabel("Chart representing some data")
}
}

在这里,我们创建了一组 DataPoint 实例的示例数组,并将其传递给 BarChartView。我们还为图表创建了一个可访问元素,并禁用了其子元素的可访问性信息。为了改进图表视图的可访问性体验,我们还添加了可访问性标签。


最后,我们可以开始为我们的条形图视图实现音频图表功能。音频图表可以通过旋钮菜单获得。要使用旋钮,请在 iOS 设备的屏幕上旋转两个手指,就像您在拨盘。VoiceOver 会说出第一个旋钮选项。继续旋转手指以听到更多选项。松开手指选择音频图表。然后在屏幕上上下滑动手指以导航。


音频图表允许用户使用音频组件理解和解释图表数据。VoiceOver 在移动到图表视图中的条形时播放具有不同音调的声音。VoiceOver 对于更大的值使用高音调,对于较小的值使用低音调。这些音调代表数组中的数据。


实现协议


现在,我们可以讨论在 BarChartView 中实现此功能的方法。首先,我们必须创建一个符合 AXChartDescriptorRepresentable 协议的类型。AXChartDescriptorRepresentable 协议只有一个要求,即创建 AXChartDescriptor 类型的实例。AXChartDescriptor 类型的实例表示我们图表中的数据,以 VoiceOver 可以理解和交互的格式呈现。


extension ContentView: AXChartDescriptorRepresentable {
func makeChartDescriptor() -> AXChartDescriptor {
let xAxis = AXCategoricalDataAxisDescriptor(
title: "Labels",
categoryOrder: dataPoints.map(\.label)
)

let min = dataPoints.map(\.value).min() ?? 0.0
let max = dataPoints.map(\.value).max() ?? 0.0

let yAxis = AXNumericDataAxisDescriptor(
title: "Values",
range: min...max,
gridlinePositions: []
) { value in "\(value) points" }

let series = AXDataSeriesDescriptor(
name: "",
isContinuous: false,
dataPoints: dataPoints.map {
.init(x: $0.label, y: $0.value)
}
)

return AXChartDescriptor(
title: "Chart representing some data",
summary: nil,
xAxis: xAxis,
yAxis: yAxis,
additionalAxes: [],
series: [series]
)
}
}

我们所需做的就是符合 AXChartDescriptorRepresentable 协议,并添加 makeChartDescriptor 函数,该函数返回 AXChartDescriptor 的实例。


首先,我们通过使用 AXCategoricalDataAxisDescriptorAXNumericDataAxisDescriptor 类型定义 X 轴和 Y 轴。我们希望在 X 轴上使用字符串标签,这就是为什么我们使用 AXCategoricalDataAxisDescriptor 类型的原因。在线图的情况下,我们将在两个轴上都使用 AXNumericDataAxisDescriptor 类型。


实现线图


接下来,我们使用 AXDataSeriesDescriptor 类型定义图表中的点。有一个 isContinuous 参数,允许我们定义不同的图表样式。例如,对于条形图,它应该是 false,而对于线图,它应该是 true。


struct ContentView: View {
@State private var dataPoints = [
DataPoint(label: "1", value: 3, color: .red),
DataPoint(label: "2", value: 5, color: .blue),
DataPoint(label: "3", value: 2, color: .red),
DataPoint(label: "4", value: 4, color: .blue),
]

var body: some View {
BarChartView(dataPoints: dataPoints)
.accessibilityElement()
.accessibilityLabel("Chart representing some data")
.accessibilityChartDescriptor(self)
}
}

作为最后一步,我们使用 accessibilityChartDescriptor 视图修饰符将符合 AXChartDescriptorRepresentable 协议的实例设置为描述我们图表的实例。


示例截图:



总结


音频图表功能对于视力受损的用户来说是一项重大改进。音频图表功能的好处是,可以将其用于任何您想要的视图,甚至包括图像视图。只需创建 AXChartDescriptor 类型的实例。


作者:Swift社区
来源:juejin.cn/post/7301496834232401959
收起阅读 »

iOS 判断系统版本

iOS
方案一 double systemVersion = [UIDevice currentDevice].systemVersion.boolValue; if (systemVersion >= 7.0) { // >= iOS 7.0 ...
继续阅读 »

方案一


double systemVersion = [UIDevice currentDevice].systemVersion.boolValue;

if (systemVersion >= 7.0) {
// >= iOS 7.0
} else {
// < iOS 7.0
}

if (systemVersion >= 10.0) {
// >= iOS 10.0
} else {
// < iOS 10.0
}

如果只是大致判断是哪个系统版本,上面的方法是可行的,如果具体到某个版本,如 10.0.1,那就会有偏差。我们知道 systemVersion 依旧是10.0。


方案二


NSString *systemVersion = [UIDevice currentDevice].systemVersion;
NSComparisonResult comparisonResult = [systemVersion compare:@"10.0.1" options:NSNumericSearch];

if (comparisonResult == NSOrderedAscending) {
// < iOS 10.0.1
} else if (comparisonResult == NSOrderedSame) {
// = iOS 10.0.1
} else if (comparisonResult == NSOrderedDescending) {
// > iOS 10.0.1
}

// 或者

if (comparisonResult != NSOrderedAscending) {
// >= iOS 10.0.1
} else {
// < iOS 10.0.1
}

有篇博客提到这种方法不靠谱。比如系统版本是 10.1.1,而我们提供的版本是 8.2,会返回NSOrderedAscending,即认为 10.1.1 < 8.2 。


其实,用这样的比较方式 NSComparisonResult comparisonResult = [systemVersion compare:@"10.0.1"],的确会出现这种情况,因为默认是每个字符逐个比较,即 1(0.1.1) < 8(.2),结果可想而知。但我是用 NSNumericSearch 方式比较的,即数值的比较,不是字符比较,也不需要转化成NSValue(NSNumber) 再去比较。


方案三


if (NSFoundationVersionNumber >= NSFoundationVersionNumber_iOS_7_0) {
// >= iOS 7.0
} else {
// < iOS 7.0
}

// 或者

if (kCFCoreFoundationVersionNumber >= kCFCoreFoundationVersionNumber_iOS_7_0) {
// >= iOS 7.0
} else {
// < iOS 7.0
}

这些宏定义是 Apple 预先定义好的,如下:


#if TARGET_OS_IPHONE
...
#define NSFoundationVersionNumber_iOS_9_4 1280.25
#define NSFoundationVersionNumber_iOS_9_x_Max 1299
#endif


细心的童靴可能已经发现问题了。Apple 没有提供 iOS 10 以后的宏?,我们要判断iOS10.0以后的版本该怎么做呢?
有篇博客中提到,iOS10.0以后版本号提供了,并且逐次降低了,并提供了依据。


#if TARGET_OS_MAC
#define NSFoundationVersionNumber10_1_1 425.00
#define NSFoundationVersionNumber10_1_2 425.00
#define NSFoundationVersionNumber10_1_3 425.00
#define NSFoundationVersionNumber10_1_4 425.00
...
#endif


我想这位童鞋可能没仔细看, 这两组宏是分别针对iPhone和macOS的,不能混为一谈的。


所以也只能像下面的方式来大致判断iOS 10.0, 但之前的iOS版本是可以准确判断的。


if (NSFoundationVersionNumber > floor(NSFoundationVersionNumber_iOS_9_x_Max)) {
// > iOS 10.0
} else {
// <= iOS 10.0
}

方案四


在iOS8.0中,Apple也提供了NSProcessInfo 这个类来检测版本问题。


@property (readonly) NSOperatingSystemVersion operatingSystemVersion NS_AVAILABLE(10_10, 8_0);
- (BOOL) isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion)version NS_AVAILABLE(10_10, 8_0);

所以这样检测:


if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion){.majorVersion = 8, .minorVersion = 3, .patchVersion = 0}]) {
// >= iOS 8.3
} else {
// < iOS 8.3
}

用来判断iOS 10.0以上的各个版本也是没有问题的,唯一的缺点就是不能准确版本是哪个版本,当然这种情况很少。如果是这种情况,可以通过字符串的比较判断。


方案五


通过判断某种特定的类有没有被定义,或者类能不能响应哪个特定版本才有的方法。
比如,UIAlertController 是在iOS 8.0才被引进来的一个类,我们这个依据来判断版本


if (NSClassFromString(@"UIAlertController")) {
// >= iOS 8.0
} else {
// < iOS 8.0
}

说到这里,就顺便提一下在编译期间如何进行版本控制,依然用UIAlertController 来说明。


NS_CLASS_AVAILABLE_IOS(8_0) @interface UIAlertController : UIViewController

NS_CLASS_AVAILABLE_IOS(8_0) 这个宏说明,UIAlertController 是在iOS8.0才被引进来的API,那如果我们在iOS7.0上使用,应用程序就会挂掉,那么如何在iOS8.0及以后的版本使用UIAlertController ,而在iOS8.0以前的版本中仍然使用UIAlertView 呢?


这里我们会介绍一下在#import <AvailabilityInternal.h> 中的两个宏定义:


*__IPHONE_OS_VERSION_MIN_REQUIRED


*__IPHONE_OS_VERSION_MAX_ALLOWED


从字面意思就可以直到,__IPHONE_OS_VERSION_MIN_REQUIRED 表示iPhone支持最低的版本系统,__IPHONE_OS_VERSION_MAX_ALLOWED 表示iPhone允许最高的系统版本。


__IPHONE_OS_VERSION_MAX_ALLOWED 的取值来自iOS SDK的版本,比如我现在使用的是Xcode Version 8.2.1(8C1002),SDK版本是iOS 10.2,怎么看Xcode里SDK的iOS版本呢?



进入PROJECT,选择Build Setting,在Architectures中的Base SDK中可以查看当前的iOS SDK版本。



打印这个宏,可以看到它一直输出100200。


__IPHONE_OS_VERSION_MIN_REQUIRED 的取值来自项目TARGETS的Deployment Target,即APP愿意支持的最低版本。如果我们修改它为8.2,打印这个宏,会发现输出80200,默认为10.2。


通常,__IPHONE_OS_VERSION_MAX_ALLOWED 可以代表当前的SDK的版本,用来判断当前版本是否开始支持或具有某些功能。而__IPHONE_OS_VERSION_MIN_REQUIRED 则是当前SDK支持的最低版本,用来判断当前版本是否仍然支持或具有某些功能。


回到UIAlertController 使用的问题,我们就可以使用这些宏,添加版本检测判断,从而使我们的代码更健壮。


 - (void)showAlertView {
#if defined(__IPHONE_OS_VERSION_MIN_REQUIRED) && __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0
UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"Title" message:@"message" delegate:nil cancelButtonTitle:@"Cancel" otherButtonTitles:@"OK", nil];
[alertView show];
#else
if (NSFoundationVersionNumber >= NSFoundationVersionNumber_iOS_8_0) {
UIAlertController *alertViewController = [UIAlertController alertControllerWithTitle:@"Title" message:@"message" preferredStyle:UIAlertControllerStyleAlert];

UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil];
UIAlertAction *otherAction = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil];

[alertViewController addAction:cancelAction];
[alertViewController addAction:otherAction];

[self presentViewController:alertViewController animated:YES completion:NULL];
}
#endif
}

方案六


iOS 11.0 以后,Apple加入了新的API,以后我们就可以像在Swift中的那样,很方便的判断系统版本了。


if (@available(iOS 11.0, *)) {
// iOS 11.0 及以后的版本
} else {
// iOS 11.0 之前
}

参考链接



作者:蒙哥卡恩就是我
来源:juejin.cn/post/7277111344003399734
收起阅读 »

货拉拉用户 iOS 端灵动岛实践总结

iOS
1. 前言 实时活动是iOS 16.1及以上版本中新增的功能,它允许应用在锁屏界面显示实时数据,能够帮助用户实时查看当前订单的进展,而无需解锁手机。用户在货拉拉APP上下单后,可以将手机放置一旁,开始其他工作。当用户想要查询订单状态时,只需从锁定屏幕或灵动岛...
继续阅读 »

1. 前言


实时活动是iOS 16.1及以上版本中新增的功能,它允许应用在锁屏界面显示实时数据,能够帮助用户实时查看当前订单的进展,而无需解锁手机。用户在货拉拉APP上下单后,可以将手机放置一旁,开始其他工作。当用户想要查询订单状态时,只需从锁定屏幕或灵动岛上轻松操作即可。实时活动的出现不仅省去了用户解锁手机的步骤,更为用户节省了时间和精力。目前货拉拉APP适配“灵动岛”的最新6.7.68版本已正式上线,欢迎大家升级体验。在适配过程中,货拉拉App也踩过很多“坑”,在此汇总为实战经验分享给大家。


2. Live Activity&灵动岛的介绍


Live Activity的实现需要使用Apple的ActivityKit框架。通过使用ActivityKit,开发者可以轻松地创建一个Live Activity,这是一个动态的、实时更新的活动,可以在用户的设备上显示各种信息。此外,ActivityKit还提供了推送通知的功能,开发者可以通过服务器向用户的设备发送更新;这样,即使应用程序没有运行,用户也可以接收到最新的信息。


灵动岛是Live Activity的一种展示形式,灵动岛有三种展示形式:Compact紧凑、Minimal最小化,Expanded扩展。开发时必须实现这三种形式,以确保灵动岛在不同的场景下都能正常展示。



同时还需要实现锁屏下的实时活动UI,设备处于锁屏状态下,也能查看实时更新的内容。以上功能的实现,都是使用WidgetKit和SwiftUI完成开发。


2.1 技术难点及策略


实时活动,主要是APP在后台时,主动更新通知栏和灵动岛的数据,为用户展示最新实时订单状态。如何及时刷新实时活动的数据,是一个重点、难点。


更新方式有3种:



  1. 通过APP内订单状态的变化刷新实时活动和灵动岛。此方法开发量小,但是APP退到后台30s后或者进程杀掉,会停止数据的更新。

  2. 让APP配置支持后台运行模式,通过本地现有的订单状态变化逻辑,在后台发起网络请求,获取订单的数据后刷新实时活动。此方法开发量小,但求主App进程必须存在,进程一旦杀掉就无法更新。

  3. 通过接受远程推送通知来更新实时活动。此方法需要后端配合,此方式比较灵活,无需App进程存在,数据更新及时。也是业界常见的方案。


通过对数据刷新的三种方案进行评估后,选择了用户体验最佳的第三种方式。通过后端发生push,端上接受push数据来更新实时活动。


3. Live Activity&灵动岛的实践


3.1 实现方案流程图


实现流程图:


image.png


3.2 实现代码


创建Live Activities的准备:



  • Xcode需要14.1以上版本

  • 在主工程的 Info.plist 文件中添加一个键值对,key 为 NSSupportsLiveActivities,value 为 YES

  • 使用ActivityKit在Widget Extension 中创建一个Live Activity


需要实现锁屏状态下UI、灵动岛长按展开的UI、灵动岛单个UI、多个实时活动时的minimalUI


import SwiftUI
import WidgetKit

@main
struct TestWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TestAttributes.self) { context in
// 锁屏状态下的UI
} dynamicIsland: { context in
DynamicIsland {
//灵动岛展开后的UI
} compactLeading: {
// 未被展开左边UI
} compactTrailing: {
// 未被展开右边UI
} minimal: {
// 多任务时,右边的一个圆圈区域
}
.keylineTint(.cyan)
}
}
}

灵动岛主要分为StartUpdateEnd三种状态,可由ActivityKit远程推送控制其状态。


开启Live Activity


        let state = TestAttributes.ContentState()
let attri = TestAttributes(value: 100)
do {
let current = try Activity.request(attributes: attri, contentState: state, pushType: .token)
Task {
for await state in current.contentStateUpdates {
//监听state状态
}
}
Task {
for await state in current.activityStateUpdates {
//监听activity状态
}
}
} catch(let error) {
}

更新Live Activity


   Task {
guard let current = Activity<TestAttributes>.activities.first else {
return
}
let state = TestAttributes.ContentState(value: 88)
await current.update(using: state)
}

结束Live Activity


    Task {
for activity in Activity<TestAttributes>.activities {
await activity.end(dismissalPolicy: .immediate)
}
}

4. 使用ActivityKit推送通知


ActivityKit提供了接收推送令牌的功能,我们可以使用这个令牌来通过ActivityKit推送通知从我们的服务器向Apple Push Notification service (APNs)发送更新。


推送更新Live Activity的准备:




  • 在开发者后台配置生成p8证书,替换原来的p12证书




  • 通过pushTokenUpdates获取推送令牌PushToken




  • 向后端注册PushToken




代码展示:


//取得PushToken
for await tokenData in current.pushTokenUpdates {
let mytoken = tokenData.map { String(format: "x", $0) }.joined()
//向后端注册
registerActivityToken(mytoken)
}

4.1 模拟器push验证测试


环境要求:


Xcode >= 14.1 MacOS >= 13.0


准备工作:



  1. 通过pushTokenUpdates获取推送需要的token

  2. 根据开发者TeamID、p8证书本地路径、BuidleID等进行脚本配置


脚本示例:


export TEAM_ID=YOUR_TEAM_ID
export TOKEN_KEY_FILE_NAME=YOUR_AUTHKEY_FILE.p8
export AUTH_KEY_ID=YOUR_AUTHKEY_ID
export DEVICE_TOKEN=YOUR_PUSH_TOKEN
export APNS_HOST_NAME=api.sandbox.push.apple.com

export JWT_ISSUE_TIME=$(date +%s)
export JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
export JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
export JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}"
export JWT_SIGNED_HEADER_CLAIMS=$(printf "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
export AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}"

curl -v \
--header "apns-topic:YOUR_BUNDLE_ID.push-type.liveactivity" \
--header "apns-push-type:liveactivity" \
--header "authorization: bearer $AUTHENTICATION_TOKEN" \
--data \
'{"Simulator Target Bundle": "YOUR_BUNDLE_ID",
"aps": {
"timestamp":1689648272,
"dismissal-date":0,
"event": "update",
"sound":"default",
"content-state": {
"title": "等待付款",
"content": "请尽快完成下单"
}
}}'
\
--http2 \
https://${APNS_HOST_NAME}/3/device/$DEVICE_TOKEN

其中:


apns-topic:固定为{BundleId}.push-type.liveactivity


apns-push-type:固定为liveactivity


Simulator Target Bundle:模拟器推送,设置为对应APP的BundleId


timestamp:表示推送通知的发送时间,如果timestamp字段的值与当前时间相差太大,可能会收不到推送。


event:可填入update、end,对应Live Activity的更新与结束。


dismissal-date:当event为end时有效,表示结束后从锁屏上移除Live Activity的时间。如果推送内容不包含"dismissal-date",默认结束后4小时后消失,但内容不会再发生更新。如果期望Live Activity结束后立即从锁屏上移除它,可为"dismissal-date"提供一个过去的日期。


content-state:对应灵动岛的Activity.ContentState;如果push中content-state的字段和Attributes比较:




  • 字段过多,多余的字段可能会被忽略,不会导致解析失败




  • 字段缺少,会在解析push通知时出现问题错误。错误表现为:实时活动会有蒙层,并展示loading菊花UI。




示范:


image.png


image.png


5. 踩坑记录




  • 在模拟器上无法获取到pushToken,无法进行推送模拟?


    检查电脑的系统版本号,需要13.0以上




  • 更新实时活动时,页面显示加载loadingUI,为什么?


    核对push字段和Activity.ContentState的字段是否完全一致,字段少了会解析失败




  • 在16.1系统上,无法展示实时活动,其他更高系统能展示?


    检查Widget里面iOS系统版本号的配置,设置为想要支持的最低版本




  • dismissal-date设置为10分钟后才消失,为什么Dynamic Island灵动岛立即消失了?


    Dynamic Island的显示逻辑可能会更加复杂,如果push的event=end,Dynamic Island灵动岛会立即消失。期望同时消失,可以在指定时间再发end,dismissal-date设置为过去时间,锁屏UI和Dynamic Island灵动岛会同时消失。




  • 推送不希望打扰用户,静默推送,不需要震动和主动弹出,如何设置?


    将"content-available"设置为1,"sound" 设置为: ""




"aps" = {
"content-available" : 1,
"sound" : ""
}



  • 用户系统是深色模式时,如何适配?


    可以使用@Environment(.colorScheme)属性包装器来获取当前设备的颜色模式。会返回一个ColorScheme枚举,它可以是.light.dark。在根据具体的场景进行UI适配




struct ContentView: View {
@Environment(.colorScheme) var colorScheme

var body: some View {
VStack {
if colorScheme == .dark {
Text("深夜模式")
.foregroundColor(.white)
.background(Color.black)
} else {
Text("日间模式")
.foregroundColor(.(.black)
.background(Color.white)
}
}
}
}

5.1 场景限制及建议



  1. 官方文档提示实时活动最多持续8小时,8小时后数据无法刷新,12小时后会强制消失。因此8小时后的数据不准确

  2. 实时活动的卡片上禁止定位以及网络请求,数据需要小于4KB,不能展示特别负责庞大的数据

  3. 同场景多卡片由于样式趋同且折叠,不建议同时创建多卡片。用户多次下单时,建议只处理第一个订单


6. 用户APP上线效果


用户端iOS APP灵动岛上线后的部分场景截图:







7. 总结


灵动岛功能自上线以来,经过我们的数据统计,用户实时活动使用率高达75%以上。这一数据的背后,是灵动岛强大的功能和优秀的用户体验。用户可以在锁屏页直接查看订单状态,无需繁琐的操作步骤,大大提升了用户体验。这种便捷性,使得灵动岛在用户中的接受度较高。


我们的方案不仅可以应用于当前的业务场景,后续还计划扩展到营销活动,定制化通知消息等多种业务场景。这种扩展性,使得灵动岛可以更好地满足不同用户的需求,丰富产品运营策略。


我们希望通过分享开发过程中遇到的问题和解决方案,可以帮助到更多的人。如果你有任何问题或者想法,欢迎在评论区留言。期待我们在技术的道路上再次相遇。


总的来说,灵动岛以其高效、便捷、灵活的特性,赢得了用户的广泛好评。我们将继续努力,为用户提供更优质的服务,为产品的发展注入更多的活力。


作者:货拉拉技术
来源:juejin.cn/post/7300779071390335030
收起阅读 »

iOS 仿花小猪首页滑动效果

iOS
一. 背景 首页改版,想要做一个类似花小猪首页滑动效果,具体如下所示: 二. 分析 从花小猪首页交互我们可以分析出如下信息: 首页卡片分为三段式,底部、中间、顶部。 当首页卡片在底部,只能先外部视图整体往上滑动,滑动到顶部后,内部卡片头部悬浮,内部卡...
继续阅读 »

一. 背景


首页改版,想要做一个类似花小猪首页滑动效果,具体如下所示:



二. 分析


从花小猪首页交互我们可以分析出如下信息:




  • 首页卡片分为三段式,底部、中间、顶部。




  • 当首页卡片在底部,只能先外部视图整体往上滑动,滑动到顶部后,内部卡片头部悬浮,内部卡片滚动视图依然可以滚动。




  • 当首页卡片在中间,可以先外部视图整体往上或者往下滑动,往下滑动到底部后,禁止滑动,滑动到顶部,内部视图卡片头部悬浮,内部滚动视图可以滚动。




  • 当首页卡片在顶部,可以拖动卡片外部视图整体下滑,也可以通过内部视图向下滚动,滚动到跟内部头部底部持平,变成整体一起向下滑动。而当内部滚动视图向上滚动,内部卡片头部悬浮固定。




  • 首页卡片滑动过程中,如果停在中间位置,依据卡片停止位置,距离底部、中间、顶部位置远近,向距离近的一端,直接移动到相应位置,比如移动到中间和顶部位置之间,如果距离顶部近,则直接移动到顶部。




  • 当首页卡片在底部,上滑速度很快超过一定值,就直接到顶部。同样在顶部下滑也一样。




  • 当首页卡片在顶部,内部滚动视图快速下滑,下滑到跟卡片头部分开,产生弹簧效果,不直接一起下滑,但其他部分如果慢慢滑动,下滑到跟卡片头部即将分开时,变成整体一起下滑。




三. 实现


理清了首页卡片的滑动交互细节之后,我们开始设计对应类和相关职责。



从上面结构图我们可以看出,主要分为三部分




  • 卡片外层容器externalScrollView,限定为UIScrollView类型。




  • 卡片内头部insideHeaderView,限定为UIView类型。




  • 卡片内滚动视图insideTableView,由于滚动视图所以insideTableView一定是UIScrollView类型,为了复用,这里我们限定为UITableView




这里其实我们不关心头部视图insideHeaderView,因为内部头部视图insideHeaderView和内部滚动视图insideTableView之间的关系是固定,就是内部滚动视图insideTableView一直在头部视图 insideHeaderView下面。


同样我们也不关心滚动视图insideTableView里面的内容,我们需要处理的就是卡片外层容器externalScrollView和内部滚动视图insideTableView之间交互关系。


因为所有这种类型交互处理逻辑是一致的,因此我们抽出FJFScrollDragHelper类。



  • 首先我们来认识下滚动辅助类FJFScrollDragHelper相关属性


    /// scrollView 显示高度
public var scrollViewHeight: CGFloat = kScreenH
/// 限制的高度(超过这个高度可以滚动)
public var kScrollLimitHeight: CGFloat = kScreenH * 0.51
/// 滑动初始速度(大于该速度直接滑动到顶部或底部)
public var slideInitSpeedLimit: CGFloat = 3500.0
/// 当前 滚动 视图 位置
public var curScrollViewPositionType: FJFScrollViewPositionType = .middle
/// 最高 展示 高度
public var topShowHeight: CGFloat = 0
/// 中间 展示 高度
public var middleShowHeight: CGFloat = 0
/// 最低 展示 高度
public var lowestShowHeight: CGFloat = 0
/// 当前 滚动 视图 类型
private var currentScrollType: FJFCurrentScrollViewType = .externalView
/// 外部 滚动 view
public weak var externalScrollView: UIScrollView?
/// 内部 滚动 view
public weak var insideScrollView: UIScrollView?
/// 拖动 scrollView 回调
public var panScrollViewBlock: (() -> Void)?
/// 移动到顶部
public var goToTopPosiionBlock: (() -> Void)?
/// 移动到 底部 默认位置
public var goToLowestPosiionBlock: (() -> Void)?
/// 移动到 中间 默认位置
public var goToMiddlePosiionBlock: (() -> Void)?

我们看到FJFScrollDragHelper内部弱引用了外部滚动视图externalScrollView和内部滚动视图insideScrollView




  1. 关联对象,并给外部externalScrollView添加滑动手势




/// 添加 滑动 手势 到 外部滚动视图
public func addPanGestureRecognizer(externalScrollView: UIScrollView){
let panRecoginer = UIPanGestureRecognizer(target: self, action: #selector(panScrollViewHandle(pan:)))
externalScrollView.addGestureRecognizer(panRecoginer)
self.externalScrollView = externalScrollView
}



  1. 处理滑动手势




// MARK: - Actions
/// tableView 手势
@objc
private func panScrollViewHandle(pan: UIPanGestureRecognizer) {
/// 当前 滚动 内部视图 不响应拖动手势
if self.currentScrollType == .insideView {
return
}
guard let contentScrollView = self.externalScrollView else {
return
}
let translationPoint = pan.translation(in: contentScrollView.superview)

// contentScrollView.top 视图距离顶部的距离
contentScrollView.y += translationPoint.y
/// contentScrollView 移动到顶部
let distanceToTopH = self.getTopPositionToTopDistance()
if contentScrollView.y < distanceToTopH {
contentScrollView.y = distanceToTopH
self.curScrollViewPositionType = .top
self.currentScrollType = .all
}
/// 视图在底部时距离顶部的距离
let distanceToBottomH = self.getBottomPositionToTopDistance()
if contentScrollView.y > distanceToBottomH {
contentScrollView.y = distanceToBottomH
self.curScrollViewPositionType = .bottom
self.currentScrollType = .externalView
}
/// 拖动 回调 用来 更新 遮罩
self.panScrollViewBlock?()
// 在滑动手势结束时判断滑动视图距离顶部的距离是否超过了屏幕的一半,如果超过了一半就往下滑到底部
// 如果小于一半就往上滑到顶部
if pan.state == .ended || pan.state == .cancelled {

// 处理手势滑动时,根据滑动速度快速响应上下位置
let velocity = pan.velocity(in: contentScrollView)
let largeSpeed = self.slideInitSpeedLimit
/// 超过 最大 力度
if velocity.y < -largeSpeed {
gotoTheTopPosition()
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y < 0, velocity.y > -largeSpeed {
if self.curScrollViewPositionType == .bottom {
gotoMiddlePosition()
} else {
gotoTheTopPosition()
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y > largeSpeed {
gotoLowestPosition()
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
} else if velocity.y > 0, velocity.y < largeSpeed {
if self.curScrollViewPositionType == .top {
gotoMiddlePosition()
} else {
gotoLowestPosition()
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
return
}
let scrollViewDistanceToTop = kScreenH - contentScrollView.top
let topAndMiddleMeanValue = (self.topShowHeight + self.middleShowHeight) / 2.0
let middleAndBottomMeanValue = (self.middleShowHeight + self.lowestShowHeight) / 2.0

if scrollViewDistanceToTop >= topAndMiddleMeanValue {
gotoTheTopPosition()
} else if scrollViewDistanceToTop < topAndMiddleMeanValue,
scrollViewDistanceToTop > middleAndBottomMeanValue {
gotoMiddlePosition()
} else {
gotoLowestPosition()
}
}
pan.setTranslation(CGPoint(x: 0, y: 0), in: contentScrollView)
}

处理滑动手势需要当前视图滚动类型currentScrollType和卡片当前所处的位置curScrollViewPositionType来分别进行判断。


/// 当前 滚动 视图 类型
public enum FJFCurrentScrollViewType {
case externalView /// 外部 视图
case insideView /// 内部 视图
case all /// 内部外部都可以响应
}

/// 当前 滚动 视图 位置 属性
public enum FJFScrollViewPositionType {
case top /// 顶部
case middle /// 中间
case bottom /// 底部
}

如下是对应的判断逻辑:


暂时无法在飞书文档外展示此内容


A. 在底部


 /// 回到 底部 位置
private func gotoLowestPosition() {
self.curScrollViewPositionType = .bottom
self.goToLowestPosiionBlock?()
}

private func gotoLowestPosition(withAnimated animated: Bool = true) {
self.insideTableView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
if animated {
UIView.animate(withDuration: 0.18, delay: 0, options: .allowUserInteraction) {
self.externalScrollView.top = self.scrollDragHelper.getBottomPositionToTopDistance()
}
} else {
self.externalScrollView.top = self.scrollDragHelper.getBottomPositionToTopDistance()
}
}

只能滚动外部视图,内部滚动视图偏移量是0.


B. 在中间


/// 回到 中间 位置
private func gotoMiddlePosition() {
self.curScrollViewPositionType = .middle
self.goToMiddlePosiionBlock?()
}

private func gotoMiddlePosition(withAnimated animated: Bool = true) {
self.insideTableView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
if animated {
UIView.animate(withDuration: 0.18, delay: 0, options: .allowUserInteraction) {
self.externalScrollView.top = self.scrollDragHelper.getMiddlePositionToTopDistance()
}
} else {
self.externalScrollView.top = self.scrollDragHelper.getMiddlePositionToTopDistance()
}
}

只能滚动外部视图,内部滚动视图偏移量是0.


C. 在顶部



  • 开始滚动判断:


    /// 更新 当前 滚动类型 当开始拖动 (当在顶部,开始滑动时候,判断当前滑动的对象是内部滚动视图,还是外部滚动视图)
public func updateCurrentScrollTypeWhenBeginDragging(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
}
}


  • 滚动过程中判断


/// 更新 滚动 类型 当滚动的时候,并返回是否立即停止滚动
public func isNeedToStopScrollAndUpdateScrollType(scrollView: UIScrollView) -> Bool {
if scrollView == self.insideScrollView {
/// 当前滚动的是外部视图
if self.currentScrollType == .externalView {
self.insideScrollView?.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
return true
}
if self.curScrollViewPositionType == .top {
if self.currentScrollType == .all { /// 在顶部的时候,外部和内部视图都可以滑动,判断当内部滚动视图视图的位置,如果滚动到底部了,则变为外部滚动视图跟着滑动,内部滚动视图不动
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .insideView
}
} else if scrollView.isDecelerating == false,
self.currentScrollType == .insideView { /// 在顶部的时候,当内部滚动视图,慢慢滑动到底部,变成整个外部滚动视图跟着滑动下来,内部滚动视图不再滑动
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
}
}
}
}
return false
}


  • 滚动结束判断


/// 当在顶部,滚动停止时候 更新 当前 滚动类型 ,如果当前内部滚动视图,已经滚动到最底部,
/// 则只能滚动最外层滚动视图,如果内部滚动视图没有滚动到最底部,则外部和内部视图都可以滚动
public func updateCurrentScrollTypeWhenScrollEnd(_ scrollView: UIScrollView) {
if self.curScrollViewPositionType == .top {
if scrollView.contentOffset.y <= 0 {
self.currentScrollType = .externalView
} else {
self.currentScrollType = .all
}
}
}

以上就是具体滚动判断相关处理逻辑,对应实现效果如下。



作者:果哥爸
来源:juejin.cn/post/7299731897626345481
收起阅读 »

越狱手机root密码重置

之前有入手过一台iphone6越狱机器,手机刚到那会儿,把玩了一番。之后就一直没动过了,今天突然心血来潮,想玩玩,结果发现,ssh登陆不上了,因为root密码不记得了。像咱们的qq或者什么的密码忘记了,正常思路,就是找回密码,即重置密码。所以同理。iphone...
继续阅读 »

之前有入手过一台iphone6越狱机器,手机刚到那会儿,把玩了一番。之后就一直没动过了,今天突然心血来潮,想玩玩,结果发现,ssh登陆不上了,因为root密码不记得了。
像咱们的qq或者什么的密码忘记了,正常思路,就是找回密码,即重置密码。所以同理。
iphone手机的账号和密码,一般是存储在/private/etc/master.passwd。


把这个文件导出到电脑桌面(一般越狱手机都是可以通过一些软件直接访问文件的,像ifunbox、pp助手、ifiles。这里我用的是pp助手),打开:



编辑该文件,把root后面的ZGrKPbggg0H8Q(这里不一定是这个,每个机器肯定都不同,只需记住是root:之后的13个字符即可。)改为/smx7MYTQIi2M


把文件名修改为master.passwd,再放回手机的private/etc/目录下。
再次ssh登陆越狱手机,输入alpine,即可。


收起阅读 »

ARM汇编基础(一)----寄存器篇

AArch64寄存器Arm处理器提供通用寄存器和专用寄存器以及一些在特定模式下可用的额外寄存器。在 AArch64状态下,有以下寄存器是可用的:31个64位通用寄存器(X0-X30),通用寄存器的低32位可用W0-W30访问。4个栈指针寄存器:SP_EL0、S...
继续阅读 »

AArch64寄存器

Arm处理器提供通用寄存器和专用寄存器以及一些在特定模式下可用的额外寄存器。

在 AArch64状态下,有以下寄存器是可用的:
  • 31个64位通用寄存器(X0-X30),通用寄存器的低32位可用W0-W30访问。

  • 4个栈指针寄存器:SP_EL0、SP_EL1、SP_EL2、SP_EL3。

  • 3个异常链接寄存器:ELR_EL1、ELR_EL2、ELR_EL3。

  • 3个程序状态寄存器:SPSR_EL1、SPSR_EL2、SPSR_EL3。

  • 1个程序计数器。

除了状态寄存器SPSR_EL1、SPSR_EL2、SPSR_EL3是32bit,所有的寄存器均为64bit。

大多数的指令都可以操作32bit和64bit寄存器。寄存器宽度由寄存器标识符决定,W表示32bit,X表示64bit。Wn和Xn(0-30)是指同一个寄存器。当使用32位指令时,源寄存器的高32bit会被忽略,而目的寄存器的高32bit则会被置0。

没有W31或者X31寄存器,根据指令,寄存器31会被用作栈指针寄存器或者零寄存器。当用作栈指针寄存器时,可以用SP表示;当用作零寄存器时,用WZR和XZR分别表示32bit和64bit的零寄存器。

异常等级

Armv8架构定义了4个异常等级(EL0-EL3),EL3代表拥有最多执行特权的最高异常级别。当接受异常时,异常等级可以上升或者保持不变,当从异常处理中返回时,异常等级可以降低或者保持不变。

以下为常用的异常等级模式:

  • EL0

应用程序。

  • EL1

系统内核以及特殊函数。

  • EL2

虚拟机监视器(virtual machine monitor)。

  • EL3

Secure monitor.

当将异常带到更高的异常级别时,执行状态可以保持不变,或者从AArch32变到AArch64。

当返回到较低的异常级别时,执行状态可以保持不变,也可以从AArch64变更为AArch32。

执行状态的改变的唯一方式是从异常中获取或返回。在执行状态之间进行更改不可能与在AArch32状态下在A32和T32指令之间进行更改相同。

在powerup和reset上,处理器进入最高的实现异常级别。此异常级别的执行状态是实现的属性,可能由配置输入信号决定。

对于EL0以外的异常级别,执行状态由一个或多个控制寄存器配置位决定。这些位只能在更高的异常级别上设置。

对于EL0,执行状态被确定为异常返回EL0的一部分,由执行返回的异常级别控制。

LR寄存器

在AArch64状态下,当进行子函数调用时,LR寄存器保存返回地址。如果返回地址呗保存在栈上,LR寄存器也可以用作通用寄存器。LR寄存器对应的是寄存器30,与AArch32不同的是,LR寄存器和异常链接寄存器(ELRs)是不同的,因此,LR是未存储的。(ps:Unlike in AArch32 state, the LR is distinct from the Exception Link Registers (ELRs) and is therefore unbanked.【unbanked 确实不知道怎么翻译】)

异常链接寄存器有三个:ELR_EL1、ELR_EL2、ELR_EL3,与异常等级相对应。当发生异常时,目标异常级别的异常链接寄存器将存储异常处理完后要跳转到的返回地址。如果异常来自AArch32,ELR寄存器的高32bit全部置0。异常级别内的子函数调用用LR来存储子函数的返回地址。

例如,当异常等级从EL0变为EL1,返回地址将存储在ELR_EL1寄存器中。

在异常时,如果要启用用相同级别的中断,必须将ELR中的数据存储到栈中,因为在发生中断时ELR寄存器将被新的返回地址覆盖。

栈指针寄存器

在AArch64状态下,SP表示64位栈指针,SP_EL0是SP的别名。不要讲SP用作通用寄存器。

SP只能用作以下指令的操作寄存器:

  • 作为装载和存在的基本寄存器。在这种情况下,再添加任何偏移量之前,它必须是4字节对齐的,否则会发生堆栈对齐异常。

  • 作为算术指令的源寄存器或者目标寄存器,但是它不能被用作设置了条件标志的指令的目标寄存器。

  • 逻辑指令,例如为了使其地址对齐。

对于三个异常级别,都有一个单独的栈指针。在异常级别中,可以使用该异常级别下的专用栈指针,也可以使用与之相应的栈寄存器。可以使用SPSel寄存器来选择要在异常级别使用的栈指针。

栈指针的选择由附加到异常级别名称的字母t或h表示,例如EL0t或EL3h。t后缀表示异常级别使用SP_EL0, h后缀表示使用SP_ELx,其中x是当前异常级别号。EL0总是使用SP_EL0,所以不能有h后缀。

AArch64状态下预声明的核心寄存器名字

在AArch64状态中,预先声明的核心寄存器与AArch32状态中的不同。

下表显示AArch64状态下预声明的核心寄存器:

寄存器名称含义
W0-W3032-bit 通用寄存器。
X0-X3064-bit 通用寄存器。
WZR32-bit RAZ/WI寄存器,在32位指令下,寄存器31用作零寄存器时的名称。
XZR64-bit RAZ/WI寄存器,在64位指令下,寄存器31用作零寄存器时的名称。
WSP32-bit 栈指针,在32位指令下,寄存器31用作栈指针时的名称。
SP64-bit 栈指针,在64位指令下,寄存器31用作栈指针时的名称。
LR链接寄存器。和X30是同一个寄存器。

可以将寄存器名全部写成大写或小写。

请注意:

在AArch64状态下,PC寄存器不是一个通用寄存器,不能通过名称来访问他。

在AArch64下预声明的扩展寄存器

您可以将高级SIMD和浮点寄存器的名称写成大写或小写。

下表显示了AArch64状态下预先声明的扩展寄存器名:

寄存器名称含义
V0-V31128-bit矢量寄存器。
Q0-Q31128-bit标量寄存器。
D0-D3164-bit标量寄存器、双精度浮点寄存器。
S0-S3132-bit标量寄存器、单精度浮点寄存器。
H0-H3116-bit标量寄存器、半精度浮点寄存器。
B0-B318-bit标量寄存器。

AArch64状态下的PC寄存器

在AArch64状态下,PC寄存器存储的事当前执行的指令的地址。

它是由执行的指令的大小增加的,总是四个字节。

在AArch64状态下,PC寄存器不是一个通用寄存器,所以不能显式地访问它。以下类型的指令,可以隐式地读取它的值:

  • 计算PC相对地址的指令。

  • PC相关的加载指令。

  • 直接指向PC相关的标签。

  • 分支和链接指令,会将PC值存储在LR寄存器中。

唯一可以写入PC寄存器的指令类型:

  • 条件分支和无条件分支。

  • 异常产生和异常返回。

分支指令将目的地址加载到PC寄存器中。

在AArch64状态下的条件执行

在AArch64状态下,NZCV寄存器保存着N、Z、C、V标志位的值,处理器用这些标志位来决定是否执行条件指令。这些标志位被保存在NZCV寄存器的【31:28】位上。

条件标志位在任何异常等级下都可以使用MSR和MRS指令进行访问。

与A32相比,A64对条件的利用更少。例如在A64中:

  • 只有少数的指令可以set或test条件标志位。

  • 没有等效的T32 IT指令。

  • 唯一有条件执行的指令是条件分支指令B.cond,如果条件判定不成立(false),B.cond指令就像NOP指令一样。

在AArch64状态下的Q标志位

在AArch64状态下,不能对Q标识位进行读写,因为在A64中没有在通用寄存器上操作的饱和算术指令。(in A64 there are no saturating arithmetic instructions that operate on the general purpose registers.)

先进的SIMD饱和算法指令将浮点状态寄存器(FPSR)中的QC位设置为表示已经发生饱和。您可以通过Q助记符修饰符(例如SQADD)来识别这些指令。

流程状态

在AArch64状态下,没有CPSR寄存器。但是可以通过访问CPSR中不同的部分作为流程状态字段。

流程状态字段:

  • N、Z、C、V条件标识位(NZCV)。

  • 当前寄存器位宽(nRW)。

  • 栈指针选择位(SPSel)。

  • 禁止中断位(DAIF)。

  • 当前异常等级(EL)。

  • 单步处理位(SS)。

  • 非法异常返回状态位(IL)。

可以使用MSR指令写:

  • NZCV寄存器中的N、Z、C、V标识位。

  • DAIF寄存器中的禁止中断标识位。

  • 在异常等级为EL1或更高的情况下,SPSel寄存器的SP选择位。

可以使用MRS指令读:

  • NZCV寄存器中的N、Z、C、V标识位。

  • DAIF寄存器中的禁止中断标识位。

  • 在异常等级为EL1或更高的情况下,CEL寄存器的异常等级位。

  • 在异常等级为EL1或更高的情况下,SPSel寄存器的SP选择位。

当发生异常时,与当前异常级别关联的所有流程状态字段都存储在与目标异常级别关联的单个SPSR寄存器中。只能从SPSR访问SS、IL和nRW位。

AArch64下的SPSRs

保存的程序状态寄存器(SPSRs)是32位寄存器,当将异常带到使用AArch64状态的异常级别时,它存储当前异常级别的进程状态。这允许在处理异常之后恢复进程状态。

在AArch64状态下,每个异常等级都有自己的SPSR寄存器:

  • SPSR_EL1.

  • SPSR_EL2.

  • SPSR_EL3.

当发生异常时,当前异常等级的进程状态会被写入当前异常等级对应的SPSR寄存器中。当从一个异常中返回时,异常处理程序使用正在返回的异常级别的SPSR来恢复正在返回的异常级别的流程状态。

请注意

从异常返回时,首选返回地址将从与正在返回的异常级别关联的ELR恢复。

SPSRs存储了以下信息:

  • N、Z、C、V标识位。

  • D、A、I、F禁止中断位。

  • 寄存器位宽。

  • 执行模式。

  • IL和SS位。

收起阅读 »

符号绑定的另一种打开方式

懒加载和非懒加载iOS对于引用的外部符号,分为Lazy Symbol和Non-Lazy Symbol,分别存储在__DATA,__got节和__DATA,__la_symbol_ptr节。Non-Lazy Symbol符号在dyld加载模块的时候,就会将真实的...
继续阅读 »

懒加载和非懒加载

iOS对于引用的外部符号,分为Lazy SymbolNon-Lazy Symbol,分别存储在__DATA,__got节和__DATA,__la_symbol_ptr节。

Non-Lazy Symbol符号在dyld加载模块的时候,就会将真实的函数地址写入到对应的地址中,实现绑定。而Non-Lazy Symbol则会在第一次调用该函数的时候,为其动态寻找真实函数地址并进行绑定。

facebook基于符号绑定机制,写出了hook神器fishhook,通过查找符号指针并替换,从而达到hook效果!!!

然而,基于模块检测反hook,却甚是烦人。你可能会有反反hook来应付,但是它也有可能会有反反反hook来对付你~~~

那么,怎么才能终结这场hook与反hook的心理战呢?

Mach-O View 分析动态符号绑定过程

简单分析一下Lazy Symbol的绑定过程:



这里以NSLog为例:

可以看出,符号NSLog所指向的地址为:0x0000000100006474。

转化为文件偏移为:0x0000000100006474 - 0x100008078 + 32888 = 0x6474;

到文件偏移为0x6474的位置查看:

这是一段可执行代码,地址0x6474处的意思是:读取0x647c位置处的四个字节的数据(0x1d),保存到w16寄存器。然后无条件跳转到0x645c(这里的地址,全部都是指文件偏移)。


这段代码,光这么看其实看不出什么,但是如果去调试的话,就会发现,这段代码实际上是在调用dyld_stub_binder为懒加载符号绑定真实地址。而刚刚在0x6474处的代码获取到的四字节的数据,实际上是符号绑定信息的偏移:


0xc428+0x1d = 0xc445

也就是说,动态绑定NSLog所需要的数据,就存储在0xc445处。

那么,理论上来说,如果我们尝试着修改这里的数据,是不是就会改变符号的查找的过程呢?

实践

想的再多,都不如动手操作!!!

新建一个工程,书写如下代码(main.m):

__attribute__((constructor)) static void entry(int argc,char *argv[],char **apple,char **executablepath,struct mach_header_64 **mh_ptr){

if (!strncmp(argv[0], "aaa", 3)) {
printf("the same!!");
}
}

并按照如上方式,查找到函数strncmp的Lazy Binding Info,做如下修改:


修改后:


编写动态库并注入到可执行文件:


__attribute__((visibility("default"))) int strncmq(const char *__s1, const char *__s2, size_t __n);

int strncmq(const char *__s1, const char *__s2, size_t __n){
      printf("hook:%s\nhook:%s",__s1,__s2);
       return strncmp(__s1, __s2, __n);
}

重签名运行!!


发现已经替换成功了!!!

但是,用ida或者hopper分析一下二进制文件,会发现调用的还是原来的strncmp符号:


说明如果进行模块检测的话,还是可以检测出来的~因为虽然符号查找替换了,但是实际上"外套"还是strncmp。所以,继续把外套也修改了!!!

修改这两个处:


修改后:


总结

对于动态绑定的外部引用符号,能动手脚的地方确实很多!!!

收起阅读 »

iOS应用砸壳

应用商店下载的app,都是进过加密过的,用hopper或者ida完全分析不了。那是不是就没办法了呢? 其实不然,解铃还须系铃人,要想得到解密后的文件,还是要依靠苹果爸爸啊!!! 首先,我们知道,加密后的应用,如果不解密的话,苹果自己都不知道怎么去解析可执行文件...
继续阅读 »

应用商店下载的app,都是进过加密过的,用hopper或者ida完全分析不了。那是不是就没办法了呢?
其实不然,解铃还须系铃人,要想得到解密后的文件,还是要依靠苹果爸爸啊!!!
首先,我们知道,加密后的应用,如果不解密的话,苹果自己都不知道怎么去解析可执行文件,所以当设备运行应用时,加载进内存中的数据,肯定是经过解密后的数据,因此咱们只需把内存里的对应的解密部分dump下来即可。
这里介绍一个砸壳工具:dumpdecrypted
该工具的原理就是在程序运行之后注入一个动态库,然在内存中dump下解密之后的部分。
原理和实现不赘述,想进一步了解,可以查看源码。
这里简单讲一下如何用该工具进行解密app:
1、运行源码,生成动态链接库。
解压下载下来的dumpdecrypted-master.zip到当前目录;打开终端,cd到该目录。


在该目录下直接运行make:


如果运行不成功,报错找不到文件。运行xcode-select --print-path ,查看目录是否指向/Applications/Xcode.app/Contents/Developer。如果不是,则用xcode-select -s /Applications/Xcode.app/Contents/Developer修改一下。再执行make。
运行成功,会在当前目录生成dumpdecrypted.dylib动态库。
2、注入动态库
ssh登录进手机,用ps命令查看目标app所在的目录。


记住这个目录。待会儿有用。
再用cycript获取目标app的沙盒目录。


获取到目标app的沙盒目录之后,退出cycriptcontrol+D),将第一步得到的dumpdecrypted.dylib,导入到沙盒目录(导入方法有很多,scp、PP助手、ifiles等)。
cd到沙盒目录,运行DYLD_INSERT_LIBRARIES=dumpdecrypted.dylib /var/mobile/Containers/Bundle/Application/DDFA8DC8-F19A-4A6F-932B-22E8170BB22D/HDXinHuaDict.app/HDXinHuaDict:


看到一系列的+就说明运行成功,在沙盒目录下就会有被解密的二进制文件。



收起阅读 »

类的布局——方法缓存hash表

回顾一下class的结构:struct objc_class : objc_object { // Class ISA; // 继承自 struct objc_object Class superclass; cache_t cache;...
继续阅读 »

回顾一下class的结构:struct objc_class : objc_object {

    // Class ISA;			// 继承自 struct objc_object
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits;
class_rw_t *data() const {
return bits.data();
}
};

不难发现,在objc_class结构中,有一个cache_t类型的成员变量cache

其结构如下:struct bucket_t {

private:
IMP _imp;
SEL _sel;
};
struct cache_t {
private:
uintptr_t _bucketsAndMaybeMask;
union {
struct {
uint32_t _unused;
uint16_t _occupied;
uint16_t _flags;
};
preopt_cache_t * _originalPreoptCache;
};
};
// objc-cache.m
static constexpr uintptr_t maskShift = 48;
// Additional bits after the mask which must be zero. msgSend
// takes advantage of these additional bits to construct the value
// `mask << 4` from `_maskAndBuckets` in a single instruction.
static constexpr uintptr_t maskZeroBits = 4;
// The largest mask value we can store.
static constexpr uintptr_t maxMask = ((uintptr_t)1 << (64 - maskShift)) - 1;
// The mask applied to `_maskAndBuckets` to retrieve the buckets pointer.
static constexpr uintptr_t bucketsMask = ((uintptr_t)1 << (maskShift - maskZeroBits)) - 1;

struct bucket_t *cache_t::buckets() const {
uintptr_t addr = _bucketsAndMaybeMask;
return (bucket_t *)(addr & bucketsMask);
}
// 哈希表已用
mask_t cache_t::occupied() const {
return _occupied;
}
// 哈希表容积
unsigned cache_t::capacity() const {
return mask() ? mask()+1 : 0;
}
mask_t cache_t::mask() const {
uintptr_t maskAndBuckets = _bucketsAndMaybeMask;
return maskAndBuckets >> maskShift;
}

通过查看cache_tbucket_t的结构以及其实现,可以很清晰的看到类的整个缓存表的内容。

接下来用lldb简单验证下:

//  CacheClass.h
@interface CacheClass : NSObject
- (void)cacheMethodA;
- (void)cacheMethodB:(NSString *)str;
- (void)cacheMethodC:(NSUInteger)integer andString:(NSString *)str;
@end

// CacheClass.m
#import "CacheClass.h"
@implementation CacheClass
- (void)cacheMethodA {
NSLog(@"%s", __func__);
}
- (void)cacheMethodB:(NSString *)str {
NSLog(@"%@===>%s", str, __func__);
}
- (void)cacheMethodC:(NSUInteger)integer andString:(NSString *)str {
NSLog(@"%lu====>%@===>%s", integer, str, __func__);
}
@end

// 调用
CacheClass *cacheObj = [[CacheClass alloc] init];
[cacheObj cacheMethodA];
NSLog(@"===================="); // 此行断点

  • 获取CacheObj的isa
(lldb) x/1gx cacheObj
0x281da0bc0: 0x0100000102c2d101
(lldb) p/x 0x0100000102c2d101 & 0x0000000ffffffff8ULL
(unsigned long long) $4 = 0x0000000102c2d100
  • 读取cache_t结构体:
(lldb) x/2gx 0x0000000102c2d100+0x10		// 此处+0x10是因为cache是在`objc_class`结构体的第16字节处开始。
0x102c2d110: 0x0001000281f850c0 0x8010000200000000 // 0x0001000281f850c0 为 _bucketsAndMaybeMask的值 0x8010000200000000 为 联合体的值
  • 获取bucket_t数组的首地址:
(lldb) p/x ((uintptr_t)1 << (48-4))-1		// 计算 bucketsMask 的值
(unsigned long) $4 = 0x00000fffffffffff
(lldb) p/x 0x0001000281f850c0 & 0x00000fffffffffff // _bucketsAndMaybeMask & bucketsMask
(long) $5 = 0x0000000281f850c0 // bucket_t数组的首地址
  • 获取bucket_t数组的count:
(lldb) p/x (0x0001000281f850c0 >> 48) + 1		// 相当于调用cache_t::capacity()函数
(long) $9 = 0x0000000000000002
  • 输出bucket_t数组的内容:
(lldb) x/4gx 0x0000000281f850c0
0x281f850c0: 0x58226481ad686ff0 0x00000001b0870410
0x281f850d0: 0x3c04798102c25d08 0x0000000102c26f97
  • 以{IMP,SEL}的结构验证:
(lldb) p (char *)0x00000001b0870410
(char *) $10 = 0x00000001b0870410 "init"
(lldb) dis -a 0x58226481ad686ff0
libobjc.A.dylib`-[NSObject init]:
0x1ad686ff0 <+0>: ret
0x1ad686ff4 <+4>: udf #0x0
0x1ad686ff8 <+8>: udf #0x0
(lldb) p (char *)0x0000000102c26f97
(char *) $11 = 0x0000000102c26f97 "cacheMethodA"
(lldb) dis -a 0x3c04798102c25d08
MethodCacheDemo`-[CacheClass cacheMethodA]:
0x102c25d08 <+0>: sub sp, sp, #0x30
0x102c25d0c <+4>: stp x29, x30, [sp, #0x20]
0x102c25d10 <+8>: add x29, sp, #0x20
0x102c25d14 <+12>: stur x0, [x29, #-0x8]
0x102c25d18 <+16>: str x1, [sp, #0x10]
0x102c25d1c <+20>: mov x9, sp
0x102c25d20 <+24>: adrp x8, 2
0x102c25d24 <+28>: add x8, x8, #0x363 ; "-[CacheClass cacheMethodA]"
0x102c25d28 <+32>: str x8, [x9]
0x102c25d2c <+36>: adrp x0, 3
0x102c25d30 <+40>: add x0, x0, #0x90 ; @"%s"
0x102c25d34 <+44>: bl 0x102c26344 ; symbol stub for: NSLog
0x102c25d38 <+48>: ldp x29, x30, [sp, #0x20]
0x102c25d3c <+52>: add sp, sp, #0x30
0x102c25d40 <+56>: ret

从输出结果可以看出,缓存数组位置是正确的,类CacheClass缓存了两个方法,分别为:-[NSObject init]-[CacheClass cacheMethodA]

用代码获取:

#if __arm64__
#if TARGET_OS_EXCLAVEKIT
#define ISA_MASK 0xfffffffffffffff8ULL
#elif __has_feature(ptrauth_calls) || TARGET_OS_SIMULATOR
#define ISA_MASK 0x007ffffffffffff8ULL
#else
#define ISA_MASK 0x0000000ffffffff8ULL
#endif
#endif

uintptr_t _isaForObject(NSObject *obj) {
if (obj == nil) return 0;
struct _object {
BytePtr isa;
};
struct _object *obj_ptr = (struct _object *)(__bridge void *)obj;
return (uintptr_t)((uintptr_t)obj_ptr->isa & ISA_MASK);
}

typedef uint32_t mask_t;
//cache_t源码模仿
const uintptr_t maskShift = 48;
const uintptr_t maskZeroBits = 4;
const uintptr_t maxMask = (((uintptr_t)1 << (64 - maskShift))-1);
const uintptr_t bucketsMask = ((uintptr_t)1 << (maskShift - maskZeroBits)) - 1; //0x100000000000-1 = 0xfffffffffff
struct bucket_t {
IMP _imp;
SEL _sel;
};
struct cache_t {
uintptr_t _bucketsAndMaybeMask; // 8
union {
struct {
uint32_t _unused;
uint16_t _occupied;
uint16_t _flags;
};
uintptr_t _originalPreoptCache; // 8
};
};
struct bucket_t *buckets(struct cache_t *cache) {
return (struct bucket_t *)(cache->_bucketsAndMaybeMask & bucketsMask);
}
uint32_t mask(struct cache_t *cache) {
return (uint32_t)(cache->_bucketsAndMaybeMask >> maskShift);
}
uint32_t capacity(struct cache_t *cache) {
return mask(cache) ? mask(cache)+1 : 0;
}
mask_t occupied(struct cache_t *cache) {
return cache->_occupied;
}

void _printMethodCaches(id obj) {
printf("============================\n");
uintptr_t isa = _isaForObject(obj);

// 读取cache结构体
struct cache_t *cache = (struct cache_t *)(isa + 0x10);

// 读取bucket_t
struct bucket_t *bucket_array = buckets(cache);

// 获取count
uint32_t count = capacity(cache);

// 获取已缓存数
uint32_t occupied_count = occupied(cache);

printf("哈希表容积:%u\t\t\t已缓存方法数:%u\n",count, occupied_count);

// 输出缓存内容
for (int c = 0; c < count; c++) {
struct bucket_t *bucket = (bucket_array + c);
printf("imp->sel:0x%lx->%s\n", (intptr_t)bucket->_imp, sel_getName(bucket->_sel));
}
printf("============================\n");
}

// 调用
CacheClass *cacheObj = [[CacheClass alloc] init];
[cacheObj cacheMethodA];
_printMethodCaches(cacheObj);

// 输出
============================
哈希表容积:2 已缓存方法数:2
imp->sel:0x4f5fd201ad686ff0->init
imp->sel:0xe9344e810494dc50->cacheMethodA
============================

需要注意的是,缓存哈希表有一个扩容的过程,当缓存方法超过了哈希表容积时,就会触发扩容,此时,之前的缓存并不会被复制到新的hash表中,而是重新还是缓存!

例如上面的调用修改为如下:

    CacheClass *cacheObj = [[CacheClass alloc] init];
[cacheObj cacheMethodA];
[cacheObj cacheMethodB:@"B"];

_printMethodCaches(cacheObj);

则输出为:

============================
哈希表容积:4 已缓存方法数:1
imp->sel:0x0-><null selector>
imp->sel:0x0-><null selector>
imp->sel:0xf85bf08100269c6c->cacheMethodB:
imp->sel:0x0-><null selector>
============================

从上面的输出可以看出,调用方法-[CacheClass cacheMethodB:]时,触发了缓存表扩容;扩容过程中,它舍弃了原缓存表中的方法,仅缓存了当前方法(-[CacheClass cacheMethodB:])。


收起阅读 »

类的布局——方法列表(1)

和成员变量的使用方式一样,都是先初始化对象,再使用。这就容易让人产生一个误区:实例方法和成员变量一样,每个对象独一份,在对象初始化时存储在堆区。类对象的定义:struct class_rw_ext_t { DECLARE_AUTHED_PTR_TEMPL...
继续阅读 »

对于OC的实例方法,用法是先实例化对象,再用实例对象调用方法:

// ObjectA.h
@interface ObjectA : NSObject
@property(nonatomic,assign,readonly)BOOL b;
- (void)funcA;
- (void)funcB:(NSString *)str;
@end

// ObjectA.m
#import "ObjectA.h"
@implementation ObjectA
- (void)funcA {
NSLog(@"方法A");
}
- (void)funcB:(NSString *)str {
NSLog(@"方法B:%@", str);
}
@end

// 调用实例方法
ObjectA *aObj = [[ObjectA alloc] init];
[aObj funcA];

和成员变量的使用方式一样,都是先初始化对象,再使用。这就容易让人产生一个误区:实例方法和成员变量一样,每个对象独一份,在对象初始化时存储在堆区。

事实上,实例方法和成员变量有着本质的区别,成员变量是在对象实例化之后,存储在内存的堆区(即对象所在的地址);而实例方法作为可执行部分,编译之后存储在代码段,实例方法的地址(IMP)、方法名等信息则被存储在类对象中。

类对象的定义:

struct class_rw_ext_t {
DECLARE_AUTHED_PTR_TEMPLATE(class_ro_t)
class_ro_t_authed_ptr ro;
method_array_t methods;
property_array_t properties;
protocol_array_t protocols;
const char *demangledName;
uint32_t version;
};

struct class_rw_t {
// Be warned that Symbolication knows the layout of this structure.
uint32_t flags;
uint16_t witness;
explicit_atomic ro_or_rw_ext;

private:
using ro_or_rw_ext_t = objc::PointerUnion;
};
struct class_data_bits_t {
// Values are the FAST_ flags above.
uintptr_t bits;
};

struct objc_class : objc_object {
// Class ISA; // 继承自 struct objc_object
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits;
class_rw_t *data() const {
return bits.data();
}
};

用一个图大概描述一下其内存布局:


用lldb大致验证一下:

  • 初始化对象:
  ObjectA *aObj = [[ObjectA alloc] init];
NSLog(@"=====分割线====="); // 此处下断点
  • 获取对象地址:
(lldb) po aObj
  • 获取isa指针:
(lldb) x/1gx 0x282dccdd0
0x282dccdd0: 0x01000001001f14a9 // 0x01000001001f14a9 为优化后的isa指针,要获取真实的isa指针,需进一步处理
(lldb) p/x 0x01000001001f14a9 & 0x0000000ffffffff8ULL
(unsigned long long) $1 = 0x00000001001f14a8 // 0x00000001001f14a8 为真实的isa指针
  • 获取bits:
(lldb) x/5gx 0x00000001001f14a8
0x1001f14a8: 0x00000001001f1480 0x000000020afa07d8
0x1001f14b8: 0x0003000283a88440 0x8018000100000000
0x1001f14c8: 0x8000000282fea4e4 // 0x8000000282fea4e4 即为bits的值
  • 从bits值中获取class_rw_t结构体:
(lldb) p/x 0x8000000282fea4e4 & 0x0f00007ffffffff8UL
(unsigned long) $2 = 0x0000000282fea4e0 // struct class_rw_t指针
  • 获取class_ro_t结构体:
(lldb) x/2gx 0x0000000282fea4e0
0x282fea4e0: 0x0000000080080006 0x00000001001f02c8 // 0x00000001001f02c8 为ro_or_rw_ext联合指针
(lldb) p/x 0x00000001001f02c8 & 0x1
(long) $3 = 0x0000000000000000 // ro_or_rw_ext & 0x1 == 0 ,则 ro_or_rw_ex 的值即为class_ro_t指针
  • 获取baseMethods
(lldb) x/5gx 0x00000001001f02c8
0x1001f02c8: 0x0000000800000080 0x0000000000000009
0x1001f02d8: 0x0000000000000000 0x00000001001ef3d3
0x1001f02e8: 0x00000001001f0238 // 该指针即为baseMethods
  • 查看baseMethods的内容:
// baseMethods为struct method_list_t指针。
// struct method_list_t 结构如下:

template
struct entsize_list_tt {
uint32_t entsizeAndFlags;
uint32_t count;
uint32_t entsize() const {
return entsizeAndFlags & ~FlagMask;
}
uint32_t flags() const {
return entsizeAndFlags & FlagMask;
}

ALWAYS_INLINE
Element& getOrEnd(uint32_t i) const {
ASSERT(i <= count);
uint32_t iBytes;
if (os_mul_overflow(i, entsize(), &iBytes))
_objc_fatal("entsize_list_tt overflow: index %" PRIu32 " in list %p with entsize %" PRIu32,
i, this, entsize());
return *PointerModifier::modify(*(List *)this, (Element *)((uint8_t *)this + sizeof(*this) + iBytes));
}

Element& get(uint32_t i) const {
ASSERT(i < count);
return getOrEnd(i);
}
...
};

struct method_list_t : entsize_list_tt {
...
};

乍一看,似乎这里并没有存储任何和方法列表相关的信息,但是细看结构体模版entsize_list_ttgetOrEnd实现就会发现,该模版在结构体内部维护了一个Element类型的数组,其实际结构可以理解为:

struct entsize_list_tt {
uint32_t entsizeAndFlags;
uint32_t count;
Element elements[0];
};

从上面可以看出,该模版作为method_list_t的实现,Element的类型为:method_t

struct method_t {
SEL name;
const char *types;
MethodListIMP imp;
};

到这里,baseMethods的内容便呼之欲出了:

(lldb) x/15gx 0x00000001001f0238			// 以指针的形式读取15条baseMethods的内容
0x1001f0238: 0x000000030000001b 0x00000001001ef035 //0x000000030000001b 为 entsizeAndFlags 和 count 的值
0x1001f0248: 0x00000001001ef444 0x00000001001edd24
0x1001f0258: 0x00000001001ef03b 0x00000001001ef44c
0x1001f0268: 0x00000001001edd50 0x00000001b04f22e9
0x1001f0278: 0x00000001001ef457 0x00000001001eddb4
0x1001f0288: 0x0000000100000020 0x00000001001f1420
0x1001f0298: 0x00000001001ee619 0x00000001001ef45f
0x1001f02a8: 0x0000000100000000
(lldb) p (SEL)0x00000001001ef035 // 读取第1个method_t的name
(SEL) $11 = "funcA"
(lldb) p (char *)0x00000001001ef444 // 读取第1个method_t的types
(char *) $12 = 0x00000001001ef444 "v16@0:8"
(lldb) dis -a 0x00000001001edd24 // 通过反编译指令验证第1个method_t的imp
ObjectDemo`-[ObjectA funcA]:
0x1001edd24 <+0>: sub sp, sp, #0x20
0x1001edd28 <+4>: stp x29, x30, [sp, #0x10]
0x1001edd2c <+8>: add x29, sp, #0x10
0x1001edd30 <+12>: str x0, [sp, #0x8]
0x1001edd34 <+16>: str x1, [sp]
0x1001edd38 <+20>: adrp x0, 3
0x1001edd3c <+24>: add x0, x0, #0x90 ; @
0x1001edd40 <+28>: bl 0x1001ee3a8 ; symbol stub for: NSLog
0x1001edd44 <+32>: ldp x29, x30, [sp, #0x10]
0x1001edd48 <+36>: add sp, sp, #0x20
0x1001edd4c <+40>: ret
(lldb) p (SEL)0x00000001001ef03b // 读取第2个method_t的name
(SEL) $13 = "funcB:"
(lldb) p (char *)0x00000001001ef44c // 读取第2个method_t的types
(char *) $14 = 0x00000001001ef44c "v24@0:8@16"
(lldb) dis -a 0x00000001001edd50 // 通过反编译指令验证第2个method_t的imp
ObjectDemo`-[ObjectA funcB:]:
0x1001edd50 <+0>: sub sp, sp, #0x40
0x1001edd54 <+4>: stp x29, x30, [sp, #0x30]
0x1001edd58 <+8>: add x29, sp, #0x30
0x1001edd5c <+12>: mov x8, x1
0x1001edd60 <+16>: mov x1, x2
0x1001edd64 <+20>: stur x0, [x29, #-0x8]
0x1001edd68 <+24>: stur x8, [x29, #-0x10]
0x1001edd6c <+28>: add x0, sp, #0x18
0x1001edd70 <+32>: str x0, [sp, #0x8]
0x1001edd74 <+36>: mov x8, #0x0
0x1001edd78 <+40>: str x8, [sp, #0x10]
0x1001edd7c <+44>: str xzr, [sp, #0x18]
0x1001edd80 <+48>: bl 0x1001ee42c ; symbol stub for: objc_storeStrong
0x1001edd84 <+52>: ldr x8, [sp, #0x18]
0x1001edd88 <+56>: mov x9, sp
0x1001edd8c <+60>: str x8, [x9]
0x1001edd90 <+64>: adrp x0, 3
0x1001edd94 <+68>: add x0, x0, #0xb0 ; @
0x1001edd98 <+72>: bl 0x1001ee3a8 ; symbol stub for: NSLog
0x1001edd9c <+76>: ldr x0, [sp, #0x8]
0x1001edda0 <+80>: ldr x1, [sp, #0x10]
0x1001edda4 <+84>: bl 0x1001ee42c ; symbol stub for: objc_storeStrong
0x1001edda8 <+88>: ldp x29, x30, [sp, #0x30]
0x1001eddac <+92>: add sp, sp, #0x40
0x1001eddb0 <+96>: ret

用lldb将方法列表的存储位置过一遍后,整个过程清晰了很多,接下来再用代码走一遍:

// 定义 ISA_MASK 用于获取isa指针
#if __arm64__
#if TARGET_OS_EXCLAVEKIT
#define ISA_MASK 0xfffffffffffffff8ULL
#elif __has_feature(ptrauth_calls) || TARGET_OS_SIMULATOR
#define ISA_MASK 0x007ffffffffffff8ULL
#else
#define ISA_MASK 0x0000000ffffffff8ULL
#endif
#endif

// 定义 FAST_DATA_MASK 用于获取class_rw_t
#if TARGET_OS_EXCLAVEKIT
#define FAST_DATA_MASK 0x0000001ffffffff8UL
#elif TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR
#define FAST_DATA_MASK 0x0f00007ffffffff8UL
#else
#define FAST_DATA_MASK 0x0f007ffffffffff8UL
#endif

// 定义方法结构体
struct method_t {
SEL sel;
char *types;
IMP imp;
};

// 定义方法列表结构体
struct method_list_t {
uint32_t entsizeAndFlags;
uint32_t count;
struct method_t elements[0];
};

// 从实例对象中获取isa指针
BytePtr _isaForObject(NSObject *obj) {
if (obj == nil) return NULL;
struct _object {
BytePtr isa;
};
struct _object *obj_ptr = (struct _object *)(__bridge void *)obj;
return (BytePtr)((int64_t)obj_ptr->isa & ISA_MASK);
}

// 从isa指针中获取方法列表和方法数
void _methodsWithIsa(BytePtr isa, struct method_t **methods, uint32_t *count) {
// 获取isa中的bits
uintptr_t bits = *((uintptr_t *)(isa + 8/*isa*/ + 8/*superclass*/ + 16/*cache*/));

// 获取 class_rw_t
uintptr_t class_rw_t = bits & FAST_DATA_MASK;

// 获取 ro_or_rw_ext
uintptr_t ro_or_rw_ext = *((uintptr_t *)(class_rw_t + 0x8));

// 获取class_ro_t
// 判断是class_rw_ext_t 还是 class_ro_t
uintptr_t class_ro_t = ro_or_rw_ext;
if (ro_or_rw_ext & 0x1) {
// class_rw_ext_t
class_ro_t = *((uintptr_t *)ro_or_rw_ext);
}

// 获取class_ro_t 中的 baseMethods
uintptr_t baseMethods = *(uintptr_t *)(class_ro_t + 4/*flags*/ + 4/*instanceStart*/ + 4/*instanceSize*/ + 4/*reserved*/ + 8/*ivarLayout*/ + 8/*name*/);

struct method_list_t *_method_list_t = (struct method_list_t *)baseMethods;
*count = _method_list_t->count;
*methods = _method_list_t->elements;
};

// 输出方法信息
void _printMethods(struct method_t *methods, uint32_t count) {
struct method_t *node = methods;
for (int i=0; i printf("=============================\n");
printf("SEL:%s\n", sel_getName(node->sel));
printf("types:%s\n",node->types);
printf("imp:0x%lx\n",(uintptr_t)node->imp);
node++;
}
printf("=============================\n");
}

// 调用
ObjectA *aObj = [[ObjectA alloc] init];

BytePtr a_isa = _isaForObject(aObj);
struct method_t *method = NULL;
uint32_t count;
_methodsWithIsa(a_isa, &method, &count);
_printMethods(method, count);

// 输出如下:
=============================
SEL:funcA
types:v16@0:8
imp:0x1021ddce8
=============================
SEL:funcB:
types:v24@0:8@16
imp:0x1021ddd14
=============================
SEL:b
types:B16@0:8
imp:0x1021ddd78
=============================


收起阅读 »

类的布局——成员变量

日常开发中,我们定义的OC类,都会被编译成结构体类型:/// Represents an instance of a class.struct objc_object { Class _Nonnull isa OBJC_ISA_AVAILABILITY...
继续阅读 »

日常开发中,我们定义的OC类,都会被编译成结构体类型:

/// Represents an instance of a class.
struct objc_object {
Class _Nonnull isa OBJC_ISA_AVAILABILITY;
};

在类中定义的属性和成员变量,也会变成结构体的成员变量:

@interface ObjectA : NSObject
@property(nonatomic,assign)BOOL b;
@end

// 类ObjectA会转化为如下结构体
struct ObjectA_IMPL {
struct NSObject_IMPL NSObject_IVARS; // 继承自NSObject
BOOL _b;
};

知道了类的真面目,可以在内存级别去做一些操作;例如:

@interface ObjectA : NSObject
@property(nonatomic,assign,readonly)BOOL b;
@end

...

- (void)viewDidLoad {
[super viewDidLoad];
ObjectA *aObj = [[ObjectA alloc] init];

NSLog(@"%@",aObj); // 在这一行打断点

// Do any additional setup after loading the view.
}


用代码实现如下:

- (void)viewDidLoad {
[super viewDidLoad];
ObjectA *aObj = [[ObjectA alloc] init];

void *aObj_ptr = (__bridge void *)aObj;
BOOL *_b_ptr = (BOOL *)((BytePtr)aObj_ptr + 0x8);
*_b_ptr = YES;

NSLog(@"%d",aObj.b); // 打印:1 证明已修改

// Do any additional setup after loading the view.
}


收起阅读 »

iOS面试题目——hook block(3)

// 题目:实现下面的函数,将任意参数 block 的实现修改成打印所有入参,并调用原始实现//// 比如// void(^block)(int a, NSString *b) = ^(int a, NSString *b){// NSLog(@"...
继续阅读 »
// 题目:实现下面的函数,将任意参数 block 的实现修改成打印所有入参,并调用原始实现
//
// 比如
// void(^block)(int a, NSString *b) = ^(int a, NSString *b){
// NSLog(@"block invoke");
// }
// HookBlockToPrintArguments(block);
// block(123,@"aaa");
// 这里输出 "123,aaa" 和 "block invoke"

// void(^block)(int a, double b) = ^(int a, double b){
// NSLog(@"block invoke");
// }
// HookBlockToPrintArguments(block);
// block(123,3.14);
// 这里输出 "123,3.14" 和 "block invoke"

分析题目:首先,题目的本意和上一个题目一样,就是hook block 的 invoke,然后将其所有的入参打印出来,再调用原实现。区别在于任意Block,这个任意block,就让我们无法对用来替换的函数有一个很合适的定义,因为我们定义的时候,根本就不知道即将hook的block有几个参数。

这个问题,可以用libffi来解决。

整个思路如下:

1、获取要hook的block的相关信息,例如返回值、参数列表。这些信息都存储在bkock的方法签名里。

2、通过上一步获取到的信息,利用libffi创建一个函数模板(ffi_prep_cif())。

3、创建动态调用函数,并替换block中的Invoke。

4、编写替换函数,并实现调用原函数。

代码实现:

  • 获取block的签名信息:

    struct Block_layout *layout = (__bridge struct Block_layout *)block;

if (! (layout->flags & BLOCK_HAS_SIGNATURE)){
NSLog(@"当前block没有签名");
return;
}

uint8_t *desc = (uint8_t *)layout->descriptor;

desc += sizeof(struct Block_descriptor_1);

if (layout->flags & BLOCK_HAS_COPY_DISPOSE) {
desc += sizeof(struct Block_descriptor_2);
}
struct Block_descriptor_3 *desc_3 = (struct Block_descriptor_3 *)desc;

const char *signature = desc_3->signature;
NSMethodSignature *m_signature = [NSMethodSignature signatureWithObjCTypes:signature];
  • 创建函数模版:
    ffi_type **args = malloc(sizeof(ffi_type *)*[m_signature numberOfArguments]);

// 返回值类型
ffi_type *return_ffi;
const char *return_type = [m_signature methodReturnType];
if (*return_type == @encode(_Bool)[0]) {
return_ffi = &ffi_type_sint8;
}else if (*return_type == @encode(signed char)[0]){
return_ffi = &ffi_type_sint8;
}else if (*return_type == @encode(unsigned char)[0]){
return_ffi = &ffi_type_uint8;
}else if (*return_type == @encode(short)[0]){
return_ffi = &ffi_type_sint16;
}else if (*return_type == @encode(int)[0]){
return_ffi = &ffi_type_sint32;
}else if (*return_type == @encode(long)[0]){
return_ffi = &ffi_type_sint64;
}else if (*return_type == @encode(long long)[0]){
return_ffi = &ffi_type_sint64;
}else if (*return_type == @encode(id)[0]){
return_ffi = &ffi_type_pointer;
}else if (*return_type == @encode(Class)[0]){
return_ffi = &ffi_type_pointer;
}else if (*return_type == @encode(SEL)[0]){
return_ffi = &ffi_type_pointer;
}else if (*return_type == @encode(void *)[0]){
return_ffi = &ffi_type_pointer;
}else if (*return_type == @encode(char *)[0]){
return_ffi = &ffi_type_pointer;
}else if (*return_type == @encode(float)[0]){
return_ffi = &ffi_type_float;
}else if (*return_type == @encode(double)[0]){
return_ffi = &ffi_type_double;
}else if (*return_type == @encode(void)[0]){
return_ffi = &ffi_type_void;
}else{
NSLog(@"未找到合适的类型");
return;
}
// 初始化参数列表
for (int i=0; i<[m_signature numberOfArguments]; i++) {
const char *type = [m_signature getArgumentTypeAtIndex:i];
if (*type == @encode(_Bool)[0]) {
args[i] = &ffi_type_sint8;
}else if (*type == @encode(signed char)[0]){
args[i] = &ffi_type_sint8;
}else if (*type == @encode(unsigned char)[0]){
args[i] = &ffi_type_uint8;
}else if (*type == @encode(short)[0]){
args[i] = &ffi_type_sint16;
}else if (*type == @encode(int)[0]){
args[i] = &ffi_type_sint32;
}else if (*type == @encode(long)[0]){
args[i] = &ffi_type_sint64;
}else if (*type == @encode(long long)[0]){
args[i] = &ffi_type_sint64;
}else if (*type == @encode(id)[0]){
args[i] = &ffi_type_pointer;
}else if (*type == @encode(Class)[0]){
args[i] = &ffi_type_pointer;
}else if (*type == @encode(SEL)[0]){
args[i] = &ffi_type_pointer;
}else if (*type == @encode(void *)[0]){
args[i] = &ffi_type_pointer;
}else if (*type == @encode(char *)[0]){
args[i] = &ffi_type_pointer;
}else if (*type == @encode(float)[0]){
args[i] = &ffi_type_float;
}else if (*type == @encode(double)[0]){
args[i] = &ffi_type_double;
}else{
NSLog(@"未知类型:注,结构体未处理");
return;
}
}

// _cif 定义的是全局变量 ffi_cif _cif;
ffi_status status = ffi_prep_cif(&_cif, FFI_DEFAULT_ABI, (int)[m_signature numberOfArguments], return_ffi, args);
if (status != FFI_OK) {
NSLog(@"初始化 cif 失败");
return;
}
  • 创建并绑定动态调用的函数:
    // 	_closure 定义的是全局变量		ffi_closure *_closure;
// _replacementInvoke 定义的是全局变量 void *_replacementInvoke;

_closure = ffi_closure_alloc(sizeof(ffi_closure), &_replacementInvoke);
if (!_closure) {
NSLog(@"hook 失败");
return;
}
ffi_status closure_loc_status = ffi_prep_closure_loc(_closure, &_cif, replace_bloke2_2, (__bridge void *)(NSObject.new), _replacementInvoke);
if (closure_loc_status != FFI_OK) {
NSLog(@"Hook failed! ffi_prep_closure returned %d", (int)status);
return;
}
  • 替换block中的invoke:
    //    修改内存属性
vm_address_t invoke_addr = (vm_address_t)&layout->invoke;
vm_size_t vmsize = 0;
mach_port_t object = 0;
vm_region_basic_info_data_64_t info;
mach_msg_type_number_t infoCnt = VM_REGION_BASIC_INFO_COUNT_64;
kern_return_t ret = vm_region_64(mach_task_self(), &invoke_addr, &vmsize, VM_REGION_BASIC_INFO, (vm_region_info_t)&info, &infoCnt, &object);
if (ret != KERN_SUCCESS) {
NSLog(@"获取失败");
return;
}
vm_prot_t protection = info.protection;
// 判断内存是否可写
if ((protection&VM_PROT_WRITE) == 0) {
// 修改内存属性 ===> 可写
ret = vm_protect(mach_task_self(), invoke_addr, sizeof(invoke_addr), false, protection|VM_PROT_WRITE);
if (ret != KERN_SUCCESS) {
NSLog(@"修改失败");
return;
}
}
// 保存原来的invoke
origin_blockInvoke2_2 = (void *)layout->invoke;
layout->invoke = (uintptr_t)_replacementInvoke;
  • 实现替换函数:
    void replace_bloke2_2(ffi_cif *cif, void *ret, void **args, void *userdata) {
struct Block_layout *layout = (struct Block_layout *)userdata;
uint8_t *desc = (uint8_t *)layout->descriptor;

desc += sizeof(struct Block_descriptor_1);

if (layout->flags & BLOCK_HAS_COPY_DISPOSE) {
desc += sizeof(struct Block_descriptor_2);
}
struct Block_descriptor_3 *desc_3 = (struct Block_descriptor_3 *)desc;

const char *signature = desc_3->signature;
NSMethodSignature *m_signature = [NSMethodSignature signatureWithObjCTypes:signature];

NSLog(@"回调函数");
NSLog(@"%d",cif->nargs);
// 解析参数
for (int i=0; i<[m_signature numberOfArguments]; i++) {
ffi_type *arg = args[i];
const char *type = [m_signature getArgumentTypeAtIndex:i];
if (*type == @encode(_Bool)[0]) {
NSLog(@"%d",(bool)arg->size);
}else if (*type == @encode(signed char)[0]){
NSLog(@"%d",(char)arg->size);
}else if (*type == @encode(unsigned char)[0]){
NSLog(@"%d",(unsigned char)arg->size);
}else if (*type == @encode(short)[0]){
NSLog(@"%d",(short)arg->size);
}else if (*type == @encode(int)[0]){
NSLog(@"%d",(int)arg->size);
}else if (*type == @encode(long)[0]){
NSLog(@"%ld",(long)arg->size);
}else if (*type == @encode(long long)[0]){
NSLog(@"%lld",(long long)arg->size);
}else if (*type == @encode(id)[0]){
NSLog(@"%@",(__bridge id)((void *)arg->size));
}else if (*type == @encode(Class)[0]){
NSLog(@"%@",(__bridge Class)((void *)arg->size));
}else if (*type == @encode(SEL)[0]){
NSLog(@"%s",((char *)arg->size));
}else if (*type == @encode(void *)[0]){
NSLog(@"0x%llx",((long long)arg->size));
}else if (*type == @encode(char *)[0]){
NSLog(@"%s",((char *)arg->size));
}else if (*type == @encode(float)[0]){
NSLog(@"%f",((float)arg->size));
}else if (*type == @encode(double)[0]){
NSLog(@"%f",((double)arg->size));
}else{
NSLog(@"未知类型:注,结构体未处理");
}
}
// 调用原函数
ffi_call(&_cif,(void *)origin_blockInvoke2_2, ret, args);
}


收起阅读 »

iOS面试题目——hook block(2)

// 题目:实现下面的函数,将 block 的实现修改成打印所有入参,并调用原始实现//// 例如:// void(^block)(int a, NSString *b) = ^(int a, NSString *b){// NSLog(@"blo...
继续阅读 »
// 题目:实现下面的函数,将 block 的实现修改成打印所有入参,并调用原始实现
//
// 例如:
// void(^block)(int a, NSString *b) = ^(int a, NSString *b){
// NSLog(@"block invoke");
// }
// HookBlockToPrintArguments(block);
// block(123,@"aaa");
// 这里输出 "123,aaa" 和 "block invoke"

分析:这个题目其实和题目一的本质是一样的,都是替换block的实现(即Hook Block),不过,相比较于题目一,这个题的侧重点在于:1、打印所有入参;2、调用原实现。针对这两个问题,我们逐一解析。

1、打印所有入参

对于已知参数个数和参数类型的block,要实现这个,其实并不难,只需要我们再声明替换函数的时候和block的参数对齐即可:

//这里要注意的是,第一个参数必须声明为block本身。
//针对 void(^block)(int a, NSString *b) ,我们可以将函数声明为如下形式:
void replace_bloke2(id block, int a, NSString *b);
2、调用原实现
上一个题目中,我们仅仅是将invoke的值替换了,也就是说我们舍弃了invoke原本的函数指针地址,即原本的实现;如果我们全局变量,将其先存储,再进行替换,然后在replace_bloke2函数中调用,是否就达到了目的呢?
//声明一个函数指针,用来存储invoke的值
void(*origin_blockInvoke2)(id block,int a,NSString *b);

void replace_bloke2(id block, int a, NSString *b) {
NSLog(@"%d,%@",a,b);
origin_blockInvoke2(block,a,b);
}

void HookBlockToPrintArguments(id block){

// 解析 block 为 struct Block_layout 结构体
struct Block_layout *layout = (__bridge struct Block_layout *)block;
// 修改内存属性
vm_address_t invoke_addr = (vm_address_t)&layout->invoke;
vm_size_t vmsize = 0;
mach_port_t object = 0;
vm_region_basic_info_data_64_t info;
mach_msg_type_number_t infoCnt = VM_REGION_BASIC_INFO_COUNT_64;
kern_return_t ret = vm_region_64(mach_task_self(), &invoke_addr, &vmsize, VM_REGION_BASIC_INFO, (vm_region_info_t)&info, &infoCnt, &object);
if (ret != KERN_SUCCESS) {
NSLog(@"获取失败");
return;
}
vm_prot_t protection = info.protection;
// 判断内存是否可写
if ((protection&VM_PROT_WRITE) == 0) {
// 修改内存属性 ===> 可写
ret = vm_protect(mach_task_self(), invoke_addr, sizeof(invoke_addr), false, protection|VM_PROT_WRITE);
if (ret != KERN_SUCCESS) {
NSLog(@"修改失败");
return;
}
}
// 保存原来的invoke
origin_blockInvoke2 = (void *)layout->invoke;
layout->invoke = (uintptr_t)replace_bloke2;
}



收起阅读 »

Xcode 15下,包含个推的项目运行时崩溃的处理办法

升级到Xcode15后,部分包含个推的项目在iOS17以下的系统版本运行时,会出现崩溃,由于崩溃在个推Framework内部,无法定位到具体代码,经过和个推官方沟通,确认问题是项目支持的最低版本问题。需要将项目的最低版本修改为iOS12.0或更高具体修改位置:...
继续阅读 »

升级到Xcode15后,部分包含个推的项目在iOS17以下的系统版本运行时,会出现崩溃,由于崩溃在个推Framework内部,无法定位到具体代码,经过和个推官方沟通,确认问题是项目支持的最低版本问题。

需要将项目的最低版本修改为iOS12.0或更高

具体修改位置:Target-General-Minimum Deployments-iOS 12.0



问题来源

收起阅读 »

iOS面试题目——hook block(1)

// 1、实现下面的函数,将 block 的实现修改为 NSLog(@"Hello world"); //也就是说,在调用完这个函数后调用用block()时,并不调用原始实现,而是打 "Hello world" void HookBlockToPrintHe...
继续阅读 »
// 1、实现下面的函数,将 block 的实现修改为 NSLog(@"Hello world");
//也就是说,在调用完这个函数后调用用block()时,并不调用原始实现,而是打 "Hello world"

void HookBlockToPrintHelloWorld(id block){

}


分析:题目的意思很明白,就是要实现一个函数,将作为参数传入的block的实现修改为一句log。有研究过block的结构的都知道,block实际上就是一个接口体。而它的实现部分,是作为函数指针被保存在结构体的`invoke`中(即第16个字节处):

struct Block_layout {
    void *isa;
    volatile int32_t flags; // contains ref count
    int32_t reserved;
    uintptr_t invoke; // 此处保存的是实现代码的起始地址
    struct Block_descriptor_1 *descriptor;
    // imported variables
};


所以这题的解法也很明朗:就是将block结构体中的invoke函数指针地址替换为我们的函数:

// 定义一个函数
void block1_replace(void){
NSLog(@"Hello world");
};
void HookBlockToPrintHelloWorld(id block){
    // 解析 block 为 struct Block_layout 结构体
struct Block_layout *layout = (__bridge struct Block_layout *)block;

    //此处不能直接修改,因为该处地址属性为不可写状态,强行替换,会导致程序崩溃
    //layout->invoke = (uintptr_t)block1_replace;

    //修改内存属性
    vm_address_t invoke_addr = (vm_address_t)&layout->invoke;
    vm_size_t vmsize = 0;
    mach_port_t object = 0;
    vm_region_basic_info_data_64_t info;
    mach_msg_type_number_t infoCnt = VM_REGION_BASIC_INFO_COUNT_64;
    kern_return_t ret = vm_region_64(mach_task_self(), &invoke_addr, &vmsize, VM_REGION_BASIC_INFO, (vm_region_info_t)&info, &infoCnt, &object);
    if (ret != KERN_SUCCESS) {
        NSLog(@"获取失败");
        return;
    }
    vm_prot_t protection = info.protection;
    // 判断内存是否可写
    if ((protection&VM_PROT_WRITE) == 0) {
        // 修改内存属性 ===> 可写
ret = vm_protect(mach_task_self(), invoke_addr, sizeof(invoke_addr), false, protection|VM_PROT_WRITE);
        if (ret != KERN_SUCCESS) {
NSLog(@"修改失败");
            return;
}
}
layout->invoke = (uintptr_t)block1_replace;
}


收起阅读 »

在 SwiftUI 中创建一个环形 Slider

iOS
前言 Slider 控件是一种允许用户从一系列值中选择一个值的 UI 控件。在 SwiftUI 中,它通常呈现为直线上的拇指选择器。有时将这种类型的选择器呈现为一个圆圈,拇指绕着圆周移动可能会更好。本文介绍如何在 SwiftUI 中定义一个环形的 Slider...
继续阅读 »


前言


Slider 控件是一种允许用户从一系列值中选择一个值的 UI 控件。在 SwiftUI 中,它通常呈现为直线上的拇指选择器。有时将这种类型的选择器呈现为一个圆圈,拇指绕着圆周移动可能会更好。本文介绍如何在 SwiftUI 中定义一个环形的 Slider。


初始化环形轮廓


ZStack中的三个圆环开始。一个灰色的圆环代表滑块的路径轮廓,一个淡红色的圆弧代表沿着圆环的进度,一个圆圈代表当前光标或拇指的位置。将滑块的范围设置为0.0到1.0,并硬编码一个直径和一个的当前位置进度 - 0.33。

struct CircularSliderView1: View {
let progress = 0.33
let ringDiameter = 300.0

private var rotationAngle: Angle {
return Angle(degrees: (360.0 * progress))
}

var body: some View {
VStack {
ZStack {
Circle()
.stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.9), lineWidth: 20.0)
Circle()
.trim(from: 0, to: progress)
.stroke(Color(hue: 0.0, saturation: 0.5, brightness: 0.9),
style: StrokeStyle(lineWidth: 20.0, lineCap: .round)
)
.rotationEffect(Angle(degrees: -90))
Circle()
.fill(Color.white)
.frame(width: 21, height: 21)
.offset(y: -ringDiameter / 2.0)
.rotationEffect(rotationAngle)
}
.frame(width: ringDiameter, height: ringDiameter)

Spacer()
}
.padding(80)
}
}



将进度值和拇指位置绑定


将进度变量更改为状态变量并添加默认 Slider。这个 Slider 用于修改进度值,并在圆形滑块上实现足够的代码以使拇指和进度弧响应。当前值显示在环形 Slider 的中心。

struct CircularSliderView2: View {
@State var progress = 0.33
let ringDiameter = 300.0

private var rotationAngle: Angle {
return Angle(degrees: (360.0 * progress))
}

var body: some View {
ZStack {
Color(hue: 0.58, saturation: 0.04, brightness: 1.0)
.edgesIgnoringSafeArea(.all)

VStack {
ZStack {
Circle()
.stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.9), lineWidth: 20.0)
.overlay() {
Text("\(progress, specifier: "%.1f")")
.font(.system(size: 78, weight: .bold, design:.rounded))
}
Circle()
.trim(from: 0, to: progress)
.stroke(Color(hue: 0.0, saturation: 0.5, brightness: 0.9),
style: StrokeStyle(lineWidth: 20.0, lineCap: .round)
)
.rotationEffect(Angle(degrees: -90))
Circle()
.fill(Color.white)
.shadow(radius: 3)
.frame(width: 21, height: 21)
.offset(y: -ringDiameter / 2.0)
.rotationEffect(rotationAngle)
}
.frame(width: ringDiameter, height: ringDiameter)


VStack {
Text("Progress: \(progress, specifier: "%.1f")")
Slider(value: $progress,
in: 0...1,
minimumValueLabel: Text("0.0"),
maximumValueLabel: Text("1.0")
) {}
}
.padding(.vertical, 40)

Spacer()
}
.padding(.vertical, 40)
.padding()
}
}
}


添加触摸手势


DragGesture 被添加到滑块圆圈,并且使用临时文本视图显示拖动手势的当前位置。可以看到 x 和 y 坐标围绕包含环形 Slider 的位置中心的变化情况。

struct CircularSliderView3: View {
@State var progress = 0.33
let ringDiameter = 300.0

@State var loc = CGPoint(x: 0, y: 0)

private var rotationAngle: Angle {
return Angle(degrees: (360.0 * progress))
}

private func changeAngle(location: CGPoint) {
loc = location
}

var body: some View {
ZStack {
Color(hue: 0.58, saturation: 0.04, brightness: 1.0)
.edgesIgnoringSafeArea(.all)

VStack {
ZStack {
Circle()
.stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.9), lineWidth: 20.0)
.overlay() {
Text("\(progress, specifier: "%.1f")")
.font(.system(size: 78, weight: .bold, design:.rounded))
}
Circle()
.trim(from: 0, to: progress)
.stroke(Color(hue: 0.0, saturation: 0.5, brightness: 0.9),
style: StrokeStyle(lineWidth: 20.0, lineCap: .round)
)
.rotationEffect(Angle(degrees: -90))
Circle()
.fill(Color.blue)
.shadow(radius: 3)
.frame(width: 21, height: 21)
.offset(y: -ringDiameter / 2.0)
.rotationEffect(rotationAngle)
.gesture(
DragGesture(minimumDistance: 0.0)
.onChanged() { value in
changeAngle(location: value.location)
}
)
}
.frame(width: ringDiameter, height: ringDiameter)

Spacer().frame(height:50)

Text("Location = (\(loc.x, specifier: "%.1f"), \(loc.y, specifier: "%.1f"))")

Spacer()
}
.padding(.vertical, 40)
.padding()
}
}
}


为不同的坐标值设置滑块位置


圆形滑块上有两个表示进度的值,用于显示进度弧度的progress值和用于显示滑块光标的rotationAngle。应该只有一个属性来保存滑块进度。视图被提取到一个单独的结构中,该结构具有圆形滑块上进度的一个绑定值。


滑块的range的可选参数也是可用的。这需要对进度进行一些调整,以计算已设置的角度以及拇指在圆形滑块上位置的旋转角度。另外调用onAppear根据View出现前的进度值计算旋转角度。

struct CircularSliderView: View {
@Binding var progress: Double

@State private var rotationAngle = Angle(degrees: 0)
private var minValue = 0.0
private var maxValue = 1.0

init(value progress: Binding<Double>, in bounds: ClosedRange<Int> = 0...1) {
self._progress = progress

self.minValue = Double(bounds.first ?? 0)
self.maxValue = Double(bounds.last ?? 1)
self.rotationAngle = Angle(degrees: progressFraction * 360.0)
}

private var progressFraction: Double {
return ((progress - minValue) / (maxValue - minValue))
}

private func changeAngle(location: CGPoint) {
// 为位置创建一个向量(在 iOS 上反转 y 坐标系统)
let vector = CGVector(dx: location.x, dy: -location.y)

// 计算向量的角度
let angleRadians = atan2(vector.dx, vector.dy)

// 将角度转换为 0 到 360 的范围(而不是负角度)
let positiveAngle = angleRadians < 0.0 ? angleRadians + (2.0 * .pi) : angleRadians

// 根据角度更新滑块进度值
progress = ((positiveAngle / (2.0 * .pi)) * (maxValue - minValue)) + minValue
rotationAngle = Angle(radians: positiveAngle)
}

var body: some View {
GeometryReader { gr in
let radius = (min(gr.size.width, gr.size.height) / 2.0) * 0.9
let sliderWidth = radius * 0.1

VStack(spacing:0) {
ZStack {
Circle()
.stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.9),
style: StrokeStyle(lineWidth: sliderWidth))
.overlay() {
Text("\(progress, specifier: "%.1f")")
.font(.system(size: radius * 0.7, weight: .bold, design:.rounded))
}
// 取消注释以显示刻度线
//Circle()
// .stroke(Color(hue: 0.0, saturation: 0.0, brightness: 0.6),
// style: StrokeStyle(lineWidth: sliderWidth * 0.75,
// dash: [2, (2 * .pi * radius)/24 - 2]))
// .rotationEffect(Angle(degrees: -90))
Circle()
.trim(from: 0, to: progressFraction)
.stroke(Color(hue: 0.0, saturation: 0.5, brightness: 0.9),
style: StrokeStyle(lineWidth: sliderWidth, lineCap: .round)
)
.rotationEffect(Angle(degrees: -90))
Circle()
.fill(Color.white)
.shadow(radius: (sliderWidth * 0.3))
.frame(width: sliderWidth, height: sliderWidth)
.offset(y: -radius)
.rotationEffect(rotationAngle)
.gesture(
DragGesture(minimumDistance: 0.0)
.onChanged() { value in
changeAngle(location: value.location)
}
)
}
.frame(width: radius * 2.0, height: radius * 2.0, alignment: .center)
.padding(radius * 0.1)
}

.onAppear {
self.rotationAngle = Angle(degrees: progressFraction * 360.0)
}
}
}
}


CircularSliderView 的三种不同视图被添加到View中以测试和演示 Circular Slider 视图的不同功能。

struct CircularSliderView5: View {
@State var progress1 = 0.75
@State var progress2 = 37.5
@State var progress3 = 7.5

var body: some View {
ZStack {
Color(hue: 0.58, saturation: 0.06, brightness: 1.0)
.edgesIgnoringSafeArea(.all)

VStack {
CircularSliderView(value: $progress1)
.frame(width:250, height: 250)

HStack {
CircularSliderView(value: $progress2, in: 1...10)

CircularSliderView(value: $progress3, in: 0...100)
}

Spacer()
}
.padding()
}
}
}


总结


本文展示了如何定义响应拖动手势的圆环滑块控件。可以设置滑块视图的大小,并且滑块按预期工作。可以向控件添加更多参数以设置颜色或圆环内显示的值的格式。


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

Xcode 升级到14.3以后 调试与打包遇到的坑

iOS
前言 是苹果逼的,通知说2023年4月25日之后,所有的App都要在iOS16的SDK上打包。不然也不会有那么多事情(呜呜呜🥹)。 1.Xcode 14.3版本运行项目报错 问题如下:ld: file not found: /Applications/Xcod...
继续阅读 »

前言


是苹果逼的,通知说2023年4月25日之后,所有的App都要在iOS16的SDK上打包。不然也不会有那么多事情(呜呜呜🥹)。


1.Xcode 14.3版本运行项目报错


问题如下:

ld: file not found: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/arc/libarclite_iphonesimulator.a
clang: error: linker command failed with exit code 1 (use -v to see invocation)

报错信息看,都是在链接库的时候因为找不到静态库(libarclite_iphonesimulator.a/libarclite_iphoneos.a)而报错。利用访达的前往文件夹功能快速来到报错信息中的目录,发现连 arc目录都不存在,更不用说静态库文件。


开发人员解释说,因为系统已经内置有 ARC相关的库,所以没必要再额外链接,至少Xcode 14支持的最低部署目标iOS 11及以上版本的系统肯定是没问题的。如果应用部署目标不低于iOS 11还出现问题,那么应该是第三方库的部署目标有问题。


所以解决方案也很清晰了,将所有依赖库和应用最低部署版本都限制在iOS11以上即可。


解决方案:

post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0'
end
end
end

2. 升级Xcode14以后项目报错 Stored properties cannot be marked potentially unavailable with '@available'


这是依赖库报错,把其中一个库升级到了最新的版本,不报错了。但是还有一个库没办法升级,因为我们的项目是Flutter项目,不知道是哪个三方库的依赖库,百度了好久没找到办法,最后还是强大的Google到方法:


在iOS目录下:

执行pod install
然后再执行pod update

最终可以了


3. Xcode升级到14.3 archieve打包失败

mkdir -p /Users/hsf/Library/Developer/Xcode/DerivedData/Ehospital-crirdmppgluxkodauexhkenjuxet/Build/Intermediates.noindex/ArchiveIntermediates/Ehospital/BuildProductsPath/Release-iphoneos/复旦云病理.app/Frameworks
Symlinked...
rsync --delete -av --filter P .*.?????? --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" --filter "- Headers" --filter "- PrivateHeaders" --filter "- Modules" "../../../IntermediateBuildFilesPath/UninstalledProducts/iphoneos/AliyunOSSiOS.framework" "/Users/hsf/Library/Developer/Xcode/DerivedData/Ehospital-crirdmppgluxkodauexhkenjuxet/Build/Intermediates.noindex/ArchiveIntermediates/Ehospital/InstallationBuildProductsLocation/Applications/复旦云病理.app/Frameworks"
building file list ... rsync: link_stat "/Users/hsf/Desktop/medical/app/iOS/Ehospital/../../../IntermediateBuildFilesPath/UninstalledProducts/iphoneos/AliyunOSSiOS.framework" failed: No such file or directory (2)
done

sent 29 bytes received 20 bytes 98.00 bytes/sec
total size is 0 speedup is 0.00
rsync error: some files could not be transferred (code 23) at /AppleInternal/Library/BuildRoots/97f6331a-ba75-11ed-a4bc-863efbbaf80d/Library/Caches/com.apple.xbs/Sources/rsync/rsync/main.c(996) [sender=2.6.9]
Command PhaseScriptExecution failed with a nonzero exit code

找到...-frameworks.sh 文件,替换

source="$(readlink "${source}")"

source="$(readlink -f "${source}")"

4. The version of CocoaPods used to generate the lockfile (1.3.1) is higher than the version of the current executable (1.1.0.beta.1). Incompatibility issues may arise.


这个比较简单,更新cocoapods就行。

sudo gem install cocoapods

5. Warning: CocoaPods minimum required version 1.6.0 or greater not installed…

sudo gem install cocoapods

6. Cocoapods 更新卡死在1.5.3,但控制台一直提示说有新版本


主要就是ruby的问题了。别问我怎么知道的,花了一天的时间。

ruby -v 查看版本

若比较低,现在一般都3.x了,所以要升级


用以下命令就可以升级了,可能需要科学上网。

brew update
brew install ruby

升级完成以后,ruby -v后其实还是原来的版本👌,这是因为环境变量没有配置。因此,还有一个步骤就是配置环境变量。

vi ~/.zshrc 

拷贝 export PATH="/usr/local/opt/ruby/bin:$PATH" 放进去


英文输入法下 按下esc键 输入 :wq


最后再执行

source ~/.bash_profile

然后更新gem

gem update #更新所有包
gem update --system #更新RubyGems软件

最后再更新pod

sudo gem install cocoapods

注意现在可能会提示说更新到了1.12.1了,但实际上还是1.5.3,所以还要执行另外一个命令。

sudo gem install -n /usr/local/bin cocoapods

这个就可以有效升级了。


7. gem常用命令

gem -v #gem版本
gem update #更新所有包
gem update --system #更新RubyGems软件
gem install rake #安装rake,从本地或远程服务器
gem install rake --remote #安装rake,从远程服务器
gem install watir -v(或者--version) 1.6.2#指定安装版本的
gem uninstall rake #卸载rake包
gem list d #列出本地以d打头的包
gem query -n ''[0-9]'' --local #查找本地含有数字的包
gem search log --both #从本地和远程服务器上查找含有log字符串的包
gem search log --remoter #只从远程服务器上查找含有log字符串的包
gem search -r log #只从远程服务器上查找含有log字符串的包
gem help #提醒式的帮助
gem help install #列出install命令 帮助
gem help examples #列出gem命令使用一些例子
gem build rake.gemspec #把rake.gemspec编译成rake.gem
gem check -v pkg/rake-0.4.0.gem #检测rake是否有效
gem cleanup #清除所有包旧版本,保留最新版本
gem contents rake #显示rake包中所包含的文件
gem dependency rails -v 0.10.1 #列出与rails相互依赖的包
gem environment #查看gem的环境

结语


有些坑现在只是知道这样做就行,还不知道为什么。后面再补补吧。


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

iOS-解决定位权限卡顿问题

iOS
一、简介 在iOS系统中,定位权限获取是一个涉及进程间同步通信的方法,如果频繁访问可能会导致卡顿或者卡死。在一些打车或者地图类的APP中,定位权限的卡顿报错可能是大头,亟需解决! 下面是系统类提供的访问定位权限的方法:// CLLocationManager是...
继续阅读 »

一、简介


在iOS系统中,定位权限获取是一个涉及进程间同步通信的方法,如果频繁访问可能会导致卡顿或者卡死。在一些打车或者地图类的APP中,定位权限的卡顿报错可能是大头,亟需解决!
下面是系统类提供的访问定位权限的方法:

// CLLocationManager是系统的定位服务管理类
open class CLLocationManager : NSObject {
// 1.下面方法是访问系统设置中定位是否打开
@available(iOS 4.0, *)
open class func locationServicesEnabled() -> Bool

// 2.1 iOS 14.0之后,访问定位的授权状态
@available(iOS 14.0, *)
open var authorizationStatus: CLAuthorizationStatus { get }

// 2.2 iOS 14.0之后,访问定位的授权状态
@available(iOS, introduced: 4.2, deprecated: 14.0)
open class func authorizationStatus() -> CLAuthorizationStatus
}

二、从卡顿堆栈例子中分析问题


为了解决这个卡顿,首先要分析卡顿报错堆栈。接下来举一个定位权限频繁获取导致的卡顿的堆栈:

0 libsystem_kernel.dylib _mach_msg2_trap + 8
1 libsystem_kernel.dylib _mach_msg2_internal + 80
2 libsystem_kernel.dylib _mach_msg_overwrite + 388
3 libsystem_kernel.dylib _mach_msg + 24
4 libdispatch.dylib __dispatch_mach_send_and_wait_for_reply + 540
5 libdispatch.dylib _dispatch_mach_send_with_result_and_wait_for_reply + 60
6 libxpc.dylib _xpc_connection_send_message_with_reply_sync + 240
7 Foundation ___NSXPCCONNECTION_IS_WAITING_FOR_A_SYNCHRONOUS_REPLY__ + 16
8 Foundation -[NSXPCConnection _sendInvocation:orArguments:count:methodSignature:selector:withProxy:] + 2236
9 Foundation -[NSXPCConnection _sendSelector:withProxy:arg1:arg2:arg3:] + 136
10 Foundation __NSXPCDistantObjectSimpleMessageSend3 + 76
11 CoreLocation _CLCopyTechnologiesInUse + 30852
12 CoreLocation _CLCopyTechnologiesInUse + 25724
13 CoreLocation _CLClientStopVehicleHeadingUpdates + 104440
14 MyAPPName +[ZLLocationRecorder locationAuthorised] + 40
15 ... // 以下略
  • 首先从第14行找到是ZLLocationRecorder类的locationAuthorised方法调用后,执行到了系统库函数,最终导致了卡死、卡顿。

  • 对堆栈中第0-13行中的方法做一番了解,初步发现xpc_connection_send_message_with_reply_sync函数涉及进程间同步通信,可能会阻塞当前线程点击查看官方方法说明



该函数说明:Sends a message over the connection and blocks the caller until it receives a reply.

  • 接下来添加符号断点xpc_connection_send_message_with_reply_sync, 注意如果是系统库中的带下划线的函数,我们添加符号断点的时候一般需要少一个下划线_. 执行后,从Xcode的方法调用栈视图中查看,可以发现ZLLocationRecorder类的locationAuthorised方法内部中调用CLLocationManager类的locationServicesEnabledauthorizationStatus方法都会来到这个符号断点.所以确定了是这两个方法导致的卡顿。(调试时并未发现卡顿,只是线上用户的使用环境更加复杂,卡顿时间长一点就被监控到了,我们目前卡顿监控是3秒,卡死监控是10s+)。

  • 然后通过CLLocationManager类的authorizationStatus方法说明,发现也是说在权限发生改变后,系统会保证调用代理方法locationManagerDidChangeAuthorization(_:),所以就产生了我们的解决方案,最终上线后也是直接解决了这个卡顿,并且APP启动耗时监控数据也因此变好了一些。


三、具体的解决方案



 注意点:设置代理必须在有runloop的线程,如果业务量不多的话,就在主线程设置就可以。


四、Demo类,可以直接用

import CoreLocation

public class XLLocationAuthMonitor: NSObject, CLLocationManagerDelegate {
// 单例类
@objc public static let shared = XLLocationAuthMonitor()

/// 定位服务是否可用, 这里设置成变量避免过于频繁调用系统方法时产生卡顿,系统方法涉及进程间通信
@objc public private(set) var serviceEnabled: Bool {
set {
threadSafe { _serviceEnabled = newValue }
}

get {
threadSafe { _serviceEnabled ?? CLLocationManager.locationServicesEnabled() }
}
}

/// 定位服务授权状态
@objc public private(set) var authStatus: CLAuthorizationStatus {
set {
threadSafe { _authStatus = newValue }
}

get {
threadSafe {
if let auth = _authStatus {
return auth
}
if #available(iOS 14.0, *) {
return locationManager.authorizationStatus
} else {
return CLLocationManager.authorizationStatus()
}
}
}
}

/// 计算属性,这里返回当前定位是否可用
@objc public var isLocationEnable: Bool {
guard serviceEnabled else {
return false
}

switch authStatus {
case .authorizedAlways, .authorizedWhenInUse:
return true
case .denied, .notDetermined, .restricted:
return false
default: return false
}
}

// MARK: - 内部使用的私有属性
private lazy var locationManager: CLLocationManager = CLLocationManager()
private let _lock = NSLock()
private var _serviceEnabled: Bool?
private var _authStatus: CLAuthorizationStatus?

private override init() {
super.init()
// 如果是主线程则直接设置,不是则在mainQueue中设置
DispatchQueue.main.safeAsync {
self.locationManager.delegate = self
}
}

private func threadSafe<T>(task: () -> T) -> T {
_lock.lock()
defer { _lock.unlock() }
return task()
}

// MARK: - CLLocationManagerDelegate
/// iOS 14以上调用
public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if #available(iOS 14.0, *) {
authStatus = locationManager.authorizationStatus
serviceEnabled = CLLocationManager.locationServicesEnabled()
}
}

/// iOS 14以下调用
public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
authStatus = status
serviceEnabled = CLLocationManager.locationServicesEnabled()
}
}

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

鸿蒙原生应用,全面启动,开发者需要抓住风口的浪尖

iOS
前言 老铁们,就在前天,9月25日,在华为秋季全场景新品发布会上,华为常务董事、终端BG CEO、智能汽车解决方案BU董事长余承东介绍了鸿蒙系统的最新进展:HarmonyOS 4发布后,短短一个多月升级用户已经超过6000万,成为史上升级速...
继续阅读 »

前言


老铁们,就在前天,9月25日,在华为秋季全场景新品发布会上,华为常务董事、终端BG CEO、智能汽车解决方案BU董事长余承东介绍了鸿蒙系统的最新进展:HarmonyOS 4发布后,短短一个多月升级用户已经超过6000万,成为史上升级速度最快的HarmonyOS版本;余承东宣布,鸿蒙原生应用全面启动,HarmonyOS NEXT开发者预览版将在2024年第一季度面向开发者开放。



我们知道,在8月4日的华为开发者大会,华为才刚刚推出了面向开发者的 HarmonyOS NEXT 开发者预览版,如果说当时只是一个概念,那么这次,绝对是正式官宣,打响移动端第三系统的第一枪!我们有理由且必须相信,HarmonyOS NEXT开发者预览版正在急速到来,不仅仅是对手机系统的冲击、移动端的开发者也有着不小的冲击。


如果说当时刚推出,你踌躇徘徊,犹豫不定,对待HarmonyOS犹如对待外来物一样,极度的排斥,那么这次你绝对忽视不得,否则,你将错过一个时代的步伐。


短短一个多月升级用户已经超过6千万,足以打脸那些看弱HarmonyOS的人,也从另一方面说明,HarmonyOS已经得到越来越多人的喜爱,或许是有了不断攀升的用户量,才让华为手机有了信心去发展原生系统应用,并且此前有爆料数据显示鸿蒙OS5.0会取消支持安卓软件,这种爆料绝非空穴来风,可能很多人包括我在内,会觉得取消支持Android软件,是一件非常冒险的行为,但是随着华为手机的体量越来越大,生态越来越好,这个事情是必须且迟早的要做的。


鸿蒙是否有必要学习


可能鸿蒙从一诞生,就背着一个”套壳“的骂名,毕竟一直都兼容AOSP(Android 开放源代码项目),很难不令人怀疑,当然了,曾经我也有所怀疑,以至于,对于HarmonyOS保持的态度,始终都是,冷漠,不感冒,毕竟Android开发的包,在HarmonyOS上也能用,我们何必再去研究它呢?费力又费时间,还不如刷刷短视频,对吧。


但是,一旦HarmonyOS剥离AOSP,Android开发的包无法在其运行,这种情况下,身为移动端的开发者,特别是Android端的开发者,你觉得有没有必要学习?


试想这样的一个场景,当其他的应用都能在HarmonyOS上运行,而你的应用确不支持,你是什么感觉?当然了,也得问一句,你们公司是什么感觉?虽然说目前HarmonyOS国内市场占有率为8%,占有率并不是很多,但止不住它发展迅速啊,未来,20%,50%都有可能,即便是8%,这样的一个市场,你和你的公司难道会果断的放弃?如果放弃的话,确实没必要学,但是,能放弃吗?


再试想一个场景,随着HarmonyOS不断的发展,移动端三分天下,而企业考虑到成本问题,在招聘的时候,要求了必须要会HarmonyOS开发,你如何破解这个问题?


无论是自身发展还是当下的企业布局,HarmonyOS都是你躲不过的一道屏障,无非就是什么时间入手的问题,当然了,如果一个企业或者个人,对HarmonyOS,没什么业务发展,也不在乎这些市场份额,那就没必要学习,反过来,真的要静下心来,好好研究研究了,否则影响的不仅仅是一个应用,更是大量的用户流失。


可能很多人都会觉得,HarmonyOS剥离AOSP,这么冒险的事,华为大概率不会那么武断,即便升级,可能也会采取双系统并行,也就是HarmonyOS4.0 和HarmonyOS Next,继续兼容Android一段时间,当然了,不排除这种做法,我想说的是,这也只是一个广大的猜测,在其他大厂APP都跟进的情况下,如果它升级了,怎么办?哪怕概率为1%,对企业和个人的影响绝对是100%,话又说回来,它采取了双系统并行或者有其他的兼容方案,你觉得华为会一直兼容吗,所以啊,如果你想继续从事这个行业,学只不过是早晚的问题


所以啊,HarmonyOS,肯定是要学的,除非你要告别当前从事的移动端开发,如果再做一层针对性的,那就是告别Android端开发,毕竟和iOS端的冲突目前还没那么大。


不仅要学,而且还要提前进行技术储备,目的防患于未然;毕竟来年的事,谁也说不定,有条件的公司,技术储备之后,就可以复刻鸿蒙版App了,尽量赶上升级后的第一批App,这样就可以做到无缝衔接,不至于鸿蒙系统流失用户,当然了,也可以只做技术储备,隔岸观火,进一步观察HarmonyOS的下一步动作,但是,技术储备一定要做,无论来年华为升级与否,因为复刻鸿蒙版App,不是一朝一夕能够完成的,起码目前来看,还没有一件转化的功能,只能从0到1的进行开发,小体量的App还好说,大体量的App,从0到1没个半年以上还真完成不了,所以啊,哪怕华为宣布来年不强制升级,到2025年升级,留给开发者的时间还多吗?


HarmonyOS的学习路径有很多,官网也给出了详细的视频以及文档教程,大家可以直接学习即可,当然了大家也可以关注我,哈哈,我也会定时分享HarmonyOS相关的技术,目前在有序的输出。


鸿蒙未来的发展


根据华为最新公布的数据:目前鸿蒙生态设备已达7亿台,早就跨过了“生死线”;鸿蒙品牌知名度从2021年的50%升级至今年6月的85%,越来越多的用户知晓和主动拥抱HarmonyOS;HarmonyOS 3用户升级率达到85%,超过了iOS(81%)成为最新版本设备升级率最高的操作系统,而HarmonyOS 4发布后,短短一个多月升级用户已经超过6000万,可以说是,恐怖如斯,遥遥领先!


目前华为已与合作伙伴和开发者在社交、影音、游戏、资讯、金融等18个领域全面展开合作,在HarmonyOS独特的全场景分布式体验、原生智能、纯净安全、大模型AI交互等方面,HarmonyOS NEXT构筑了差异化优势,全面领先于行业。


为了更好帮助合作伙伴成长,在HDC 2023期间,华为正式发布鸿蒙生态伙伴发展计划——“鸿飞计划”,宣布未来三年将投入百亿人民币,向伙伴提供全方位的资源扶持,包括技术支持、市场推广、商业合作等,让每一位伙伴都成为鸿蒙生态的主角。


无论是企业的绝对支持,还是政府的大力推进,HarmonyOS的发展,可以说势如破竹,三分天下,也就是时间的问题。


我们都知道,操作系统生态的发展,人才是重中之重。随着鸿蒙生态的发展,专业人才需求正在呈现井喷式增长,为此,在鸿蒙人才培养方面,华为也做了全面投入,今年以来已有超过170万人参加了鸿蒙学堂的课程学习、线下活动,华为还和全国300多所高校展开了合作,鸿蒙产学合作项目超过140个,已经颁发鸿蒙学堂证书超过7万,各类开发者活动累计参加人次超过350万。


可以告诉大家的是,俺也是其中一员,哈哈~,当然了,证书并没有含金量,只是一个阶段学习的测试而已。



除此之外,近期教育部-华为“智能基座”产教融合协同育人基地2.0启动,未来双方将与72所高校合作培养鸿蒙人才,一起促进鸿蒙生态的繁荣发展。


我们总担忧鸿蒙的生态,对它不屑一顾,说它“套壳”,说它抄袭,说它迟早会死,可是,人家不吭不响,不反驳,只会默默的耕耘,以至于发展的越来越好,越来越完善,为什么鸿蒙这么自信,我们却不自信呢?我们在担忧什么?


鸿蒙的生态离不开每一个的开发者,我们有理由相信,未来的时刻,它肯定会剑指Android和iOS,我们更有理由相信,国产系统的繁荣富强,一定会到来,民族的自信心也必定到来!


鸿蒙不仅仅是一个系统,它是更长远的国家战略


国家战略说的有点大了,但是肯定是在计划之内和大力支持的,为什么这么说,从18年的中美贸易战,到22年的俄乌冲突,卡脖子的事,发生的还少吗?动不动进行制裁,动不动限制出口,美国佬龌龊的事做的还少吗?如果说一直没有自研,那么话语权始终掌握在别人手里,不仅仅是一个系统,像芯片等等,我们始终很难强大。


俄乌冲突期间,谷歌公司停止认证运行安卓操作系统的俄罗斯BQ公司的智能手机,微软宣布禁止在俄罗斯使用Windows系统,也许对于我们个人而言,觉得没什么影响,但是站在国家层面,绝对是致命的打击,如果未来,收复TW时,也来这么一下,你觉得,国家能承受的住吗?


除了各种限制和制裁之外,俄乌冲突期间最恐怖的是,谷歌地图服务提供俄罗斯所有军事和战略设施的最高分辨率卫星图像,这不就等于明牌了,你在明处,人家在暗处,所以,无论是系统,还是芯片,还是其他的技术方向,站在国家层面上,能够自研,无论是摆脱外部限制,还是自身科技发展,绝对都是划时代的意义。


所以,老铁们,对于鸿蒙,于国于人,我们都应该有充足的自信,不仅仅关系着手机系统的三分天下,更是国家安全的未来措施,政策,一定是某项事物发展的导向,跟着国家走,准没错。


开发者如何提前布局


我觉得应该从三方面入手,第一,就是技术储备,学习HarmonyOS,能够达到独立的完成项目开发;第二,就是,技术架构,组件,基础库的梳理和开发,这么做的目的,是便于日后项目的快速开发;第三,就是着手自己项目HarmonyOS版的开发了,以应对未来HarmonyOS升级。


未来是否有一键转化HarmonyOS版App的功能,这个一切未知,有的话,就太方便了,没有的话,只能从0到1进行开发了,当然了,跨平台语言的支持,也是一个突破点,比如Flutter支持HarmonyOS,那么对于原来Flutter语言的App而言,就无比轻松了,而目前来说,这些都没有一个实质性的进展,所以还是一步一步的先学习HarmonyOS开发吧。


还好,HarmonyOS主推的是ArkTs语言,其中也定义了声明式UI,和Flutter,Compose,Swift等有着异曲同工之妙,如果你有着声明式开发的经验,那么掌握HarmonyOS简直是易如反掌。


当然了,为了更好的提高开发效率,HarmonyOS采用了反推的做法,推出了自己的跨平台框架ArkUI-X,成熟之后,我们可以作为开发框架,进而兼容Android和iOS。


ArkUI-X 是 ArkUI 的跨平台框架,采用 ArkUI 开发的应用能在 HarmonyOS 上原生运行,获得极佳的性能,通过 ArkUI-X 能够在 Android 和 IOS 上跨平台运行,获得强于 Flutter、React Native 等同类竞品的性能。



总结


该说的也都说了,不该说的也说了,至于HarmonyOS,您是学习还是放弃,只能由自己决断了,可以肯定得是,您的放弃,一定是未来的错误决定。


番外


写文章的时候,电脑上老是有一种刺啦刺啦声音,这个声音很小,听的不是很清楚,一开始我总以为是敲击键盘的声音,当我凑近一听,一种熟悉的声音扑面而来:遥遥领先,遥遥领先~


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

UIButton 扩大点击区域

iOS
在开发过程中经常会遇到设计给出的button尺寸偏小的情况.这种UIButton在使用中会非常难点击,极大降低了用户体验 解决方案一:重写UIButton的- (BOOL)pointInside:(CGPoint)point withEvent:(UIEven...
继续阅读 »

在开发过程中经常会遇到设计给出的button尺寸偏小的情况.这种UIButton在使用中会非常难点击,极大降低了用户体验


解决方案一:重写UIButton的- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event方法

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

{

//获取当前button的实际大小
CGRect bounds = self.bounds;

//若原热区小于44x44,则放大热区,否则保持原大小不变

CGFloat widthDelta = MAX(44.0 - bounds.size.width, 0);

CGFloat heightDelta = MAX(44.0 - bounds.size.height, 0);
//扩大bounds

bounds = CGRectInset(bounds, -0.5 * widthDelta, -0.5 * heightDelta);

//如果点击的点 在 新的bounds里,就返回YES

return CGRectContainsPoint(bounds, point);

}

系统默认写法是:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
return CGRectContainsPoint(self.bounds, point);
}

其实是在判断的时候对响应区域的bounds进行了修改.CGRectInset(view, 10, 20)方法表示对rect大小进行修改


解决方案二 runtime关联对象来改变范围,- (UIView) hitTest:(CGPoint) point withEvent:(UIEvent) event里用新设定的 Rect 来当着点击范围。

#import "UIButton+EnlargeTouchArea.h"
#import <objc/runtime.h>

@implementation UIButton (EnlargeTouchArea)

static char topNameKey;
static char rightNameKey;
static char bottomNameKey;
static char leftNameKey;

- (void)setEnlargeEdgeWithTop:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left
{
objc_setAssociatedObject(self, &topNameKey, [NSNumber numberWithFloat:top], OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(self, &rightNameKey, [NSNumber numberWithFloat:right], OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(self, &bottomNameKey, [NSNumber numberWithFloat:bottom], OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(self, &leftNameKey, [NSNumber numberWithFloat:left], OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (void)setTouchAreaToSize:(CGSize)size
{
CGFloat top = 0, right = 0, bottom = 0, left = 0;

if (size.width > self.frame.size.width) {
left = right = (size.width - self.frame.size.width) / 2;
}

if (size.height > self.frame.size.height) {
top = bottom = (size.height - self.frame.size.height) / 2;
}

[self setEnlargeEdgeWithTop:top right:right bottom:bottom left:left];
}

- (CGRect)enlargedRect
{
NSNumber *topEdge = objc_getAssociatedObject(self, &topNameKey);
NSNumber *rightEdge = objc_getAssociatedObject(self, &rightNameKey);
NSNumber *bottomEdge = objc_getAssociatedObject(self, &bottomNameKey);
NSNumber *leftEdge = objc_getAssociatedObject(self, &leftNameKey);
if (topEdge && rightEdge && bottomEdge && leftEdge)
{
return CGRectMake(self.bounds.origin.x - leftEdge.floatValue,
self.bounds.origin.y - topEdge.floatValue,
self.bounds.size.width + leftEdge.floatValue + rightEdge.floatValue,
self.bounds.size.height + topEdge.floatValue + bottomEdge.floatValue);
}
else
{
return self.bounds;
}
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
CGRect rect = [self enlargedRect];
if (CGRectEqualToRect(rect, self.bounds) || self.hidden)
{
return [super hitTest:point withEvent:event];
}
return CGRectContainsPoint(rect, point) ? self : nil;
}

@end


解决方案三:使用runtime swizzle交换IMP

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSError *error = nil;
[self hg_swizzleMethod:@selector(pointInside:withEvent:) withMethod:@selector(hitTest_pointInside:withEvent:) error:&error];
NSAssert(!error, @"UIView+HitTest.h swizzling failed: error = %@", error);
});
}

- (BOOL)hitTest_pointInside:(CGPoint)point withEvent:(UIEvent *)event {
if (UIEdgeInsetsEqualToEdgeInsets(self.hitTestEdgeInsets, UIEdgeInsetsZero)) {
return [self hitTest_pointInside:point withEvent:event];
}
CGRect relativeFrame = self.bounds;
CGRect hitFrame = UIEdgeInsetsInsetRect(relativeFrame, self.hitTestEdgeInsets);
return CGRectContainsPoint(hitFrame, point);
}



category的诞生只是为了让开发者更加方便的去拓展一个类,它的初衷并不是让你去改变一个类。



技术点总结


关联对象,也就是绑定对象,可以绑定任何东西

//关联对象
objc_setAssociatedObject(self, &topNameKey, [NSNumber numberWithFloat:top], OBJC_ASSOCIATION_COPY_NONATOMIC);
// self 关联的类,
//key:要保证全局唯一,key与关联的对象是一一对应关系。必须全局唯一
//value:要关联类的对象。
//policy:关联策略。有五种关联策略。
//OBJC_ASSOCIATION_ASSIGN 等价于 @property(assign)。
//OBJC_ASSOCIATION_RETAIN_NONATOMIC等价于 @property(strong, //nonatomic)。
//OBJC_ASSOCIATION_COPY_NONATOMIC等价于@property(copy, nonatomic)。
//OBJC_ASSOCIATION_RETAIN等价于@property(strong,atomic)。
//OBJC_ASSOCIATION_COPY等价于@property(copy, atomic)。

NSNumber *topEdge = objc_getAssociatedObject(self, &topNameKey);

// 方法说明
objc_setAssociatedObject 相当于 setValue:forKey 进行关联value对象

objc_getAssociatedObject 用来读取对象

objc_AssociationPolicy 属性 是设定该value在object内的属性,即 assgin, (retain,nonatomic)...等

objc_removeAssociatedObjects 函数来移除一个关联对象,或者使用objc_setAssociatedObject函数将key指定的关联对象设置为nil。

方法交换 Method Swizzling 注意点


对于已经存在的类,我们通常会在+load方法,或者无法获取到类文件,我们创建一个分类,也通过其+load方法进行加载swizzling


  • Swizzling应该总在+load中执行
  • Swizzling应该总是在dispatch_once中执行
  • Swizzling在+load中执行时,不要调用[super load]。如果多次调用了[super load],可能会出现“Swizzle无效”的假象。

交换实例方法


以class为类

void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL)
{
//class_getInstanceMethod(),如果子类没有实现相应的方法,则会返回父类的方法。
Method originMethod = class_getInstanceMethod(class, originalSEL);
Method replaceMethod = class_getInstanceMethod(class, replacementSEL);

//class_addMethod() 判断originalSEL是否在子类中实现,如果只是继承了父类的方法,没有重写,那么直接调用method_exchangeImplementations,则会交换父类中的方法和当前的实现方法。此时如果用父类调用originalSEL,因为方法已经与子类中调换,所以父类中找不到相应的实现,会抛出异常unrecognized selector.
//当class_addMethod() 返回YES时,说明子类未实现此方法(根据SEL判断),此时class_addMethod会添加(名字为originalSEL,实现为replaceMethod)的方法。此时在将replacementSEL的实现替换为originMethod的实现即可。
//当class_addMethod() 返回NO时,说明子类中有该实现方法,此时直接调用method_exchangeImplementations交换两个方法的实现即可。
//注:如果在子类中实现此方法了,即使只是单纯的调用super,一样算重写了父类的方法,所以class_addMethod() 会返回NO。

//可用BaseClass实验
if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod)))
{
class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod));
}else {
method_exchangeImplementations(originMethod, replaceMethod);
}
}


这里存在的问题是继承时子类没有实现父类方法的问题:
基类A类 有方法 -(void)test
子类B类继承自基类A,但没有重写test方法,即其类[B class]中没有test这个实例方法
当我们交换子类B中的方法test,交换为testRelease方法(这必然会在子类B中写testRelease的实现),子类B中有没有test方法的实现时,就会将基类A的test方法与testRelease替换,当仅仅使用子类B时,不会有问题。
但当我们使用基类A的test方法时,由于test指向的IMP是原testRelease的IMP,而基类A中没有这个实现,因为我们是写在子类B中的。所以就出现了unrecognized selector



交换类方法


由于类方法存储在元类中,以实例方法存在,所以实质就是交换元类的实例方法
上面交换实例方法基础上,传入cls为元类即可。
获取的元类可以这样objc_getMetaClass("ClassName")或者object_getclass([NSObject class])


事件响应者链


如图所示,不再赘述



 两个重要的方法

- (nullable UIView*)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;称为方法A

- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;称为方法B

对view进行重写这两个方法后,点击屏幕后,首先响应的是方法A;

  • 如果方法A中,我们没有调用父类([super hitTest:point withEvent:event];)的这个方法,那就根据这个方法A的返回view,作为响应事件的view。(当然返回nil,就是这个view不响应)

  • 如果方法A中,我们调用了父类的方法([super hitTest:point withEvent:event];)那这个时候系统就要调用方法B;通过这个方法的返回值,来判断当前这个view能不能响应消息

  • 如果方法B返回的是no,那就不用再去遍历它的子视图。方法A返回的view就是可以响应事件的view。

  • 如果方法B返回的是YES,那就去遍历它的子视图。(就是上图我们描述的那样,找到合适的view返回,如果找不到,那就由方法A返回的view去响应这个事件。)


总结


返回一个view来响应事件 (如果不想影响系统的事件传递链,在这个方法内,最好调用父类的这个方法)

- (nullable UIView*)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event{
    return [super hitTest:point withEvent:event];
}

返回的值可以用来判断是否继续遍历子视图(返回的根据是触摸的point是否在view的frame范围内)

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

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

SF Symbols 4 使用指南

iOS
本文基于 WWDC 2022 Session 10157 和 Session 10158 梳理,为了更方便没有 SF Symbols 经验的读者理解,也将往年的 SF Symbols 相关内容一并整理。本文从 SF Symbols 4 的新特性切入,讨论 SF...
继续阅读 »

本文基于 WWDC 2022 Session 10157Session 10158 梳理,为了更方便没有 SF Symbols 经验的读者理解,也将往年的 SF Symbols 相关内容一并整理。本文从 SF Symbols 4 的新特性切入,讨论 SF Symbols 这款由系统字体支持的符号库有哪些优点以及该如何使用。在这次 WWDC 2022 中,除了符号的数量的增加到了 4000+ 之外,还有自动渲染模式、可变符号等新特性推出,让 SF Symbols 这把利器变得又更加趁手和锋利了。




本文是 WWDC22 内参 的供稿。



什么是 SF Symbols


符号在界面中起着非常重要的作用,它们能有效地传达意义,它们可以表明你选择了哪些项目,它们可以用来从视觉上区分不同类型的内容,他们还可以节约空间、整洁界面,而且符号出现在整个视觉系统的各处,这使整个用户界面营造了一种熟悉的感觉。


符号的实现和使用方式多种多样,但设计和使用符号时有一个亘古不变的问题,那就是将符号与用户界面的另一个基本元素——「文本」很好地配合。符号和文字在用户界面中以各种不同的大小被使用,他们之间的排列形式、对齐方式、符号颜色、文本字重与符号粗细的协调、本地化配置以及无障碍设计都需要开发者和设计师来细心配置和协调。




为了方便开发者更便捷、轻松地使用符号,Apple 在 iOS 13 中开始引入他们自己设计的海量高质量符号,称之为 SF Symbols。SF Symbols 拥有超过 4000 个符号,是一个图标库,旨在与 Apple 平台的系统字体 San Francisco 无缝集成。每个符号有 9 种字重和 3 种比例,以及四种渲染模式,它们的默认设计都与文本标签对齐,同时这些符号是矢量的,这意味着它们是可以被拉伸的,使得他们在无论用什么大小时都会呈现出很好的效果。如果你想去创造具有相似设计特征或无障碍功能的自定义符号,它们也可以被导出并在矢量图形编辑工具中进行编辑以创建新的符号。


对于开发者来说,这套 SF Symbols 无论是在 UIKit,AppKit 还是 SwiftUI 中都能运作良好,且使用方式也很简单方便,寥寥数行代码就可以实现。对于设计师来说,你只需要为符号只做三个字重的版本,SF Symbols 会自动地帮你生成其余 9 种字重和 3 种比例的符号,然后在 SF Symbols 4 App 中调整四种渲染模式的表现,就制作好了一份可以高度定制化的 symbol。




如何使用 SF Symbols


SF Symbols 4 App


在开始介绍如何使用 SF Symbols 之前,我们可以先下载来自 Apple 官方的 SF Symbols 4 App,这款 App 中收录了所有的 SF Symbols,并且记录了每个符号的名称,支持的渲染模式,可变符号的分层预览,不同语言下的变体,不同版本下可能出现的不同的名称,并且可以实时预览不同渲染模式下不同强调色的不同效果。你可以在这里下载 SF Symbols 4 App。




符号的渲染模式


通过之前的图片你可能已经注意到了,SF Symbols 可以拥有多种颜色,有一些 symbol 还有预设的配色,例如代表天气、肺部、电池的符号等等。如果要使用这些带有自定义颜色的符号,你需要知道,SF Symbols 在逻辑上是预先分层的(如下图的温度计符号就分为三层),根据每一层的路径,我们可以根据渲染模式来调整颜色,而每个 SF Symbols 有四种渲染模式。




单色模式 Monochrome


在 iOS 15 / macOS 11 之前,单色模式是唯一的渲染模式,顾名思义,单色模式会让符号有一个单一的颜色。要设置单色模式的符号,我们只需要设置视图的 tint color 等属性就可以完成。

let image = UIImage(systemName: "thermometer.sun.fill")
imageView.image = image
imageView.tintColor = .systemBlue

// SwiftUI
Image(systemName: "thermometer.sun.fill")
.foregroundStyle(.blue)

分层模式 Hierarchical


每个符号都是预先分层的,如下图所示,符号按顺序最多分成三个层级:Primary,Secondary,Tertiary。SF Symbols 的分层设定不仅在分层模式下有效,在后文别的渲染模式下也是有作用的




分层模式和单色模式一样,可以设置一个颜色。但是分层模式会以该颜色为基础,生成降低主颜色的不透明度而衍生出来的其他颜色(如上上图中的温度计符号看起来是由三种灰色组合而成)。在这个模式中,层级结构很重要,如果缺少一个层级,相关的派生颜色将不会被使用。

let image = UIImage(systemName: "thermometer.sun.fill")
let config = UIImage.SymbolConfiguration(hierarchicalColor: .lightGray)
imageView.image = image
imageView.preferredSymbolConfiguration = config

// SwiftUI
Image(systemName: "thermometer.sun.fill")
.foregroundStyle(.gray)
.symbolRenderingMode(.hierarchical)

调色盘模式 Palette


调色盘模式和分层模式很像,但也有些许不同。和分层模式一样是,调色盘模式也会对符号的各个层级进行上色,而不同的是,调色盘模式允许你自由的分别设置各个层级的颜色。

let image = UIImage(systemName: "thermometer.sun.fill")
let config = UIImage.SymbolConfiguration(paletteColors: [.lightGray, .cyan, .systemTeal])
imageView.image = image
imageView.preferredSymbolConfiguration = config

// SwiftUI
Image(systemName: "thermometer.sun.fill")
.foregroundStyle(.lightGray, .cyan, .teal)

多色模式 Muticolor


在 SF Symbols 中,有许多符号的意象在现实生活中已经深入人心,比如:太阳应该是橙色的,警告应该是黄色的,叶子应该是绿色的的等等。所以 SF Symbols 也提供了与现实世界色彩相契合的颜色模式:多色渲染模式。当你使用多色模式的时候,就能看到预设的橙色太阳符号,红色的闹铃符号,而你不需要指定任何颜色。

let image = UIImage(systemName: "thermometer.sun.fill")
imageView.image = image
imageView.preferredSymbolConfiguration = .preferringMulticolor()

// SwiftUI
Image(systemName: "thermometer.sun.fill")
.symbolRenderingMode(.multicolor)

自动渲染模式 Automatic


谈论完了四种渲染模式,可以发现每次设置 symbol 的渲染模式其实也是一件费心的事情。为了解决这个问题,在最新的 SF Symbols 中,每个 symbol 都有了一个自动渲染模式。例如下图的 shareplay 符号,你可以看到在右侧面板中,shareplay 符号的第二个模式(分层模式)的下方有一个空心小圆点,这意味着该符号在代码中使用时,假如你不去特意配置他的渲染模式,那么他将使用分层模式作为他的默认渲染模式。



你可以在 SF Symbols 4 App 中查询到所有符号的自动渲染模式。





可变颜色


在有的时候,符号并不单单代表一个单独的概念或者意象,他也可以代表一些数值、比例或者程度,例如 Wi-Fi 强度或者铃声音量,为了解决这个问题,SF Symbols 引入了可变颜色这个概念。


你可以在 SF Symbol 4 App 中的 Variable 目录中找到所有有可变颜色的符号,平且可以通过右侧面板的滑块来查看不同百分比程度下可变颜色的形态。另外你也可以注意到,可变颜色的可变部分实际上也是一种分层的表现,但这里的分层和上文提到的渲染模式使用的分层是不同的。一个符号可以在渲染模式中只分两层,在可变颜色的分层中分为三层,下图中第二个符号喇叭 speaker.wave.3.fill 就是如此。关于这里的分层我们会在后文如何制作可变颜色中详细讨论。




在代码中,我们只需要在初始化 symbol 时增加一个 Double 类型的 variableValue 参数,就可以实现可变颜色在不同程度下的不同形态。值得注意的是,假如你的可变颜色(例如上图 Wi-Fi 符号)可变部分有三层,那么这个 variableValue 的判定将会三等分:在 0% 时将不高亮信号,在 0%~33% 时,将高亮一格信号,在 34%~67 % 时,将高亮 2 格信号,在 68% 以上时,将会显示满格信号。

let img = NSImage(symbolName: "wifi", variableValue: 0.2)

可变颜色的可变部分是利用不透明度来实现的,当可变颜色和不同的渲染模式结合后,也会有很好的效果。




如何制作和调整可变颜色


在 SF Symbols 4 App 中,我们可以自定义或者调整可变颜色的表现,接下来我将带着大家以 party.popper 这个符号为基础制作一个带可变颜色的符号。

  1. 首先我们打开 SF Symbols 4 App,在右上角搜索 party.popper,找到该符号后右键选择 复制为1个自定符号。推荐你在上方将符号的排列方式修改为画廊模式,如下图所示。


  2. 可以注意到右下角的  这个板块,这个符号默认是由两个层级组成的,分别是礼花和礼花筒,同时我们也可以看到,礼花和礼花筒又分别是由更零碎的路径组成的,通过勾选子路径我们可以给每个层新增或者减少路径。那我现在想要给这个符号新增一层,我只需要在画廊模式下,将符号的某一部分拖拽到层里就可以。


  3. 通过这样的操作,我们可以将这个符号整理为四层:礼花筒、线条礼花、小球礼花和大球礼花。为了可变颜色的效果,我们需要按照从下到上:礼花筒、线条礼花、大球礼花和小球礼花的顺序去放置层级,另外,我们可以切换到分层模式、调色板模式和多色模式里面去调整成自己喜欢的颜色来预览效果,我这里调整了多色模式中的配色,具体效果如下。


  4. 接下来,我们将前三层,也就是除了礼花筒外的三层,最右侧的可变符号按钮选中,来表示这三层将可以在可变符号的变化范围内活动。接下来,只要点击颜色区域内的可变符号按钮,我们就可以拖动滑块来查看可变颜色的形态。


  5. 至此,我们就完成了一个带可变颜色的自定义符号,我们可以在合适的地方使用这个符号。例如我的 App 有一个 4 个步骤的新手引导,这时候就可以给每一个步骤配备一个符号来让界面变得更加的活泼。


统一注释 Unified annotations


其实我们已经接触到了 Unified annotations 这个过程,它就是将符号的层级,路径以及子路径整理成在四个渲染模式下都能良好工作的过程,就如同上文彩色礼花筒的例子,我们通过统一注释,让彩色礼花筒符号在不同渲染模式、不同环境色、不同主题色下,都能良好的运作。


那一般来说,对于单色模式,不需要过多的调整,它就能保持良好的形态;对于分层模式和调色盘模式,我们需要在给每个层设定好哪个是 Primary 层、哪个是 Secondanry 层以及哪个是 Tertiary 层,这样系统就会按优先级给符号上合适的颜色;对于多色模式,我们可以根据喜好以及符号的意义,给它预设一个合理的颜色,另外还要注意的是,如果设计了可变颜色在符号中,那么要注意保持可变符号的效果在四个渲染模式上都表现正常。


除了这些之外,还有一些特别的地方需要注意,我们以 custom.heart.circle.fill 为例子。你可以注意到,这个垃爱心符号是有一个圆形的背景的,在这种情况下,假如我们按照原来的规则去绘制单色模式,会发现:背景的圆形和爱心的图案将会是同一个颜色,那我们就将看不见圆形背景下的图案了。




这时我们可以使用 Unified annotations 给我们提供的新功能,我们将上图在 板块的爱心,将它从 Draw 改成 Erase,这样,我们就相当于以爱心的形状镂空了这个白色的背景,从而使该图形展现了出来并且在单色模式下能够一直表现正常。同理,在分层模式和调色盘模式中,也有这个 Erase 的功能共大家调整使用。


字重和比例


SF Symbols 和 Apple 平台的系统字体 San Francisco 一样,拥有九种字重和三种比例可以选择,这意味着每个 SF Symbol 都有 27 种样式以供使用。

let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold, scale: .large)
imageView.preferredSymbolConfiguration = config

// SwiftUI
Label("Heart", systemImage: "heart")
.imageScale(.large)
.font(.system(size: 20, weight: .semibold))

符号的字重和文本的字重原理相同,都是通过加粗线条来增加字重。但 SF Symbols 的三种比例尺寸并不是单纯的对符号进行缩放。如果你仔细观察,会发现对于同一个字重,但是不同比例的符号来说,他们线条的粗细是一样的,但是对符号的整体进行了扩充和延展,以应对不一样的使用环境。


要实现这样的效果,意味着每个 symbol 的底层逻辑并不是一张张图片,而是由一组组的路径构成,这也是为什么在当你想要自定义一个属于自己的 symbol 的时候,官方要求你用封闭路径 + 填充效果去完成一个符号,而不是使用一条简单路径 + 路径描边(stroke)来完成一个符号。



更多关于如何制作一个 symbol 的内容,请移步 WWDC 21 内参:定制属于你的 Symbols





除了字重和比例之外,SF Symbols 还在很多方面进行了努力来方便开发者的工作,例如:符号的变体、不同语言下符号的本地化、符号的无障碍化等,关于这些内容,以及其它由于篇幅原因未在本文讨论的细节问题,请移步 WWDC 21 内参:SF Symbols 使用指南


总结


从上文介绍 SF Symbols 的特性和优点我们可以看到,它的出现是为了解决符号与文本之间的协调性问题,在保证了本地化、无障碍化的基础上,Apple 一直在实用性、易用度以及多样性上面给 SF Symbols 加码,目前已经有了 4000+ 的符号可以使用,相信在未来还会有更多。这些符号的样式和图案目前看来并不是那么的广泛,这些有限的符号样式并不能让设计师安心代替所有界面上的符号,但是有失必有得,在这样一个高度统一的平台上,SF Symbols 在规范化、统一化、表现能力、代码与设计上的简易程度,在今年都又进一步的提升了,达到了让人惊艳的程度,随着 SF Symbols 的继续发展,我相信对于部分开发者来说,即将成为一个最优的符号工具🥳。


更多资料


以下是这几年关于 SF Symbols 的资料:



以下是更早的 SF Symbols 资料:



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

用 Metal 画一个三角形(Swift 函数式风格)

iOS
由于今年工作中用得语言换成 Rust/OCaml/ReScript 啦,所以导致我现在写代码更倾向于写函数式风格的代码。 顺便试试 Swift 在函数式方面能达到啥好玩的程度。主要是我不会 Swift,仅仅为了好玩。 创建工程 随便创建个工程,小玩具就不打算跑...
继续阅读 »

由于今年工作中用得语言换成 Rust/OCaml/ReScript 啦,所以导致我现在写代码更倾向于写函数式风格的代码。

顺便试试 Swift 在函数式方面能达到啥好玩的程度。主要是我不会 Swift,仅仅为了好玩。


创建工程


随便创建个工程,小玩具就不打算跑在手机上了,因为我的设备是 ARM 芯片的,所以直接创建个 Mac 项目,记得勾上包含测试。


构建 MTKView 子类


现在来创建个 MTKView 的子类,其实我现在已经不接受这种所谓的面向对象,开发者用这种方式,就要写太多篇幅来描述一个上下文结构跟函数就能实现的动作。

import MetalKit

class MetalView: MTKView {
required init(coder: NSCoder) {
super.init(coder: coder)
device = MTLCreateSystemDefaultDevice()
render()
}
}

extension MetalView {
func render() {
// TODO: 具体实现
}
}

我们这里给 MetalView extension 了一个 render 函数,里面是后续要写得具体实现。


普通的方式画一个三角形


先用常见的方式来画一个三角形

class MetalView: MTKView {
required init(coder: NSCoder) {
super.init(coder: coder)
device = MTLCreateSystemDefaultDevice()
render()
}
}

extension MetalView {
func render() {
guard let device = device else { fatalError("Failed to find default device.") }
let vertexData: [Float] = [
-1.0, -1.0, 0.0, 1.0,
1.0, -1.0, 0.0, 1.0,
0.0, 1.0, 0.0, 1.0
]

let dataSize = vertexData.count * MemoryLayout<Float>.size
let vertexBuffer = device.makeBuffer(bytes: vertexData, length: dataSize, options: [])
let library = device.makeDefaultLibrary()
let renderPassDesc = MTLRenderPassDescriptor()
let renderPipelineDesc = MTLRenderPipelineDescriptor()
if let currentDrawable = currentDrawable, let library = library {
renderPassDesc.colorAttachments[0].texture = currentDrawable.texture
renderPassDesc.colorAttachments[0].clearColor = MTLClearColor(red: 0.0, green: 0.5, blue: 0.5, alpha: 1.0)
renderPassDesc.colorAttachments[0].loadAction = .clear
renderPipelineDesc.vertexFunction = library.makeFunction(name: "vertexFn")
renderPipelineDesc.fragmentFunction = library.makeFunction(name: "fragmentFn")
renderPipelineDesc.colorAttachments[0].pixelFormat = .bgra8Unorm
let commandQueue = device.makeCommandQueue()
guard let commandQueue = commandQueue else { fatalError("Failed to make command queue.") }
let commandBuffer = commandQueue.makeCommandBuffer()
guard let commandBuffer = commandBuffer else { fatalError("Failed to make command buffer.") }
let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDesc)
guard let encoder = encoder else { fatalError("Failed to make render command encoder.") }
if let renderPipelineState = try? device.makeRenderPipelineState(descriptor: renderPipelineDesc) {
encoder.setRenderPipelineState(renderPipelineState)
encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1)
encoder.endEncoding()
commandBuffer.present(currentDrawable)
commandBuffer.commit()
}
}
}
}

然后是我们需要注册的 Shader 两个函数

#include <metal_stdlib>

using namespace metal;

struct Vertex {
float4 position [[position]];
};

vertex Vertex vertexFn(constant Vertex *vertices [[buffer(0)]], uint vid [[vertex_id]]) {
return vertices[vid];
}

fragment float4 fragmentFn(Vertex vert [[stage_in]]) {
return float4(0.7, 1, 1, 1);
}

在运行之前需要把 StoryBoard 控制器上的 View 改成我们写得这个 MTKView 的子类。




自定义操作符


函数式当然不是指可以定义操作符,但是没有这些操作符,感觉没有魂灵,所以先定义个管道符


代码实现

precedencegroup SingleForwardPipe {
associativity: left
higherThan: BitwiseShiftPrecedence
}

infix operator |> : SingleForwardPipe

func |> <T, U>(_ value: T, _ fn: ((T) -> U)) -> U {
fn(value)
}

测试管道符


因为创建项目的时候,勾上了 include Tests,直接写点测试代码,执行测试。

final class using_metalTests: XCTestCase {
// ...

func testPipeOperator() throws {
let add = { (a: Int) in
return { (b: Int) in
return a + b
}
}
assert(10 |> add(11) == 21)
let doSth = { 10 }
assert(() |> doSth == 10)
}
}

目前随便写个测试通过嘞。


Functional Programming


现在需要把上面的逻辑分割成小函数,事实上,因为 Cocoa 的基础是建立在面向对象上的,我们还是没法完全摆脱面向对象,目前先小范围应用它。


生成 MTLBuffer


先理一下逻辑,代码开始是创建顶点数据,生成 buffer

fileprivate let makeBuffer = { (device: MTLDevice) in
let vertexData: [Float] = [
-1.0, -1.0, 0.0, 1.0,
1.0, -1.0, 0.0, 1.0,
0.0, 1.0, 0.0, 1.0
]

let dataSize = vertexData.count * MemoryLayout<Float>.size
return device.makeBuffer(bytes: vertexData, length: dataSize, options: [])
}

创建 MTLLibrary


接着是创建 MTLLibrary 来注册两个 shader 方法,还创建了一个 MTLRenderPipelineDescriptor 对象用于创建 MTLRenderPipelineState,但是创建的 MTLLibrary 对象是一个 Optional 的,所以其实得有两步,总之先提取它再说吧

fileprivate let makeLib = { (device: MTLDevice) in device.makeDefaultLibrary() }

抽象 map 函数


根据我们有限的函数式编程经验,像 Optional 这种对象大概率有一个 map 函数,所以我们自家实现一个,同时还要写成柯里化的(建议自动柯里化语法糖入常),因为这里有逃逸闭包,所以要加上 @escaping

func map<T, U>(_ transform: @escaping (T) throws -> U) rethrows -> (T?) -> U? {
return { (o: T?) in
return try? o.map(transform)
}
}

处理 MTLRenderPipelineState


这里最终目的就是 new 了一个 MTLRenderPipelineState,顺带处理把程序的一些上下文给渲染管线描述器(MTLRenderPipelineDescriptor),譬如我们用到的着色器(Shader)函数,像素格式。
最后一行直接 try! 不处理错误啦,反正出问题直接会抛出来的

fileprivate let makeState = { (device: MTLDevice) in
return { (lib: MTLLibrary) in
let renderPipelineDesc = MTLRenderPipelineDescriptor()
renderPipelineDesc.vertexFunction = lib.makeFunction(name: "vertexFn")
renderPipelineDesc.fragmentFunction = lib.makeFunction(name: "fragmentFn")
renderPipelineDesc.colorAttachments[0].pixelFormat = .bgra8Unorm
return (try! device.makeRenderPipelineState(descriptor: renderPipelineDesc))
}
}

暂时收尾


已经不想再抽取函数啦,其实还能更细粒度地处理,因为函数式有个纯函数跟副作用的概念,像 Haskell 里是可以用 Monad 来处理副作用的情况,这个主题留给后续吧。先把 render 改造一下

fileprivate let render = { (device: MTLDevice, currentDrawable: CAMetalDrawable?) in
return { state in
let renderPassDesc = MTLRenderPassDescriptor()
if let currentDrawable = currentDrawable {
renderPassDesc.colorAttachments[0].texture = currentDrawable.texture
renderPassDesc.colorAttachments[0].clearColor = MTLClearColor(red: 0.0, green: 0.5, blue: 0.5, alpha: 1.0)
renderPassDesc.colorAttachments[0].loadAction = .clear
let commandQueue = device.makeCommandQueue()
guard let commandQueue = commandQueue else { fatalError("Failed to make command queue.") }
let commandBuffer = commandQueue.makeCommandBuffer()
guard let commandBuffer = commandBuffer else { fatalError("Failed to make command buffer.") }
let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDesc)
guard let encoder = encoder else { fatalError("Failed to make render command encoder.") }
encoder.setRenderPipelineState(state)
encoder.setVertexBuffer(device |> makeBuffer, offset: 0, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1)
encoder.endEncoding()
commandBuffer.present(currentDrawable)
commandBuffer.commit()
}
}
}

然后再调用,于是就变成下面这副鸟样子

class MetalView: MTKView {
required init(coder: NSCoder) {
super.init(coder: coder)
device = MTLCreateSystemDefaultDevice()
device |> map {
makeLib($0)
|> map(makeState($0))
|> map(render($0, self.currentDrawable))
}
}
}

最后执行出这种效果




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

展开&收起,使用SwiftUI搭建一个侧滑展开页面交互

iOS
项目背景 闲来无事,在使用某云音乐听歌的时候发现一个侧滑展开的内页,交互效果还不错。 那么这一章节中,我们将使用SwiftUI搭建一个侧边展开页面交互。 项目搭建 首先,创建一个新的SwiftUI项目,命名为SlideOutMenu。 逻辑分析 首先我们来分...
继续阅读 »

项目背景


闲来无事,在使用某云音乐听歌的时候发现一个侧滑展开的内页,交互效果还不错。


那么这一章节中,我们将使用SwiftUI搭建一个侧边展开页面交互。


项目搭建


首先,创建一个新的SwiftUI项目,命名为SlideOutMenu




逻辑分析


首先我们来分析下基本的逻辑,一般的侧滑展开方式的交互是,在首页右上角有一个“更多”的按钮,点击按钮时,内页菜单从左往右划出,滑出至离右边20~30的位置停止。


然后首页背景将蒙上一个蒙层,点击蒙层时,侧滑展开的页面从右往左收起


简单分析完逻辑后,我们来实现这个交互。


首页入口


首先,我们需要在首页搭建一个入口,示例:

// 顶部导航入口
private var moreBtnView: some View {
    Button(action: {
    }) {
        Image(systemName: "list.bullet")
            .foregroundColor(.black)
    }
}

然后,我们可以使用NavigationViewnavigationBarItems创建顶部导航按钮样式,示例:

var body: some View {
    NavigationView {
        Text("点击左上角侧滑展开")
            .padding()
            .navigationBarTitle("首页", displayMode: .inline)
            .navigationBarItems(leading: moreBtnView)
    }
}



如此,首页入口部分我们就完成了。


左边菜单


接下来,我们来构建左侧菜单的内容。我们可以沿用之前设计过的“设置”页面的结构,我们先来构建栏目结构。示例:

// MARK: 栏目结构
struct listItemView: View {
    var itemImage: String
    var itemName: String
    var body: some View {
        Button(action: {
        }) {
            HStack {
                Image(systemName: itemImage)
                    .font(.system(size: 17))
                    .foregroundColor(.black)
                Text(itemName)
                    .foregroundColor(.black)
                    .font(.system(size: 17))
                Spacer()
                Image(systemName: "chevron.forward")
                    .font(.system(size: 14))
                    .foregroundColor(.gray)
            }.padding(.vertical, 10)
        }
    }
}

在我们构建侧滑展开的页面前,我们需要声明两个变量,一个是侧滑展开的页面的宽度,一个是当前这个页面的位置。示例:

@State var menuWidth = UIScreen.main.bounds.width - 60
@State var offsetX = -UIScreen.main.bounds.width + 60

我们设置的侧滑展开页面的宽度是屏幕宽度-60,而当前侧滑展开页面的位置是负位置,这样就可以在展示的时候先把页面隐藏起来


而当我们点击顶部导航中的“更多”按钮时,将offsetX偏移量X轴坐标设置为0。示例:

// 顶部导航入口
private var moreBtnView: some View {
    Button(action: {
        withAnimation {
            offsetX = 0
        }
    }) {
        Image(systemName: "list.bullet")
            .foregroundColor(.black)
    }
}

然后,我们创建一个新视图来构建侧滑展开的页面内容,示例:

// MARK: 左侧菜单
struct SlideOutMenu: View {
    @Binding var menuWidth: CGFloat
    @Binding var offsetX: CGFloat

    var body: some View {
        Form {
            Section {
            }
            Section {
                listItemView(itemImage: "lock", itemName: "账号绑定")
                listItemView(itemImage: "gear.circle", itemName: "通用设置")
                listItemView(itemImage: "briefcase", itemName: "简历管理")
            }
            Section {
                listItemView(itemImage: "icloud.and.arrow.down", itemName: "版本更新")
                listItemView(itemImage: "leaf", itemName: "清理缓存")
                listItemView(itemImage: "person", itemName: "关于掘金")
            }
        }
        .padding(.trailing, UIScreen.main.bounds.width - menuWidth)
        .edgesIgnoringSafeArea(.all)
        .shadow(color: Color.black.opacity(offsetX != 0 ? 0.1 : 0), radius: 5, x: 5, y: 0)
        .offset(x: offsetX)
        .background(
            Color.black.opacity(offsetX == 0 ? 0.5 : 0)
                .ignoresSafeArea(.all, edges: .vertical)
                .onTapGesture {
                    withAnimation {
                        offsetX = -menuWidth
                    }
                })
    }
}

上述代码中,我们也对页面宽度menuWidth、偏移位置offsetX进行了声明,方便之后我们在ContentView视图中进行双向绑定


我么使用Form表单和Section段落构建样式,这点就不说了。


值得说的一点是,我们设置了在页面展开的时候,也就是offsetX页面偏移量X轴坐标不为0,我们加了一个阴影,完善了侧滑展开页面的悬浮效果


然后使用offset调整页面初始位置。背景部分,除了根据offsetX页面偏移量X轴坐标加了一个蒙层,而且当我们点击的背景的时候,我们将偏移位置offsetX重新赋值,这样就能实现收起的交互效果。


我们在ContentView视图中展示侧滑展开视图,示例:

var body: some View {
    ZStack {
        NavigationView {
            Text("点击左上角侧滑展开")
                .padding()
                .navigationBarTitle("首页", displayMode: .inline)
                .navigationBarItems(leading: moreBtnView)
        }
        SlideOutMenu(menuWidth: $menuWidth, offsetX: $offsetX)
    }
}

项目展示




恭喜你,完成了本章的全部内容!


快来动手试试吧。


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

利用 UICollectionView 实现图片浏览效果

iOS
废话开篇:利用 UICollectionView 简单实现一个图片浏览效果。 一、效果展示 二、实现思路 1、封装 UICollectionViewLayout ,实现内部 UICollectionViewCell 的布局。 UICollectionView...
继续阅读 »

废话开篇:利用 UICollectionView 简单实现一个图片浏览效果。


一、效果展示




二、实现思路


1、封装 UICollectionViewLayout ,实现内部 UICollectionViewCell 的布局。

UICollectionViewLayout 在封装瀑布流的时候会用到,而且担负着核心功能的实现。其实从另一个角度也可以把 UICollectionViewLayout 理解成“数据源”,这个数据不是 UI 的展示项,而是 UI 的尺寸项。在内部进行预计算 UICollectionViewCellframe


UICollectionViewUIScrollView的子类,只不过,它里面子控件通过“重用”机制实现了优化,一些复用的复杂逻辑还是扔给了系统处理。开发过程中只负责对 UICollectionViewLayout 什么时候需要干什么进行自定义即可。


2、获取 UICollectionView 目前可见的 cells,通过进行缩放、旋转变换实现一些简单的效果。

3、自定义 cell ,修改锚点属性。

三、代码整理


1、PhotoBrowseViewLayout

这里有一点需要注意的,在 UICollectionViewLayout 内部会进行计算每一个 cellframe,在计算过程中,为了更好的展示旋转变换,cell 的锚点会修改到 (0.5,1),那么,为了保证 UI 展示不变,那么,就需要将 y 增加 cell 高度的一半

#import "PhotoBrowseViewLayout.h"

@interface PhotoBrowseViewLayout()

@property(nonatomic,strong) NSMutableArray * attributeArray;

@property(nonatomic,assign) CGFloat cellWidth;

@property(nonatomic,assign) CGFloat cellHeight;

@property(nonatomic,assign) CGFloat sep;

@property(nonatomic,assign) int showCellNum;


@end

@implementation PhotoBrowseViewLayout

- (instancetype)init
{
    if (self = [super init]) {
        self.sep = 20;
        self.showCellNum = 2;
    }
    return self;
}

//计算cell的frame
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
    if (self.cellWidth == 0) {
        self.cellWidth = **self**.collectionView.frame.size.width * 2 / 3.0;
    }
    if (self.cellHeight == 0) {
        self.cellHeight = self.collectionView.frame.size.height;
    }
    CGFloat x = (self.cellWidth + self.sep) * indexPath.item;
//这里y值需要进行如此设置,以抵抗cell修改锚点导致的UI错乱
    CGFloat y = self.collectionView.frame.size.height / 2.0;
    UICollectionViewLayoutAttributes *attrs = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];

    attrs.frame = CGRectMake(x, y, self.cellWidth, self.cellHeight);
    return attrs;
}

//准备布局
- (void)prepareLayout
{
    [super prepareLayout];
    NSInteger count = [self.collectionView numberOfItemsInSection:0];
    for (int i = 0; i <count; i++) {
        UICollectionViewLayoutAttributes *attris = [self layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForRow:i inSection:0]];
        [self.attributeArray addObject:attris];
    }
}

//返回全部cell的布局集合
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
{
    return self.attributeArray;
}

//一次性提供UICollectionView 的 contentSize
- (CGSize)collectionViewContentSize
{
    NSInteger count = [self.collectionView numberOfItemsInSection:0];
    CGFloat maxWidth = count * self.cellWidth + (count - 1) * self.sep;
    return CGSizeMake(maxWidth, 0);
}

- (NSMutableArray *)attributeArray
{

    if (!_attributeArray) {
        _attributeArray = [[NSMutableArray alloc] init];
    }
    return _attributeArray;
}

@end

2、PhotoBrowseCollectionViewCell

这里主要是进行了锚点修改(0.5,1),代码很简单。

#import "PhotoBrowseCollectionViewCell.h"

@interface PhotoBrowseCollectionViewCell()

@property(nonatomic,strong) UIImageView * imageView;

@end

@implementation PhotoBrowseCollectionViewCell


- (instancetype)initWithFrame:(CGRect)frame
{
    if (self = [super initWithFrame:frame]) {
//设置(0.5,1)锚点,以底部中点为轴旋转
        self.layer.anchorPoint = CGPointMake(0.5, 1);
        self.layer.masksToBounds = YES;
        self.layer.cornerRadius = 8;
    }
    return self;
}

- (void)setImage:(UIImage *)image
{
    self.imageView.image = image;
}


- (UIImageView *)imageView
{

    if (!_imageView) {
        _imageView = [[UIImageView alloc] init];
        _imageView.contentMode = UIViewContentModeScaleAspectFill;
        _imageView.backgroundColor = [UIColor groupTableViewBackgroundColor];
        [self.contentView addSubview:_imageView];
    }
    return _imageView;
}

- (void)layoutSubviews
{
    [super layoutSubviews];
    self.imageView.frame = **self**.contentView.bounds;
}

@end

3、CollectPhotoBrowseView

CollectPhotoBrowseView 负责进行一些 cell 的图形变换。

#import "CollectPhotoBrowseView.h"
#import "PhotoBrowseCollectionViewCell.h"
#import "PhotoBrowseViewLayout.h"

@interface CollectPhotoBrowseView()<UICollectionViewDelegate,UICollectionViewDataSource>

@property(nonatomic,strong) UICollectionView * photoCollectView;

@end

@implementation CollectPhotoBrowseView

- (instancetype)initWithFrame:(CGRect)frame
{
    if (self = [super initWithFrame:frame]) {
        [self makeUI];
    }
    return self;
}

- (void)makeUI{
//设置自定义 UICollectionViewLayout
    PhotoBrowseViewLayout * photoBrowseViewLayout = [[PhotoBrowseViewLayout alloc] init];
    self.photoCollectView = [[UICollectionView alloc] initWithFrame:self.bounds collectionViewLayout:photoBrowseViewLayout];
    self.photoCollectView.delegate = self;
    self.photoCollectView.dataSource = self;
    [self.photoCollectView registerClass:[PhotoBrowseCollectionViewCell class] forCellWithReuseIdentifier:@"CELL"];
    self.photoCollectView.showsHorizontalScrollIndicator = NO;
    [self addSubview:self.photoCollectView];
//执行一次可见cell的图形变换
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [self visibleCellTransform];
    });
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return 20;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    PhotoBrowseCollectionViewCell * cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"CELL" forIndexPath:indexPath];
    [cell setImage: [UIImage imageNamed:[NSString stringWithFormat:@"fd%ld",indexPath.item % 3 + 1]]];
    return cell;
}

#pragma mark - 滚动进行图形变换
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
//滑动的时候,动态进行cell图形变换
    [self visibleCellTransform];
}

#pragma mark - 图形变化
- (void)visibleCellTransform
{
//获取当前可见cell的indexPath集合
    NSArray * visibleItems =  [self.photoCollectView indexPathsForVisibleItems];
//遍历动态进行图形变换
    for (NSIndexPath * visibleIndexPath in visibleItems) {
        UICollectionViewCell * visibleCell = [self.photoCollectView cellForItemAtIndexPath:visibleIndexPath];
        [self transformRotateWithView:visibleCell];
    }
}

//进行图形转换
- (void)transformRotateWithView:(UICollectionViewCell *)cell
{
//获取cell在当前视图的位置
    CGRect rect = [cell convertRect:cell.bounds toView:self];
//计算当前cell中轴线与中轴线的距离的比值
    float present = ((CGRectGetMidX(rect) - self.center.x) / (self.frame.size.width / 2.0));
//根据位置设置选择角度
    CGFloat radian = (M_PI_2 / 15) * present;
//图形角度变换
    CGAffineTransform transformRotate = CGAffineTransformIdentity;
    transformRotate = CGAffineTransformRotate(transformRotate, radian);
//图形缩放变换
    CGAffineTransform transformScale = CGAffineTransformIdentity
    transformScale = CGAffineTransformScale(transformScale,1 -  0.2 *  fabs(present),1 - 0.2 * fabsf(present));
//合并变换
    cell.transform = CGAffineTransformConcat(transformRotate,transformScale);
}

@end

四、总结与思考


UICollectionView 也是 View,只不过系统为了更好的服务于开发者,快速高效的实现某些开发场景,进行了封装与优化,将复杂的逻辑单独的封装成一个管理类,这里就是 UICollectionViewLayout,交给它去做一些固定且复杂的逻辑。所以,自定义复杂UI的时候,就需要将功能模块足够细化,以实现更好的代码衔接。代码拙劣,大神勿笑[抱拳][抱拳][抱拳]


作者:头疼脑胀的代码搬运工
链接:https://juejin.cn/post/7119028552263008293
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Swift中的可选项Optional

iOS
为什么需要Optional Swift中引入了可选项(Optional)的概念是为了解决在代码中对于某些变量或常量可能为nil的情况进行处理,从而减少了程序中的不确定性,使得程序更加稳定和安全。 什么是Optional 在Swift中,可选项的类型是使用?来表...
继续阅读 »

为什么需要Optional


Swift中引入了可选项(Optional)的概念是为了解决在代码中对于某些变量或常量可能为nil的情况进行处理,从而减少了程序中的不确定性,使得程序更加稳定和安全。


什么是Optional


在Swift中,可选项的类型是使用?来表示的,例如String?即为一个可选的字符串类型,表示这个变量或常量可能为nil。而对于不可选项,则直接使用相应类型的名称,例如String表示一个非可选的字符串类型。

var str: String = nil
var str1: String? = nil

Optional实现原理


Optional实际上是Swift语言中的一种枚举类型。在Swift中声明Optional类型时,编译器会自动将其转换成对应的枚举类型,例如:

var optionalValue: Int? = 10
// 等价于:
enum Optional<Int> {
    case none
    case some(Int)
}
var optionalValue: Optional<Int> = .some(10)

在上面的代码中,我们声明了一个Optional类型的变量optionalValue,并将其初始化为10。实际上,编译器会自动将其转换为对应的枚举类型,即Optional枚举类型的.some(Int),其中的Int就是我们所声明的可选类型的关联值。


当我们在使用Optional类型的变量时,可以通过判断其枚举值是.none还是.some来确定它是否为nil。如果是.none,表示该Optional值为空;如果是.some,就可以通过访问其关联值获取具体的数值。


Optional的源码实现为:

@frozen public enum Optional<Wrapped> : ExpressibleByNilLiteral {
case none
case some(Wrapped)
}

  • Optioanl其实是标准库里的一个enum类型
  • 用标准库实现语言特性的典型
  • Optional.none 就是nil
  • Optional.some 就是包装了实际的值
  • 泛型属性 unsafelyUnwrapped
  • 理论上我们可以直接调用unsafelyUnwrapped获取可选项的值

Optional的解包方式


1. 可选项绑定(Optional Binding)


使用 if let 或者 guard let 语句来判断 Optional 变量是否有值,如果有值则解包,并将其赋值给一个非可选类型的变量。

var optionalValue: Int? = 10
// 可选项绑定
if let value = optionalValue {
    print("Optional value is \(value)")
} else {
    print("Optional value is nil")
}

可选项绑定语句有两个分支:if分支和else分支。如果 optionalValue 有值,if 分支就会被执行,unwrappedValue 就会被赋值为 optionalValue 的值。否则,执行 else 分支。


2. 强制解包(Forced Unwrapping)


使用!来获取一个不存在的可选值会导致运行错误,在使用!强制展开之前必须保证可选项中包含一个非nil的值

var optionalValue: Int? = 10
let nonOptionalValue = optionalValue!  // 解包optionalValue值
print(nonOptionalValue)                // 输出:10

需要注意的是,如果 Optional 类型的值为 nil,使用强制解包方式解包时,会导致运行时错误 (Runtime Error)。


3. 隐式解包(Implicitly Unwrapped Optionals)


在定义 Optional 类型变量时使用 ! 操作符,标明该变量可以被隐式解包。用于在一些情况下,我们可以确定该 Optional 变量绑定后不会为 nil,可以快捷的解包而不用每次都使用 ! 或者 if let 进行解包。

var optionalValue: Int! = 10
let nonOptionalValue = optionalValue // 隐式解包
print(nonOptionalValue) // 输出:10

需要注意的是,隐式解包的 Optional 如果 nil 的话,会导致 runtime error,所以使用隐式解包 Optional 需要确保其一直有值,否则还是需要检查其非 nil 后再操作。


总的来说,我们应该尽量避免使用强制解包,而是通过可选项绑定来处理 Optional 类型的值,在需要使用隐式解包的情况下,也要确保其可靠性和稳定性,尽量减少出现运行时错误的概率。


可选链(Optional Chaining)


是一种在 Optional 类型值上进行操作的方式,可以将多个 Optional 值的处理放在一起,并在任何一个 Optional 值为 nil 的时刻停止处理。


通过在 Optional 类型值后面跟上问号 ?,我们就可以使用可选链来访问该 Optional 对象的属性和方法。

class Person {
    var name: String
    var father: Person?
    init(name: String, father: Person?) {
        self.name = name
        self.father = father
    }
}
let father = Person(name: "Father", father: nil)
let son = Person(name: "Son", father: father)

// 可选链调用属性
if let fatherName = son.father?.name {
    print("Father's name is \(fatherName)") // 输出:Father's name is Father
} else {
    print("Son without father")
}

// 可选链调用方法
if let count = son.father?.name.count {
    print("Father's name has \(count) characters") // 输出:Father's name has 6 characters
} else {
    print("Son without father")
}

在上面的代码中,我们定义了一个 Person 类,并初始化了一个包含父亲(father)的儿子(son)对象。其中,父亲对象的father属性为nil。我们使用问号 ? 来标记 father 对象为 Optional 类型,以避免访问 nil 对象时的运行时错误。


需要注意的是,如果一个 Optional 类型的属性通过可选链调用后,返回值不是 Optional 类型,那么在可选链调用后,就不再需要加问号 ? 标记其为 Optional 类型了。

class Person {
    var name: String
    var age: Int?
    init(name: String, age: Int?) {
        self.name = name
        self.age = age
    }
    func printInfo() {
        print("\(name), \(age ?? 0) years old")
    }
}
let person = Person(name: "Tom", age: nil)

// 可选链调用方法后,返回值不再是 Optional 类型
let succeed = person.printInfo() // 输出:Tom, 0 years old

在上面的代码中,我们定义了一个 Person 类,并初始化了一个包含年龄(age)的人(person)对象。在可选链调用对象的方法——printInfo() 方法后,因为该方法返回值不是 Optional 类型,所以 returnedValue 就不再需要加问号 ? 标记其为 Optional 类型了。


Optional 的嵌套


将一个 Optional 类型的值作为另一个 Optional 类型的值的成员,形成嵌套的 Optional 类型。

var optionalValue: Int? = 10
var nestedOptionalValue: Int?? = optionalValue

在上面的代码中,我们定义了一个 Optional 类型的变量 optionalValue,并将其赋值为整型变量 10。然后,我们将 optionalValue 赋值给了另一个 Optional 类型的变量 nestedOptionalValue,形成了一个嵌套的 Optional 类型。


在处理嵌套的 Optional 类型时,我们需要特别小心,因为它们的使用很容易造成逻辑上的混淆和错误。为了解决这个问题,我们可以使用 Optional Binding 或者 ?? 操作符(空合并运算符)来降低 Optional 嵌套的复杂度。

var optionalValue: Int? = 10
var nestedOptionalValue: Int?? = optionalValue

// 双重可选项绑定
if let nestedValue = nestedOptionalValue, let value = nestedValue {
    print(value) // 输出:10
} else {
    print("Optional is nil")
}
// 空合并运算符
let nonOptionalValue = nestedOptionalValue ?? 0
print(nonOptionalValue) // 输出:Optional(10)

在上面的代码中,我们使用了双重可选项绑定来判断 nestedOptionalValue 是否可绑定,以及其嵌套的 Optional 值是否可绑定,并将该值赋值给变量 value,以避免 Optional 值的嵌套。另外,我们还可以使用 ?? 操作符(空合并运算符)来对嵌套的 Optional 值进行默认取值的操作。


需要注意的是,虽然我们可以使用 ?? 操作符来降低 Optional 值的嵌套,但在具体的实际应用中,我们应该在设计时尽量避免 Optional 值的嵌套,以便代码的可读性和维护性。如果对于某个变量来说,它的值可能为空,我们可以考虑使用默认值或者定义一个默认值的 Optional 值来代替嵌套的 Optional 类型。


学习 Swift,勿忘初心,方得始终。但要陷入困境时,也不要忘了最初的梦想和时代所需要的技能。


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

基于协议的业务模块路由管理

iOS
概述 这是一个关于业务模块与路由权限的管理方案,用于增强在模块化架构场景下,业务模块的健壮性。 通过对App生命周期的转发,来解除App入口与业务模块管理逻辑的耦合。通过协议来管理API路由,通过注册制实现API的服务发现。 业务模块 重新组织后,业务模块的...
继续阅读 »

概述


这是一个关于业务模块与路由权限的管理方案,用于增强在模块化架构场景下,业务模块的健壮性。


  • 通过对App生命周期的转发,来解除App入口与业务模块管理逻辑的耦合。
  • 通过协议来管理API路由,通过注册制实现API的服务发现。

业务模块




重新组织后,业务模块的管理会变得松散,容易实现插拔复用。


协议

public protocol SpaceportModuleProtocol {
   var loaded: Bool { get set}
   /// 决定模块的加载顺序,数字越大,优先级越高
   /// - Returns: 默认优先级为1000
   static func modulePriority() -> Int
   /// 加载
   func loadModule()
   /// 卸载
   func unloadModule()

   /// UIApplicationDidFinishLaunching
   func applicationDidFinishLaunching(notification: Notification)
   /// UIApplicationWillResignActive
   func applicationWillResignActive(notification: Notification)
   /// UIApplicationDidBecomeActive
   func applicationDidBecomeActive(notification: Notification)
   /// UIApplicationDidEnterBackground
   func applicationDidEnterBackground(notification: Notification)
   /// UIApplicationWillEnterForeground
   func applicationWillEnterForeground(notification: Notification)
   /// UIApplicationWillTerminate
   func applicationWillTerminate(notification: Notification)
}

特性


  • 实现模块加载/卸载保护,模块只会加载/卸载一次。
  • 同一个模块的注册是替换制,新模块会替代旧模块。
  • 提供模块优先级配置,优先级高的模块会更早加载并响应Application的生命周期回调。

最佳实践

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
   var window: UIWindow?
   func application(_ application: UIApplication, didFinishLaunchingWithOptionslaunchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
       setupModules()
// ......
       return true
   }
 
   func setupModules() {
       var modules: [SpaceportModuleProtocol] = [
           LoggerModule(),             // 4000
           NetworkModule(),            // 3000
           FirebaseModule(),           // 2995
           RouterModule(),             // 2960
           DynamicLinkModule(),        // 2950
           UserEventRecordModule(),    // 2900
           AppConfigModule(),          // 2895
           MediaModule(),              // 2800
           AdModule(),                 // 2750
           PurchaseModule(),           // 2700
           AppearanceModule(),         // 2600
           AppstoreModule(),           // 2500
           MLModule()                  // 2500
       ]
#if DEBUG
       modules.append(DebugModule())   // 2999
#endif
       Spaceport.shared.registerModules(modules)
       Spaceport.shared.enableAllModules()
   }
}

协议路由


协议路由


通过路由的协议化管理,实现模块/组件之间通信的权限管理。


  • 服务方通过Router Manger注册API协议,可以根据场景提供不同的协议版本。
    • 业务方通过Router Manager发现并使用API协议。


最佳实践


实现API协议

protocol ResultVCRouterAPI {
   @MainActor func vc(from: ResultVCFromType, project: Project) throws -> ResultVC
   @MainActor func vcFromPreview(serviceType: EnhanceServiceType, originalImage:UIImage, enhancedImage: UIImage) async throws -> ResultVC
}

class ResultVCRouter: ResultVCRouterAPI {
   @MainActor func vc(from: ResultVCFromType, project: Project) throws -> ResultVC {
       let vc = ResultVC()
       vc.modalPresentationStyle = .overCurrentContext
       try vc.vm.config(project: project)
       vc.vm.fromType = from
       return vc
   }

   @MainActor func vcFromPreview(serviceType: EnhanceServiceType, originalImage:UIImage, enhancedImage: UIImage) async throws -> ResultVC {
       let vc = ResultVC()
       vc.modalPresentationStyle = .overCurrentContext
       try await vc.vm.config(serviceType: serviceType, originalImage: originalImage,enhancedImage: enhancedImage)
       return vc
   }
}

注册API协议

public class RouterManager: SpaceportRouterService {
   public static let shared = RouterManager()
   private override init() {}
   static func API<T>(_ key: TypeKey<T>) -> T? {
       return shared.getRouter(key)
   }
}

class RouterModule: SpaceportModuleProtocol {
   var loaded = false
   static func modulePriority() -> Int { return 2960 }
   func loadModule() {
     // 注册API
       RouterManager.shared.register(TypeKey(ResultVCRouterAPI.self), router:ResultVC())
   }
   func unloadModule() { }
}

使用协议

// 通过 RouterManager 获取可用API
guard let api = RouterManager.API(TypeKey(ResultVCRouterAPI.self)) else { return }
let vc = try await api.vcFromPreview(serviceType: .colorize, originalImage:originalImage, enhancedImage: enhancedImage)
self.present(vc, animated: false)

总结


我们的业务向模块化、组件化架构演化的过程中,逐步出现跨组件调用依赖嵌套,插拔困难等问题。


通过抽象和简化,设计了这个方案,作为后续业务组件化的规范之一。通过剥离业务模块的生命周期,以及统一通信的方式,可以减缓业务增长带来的代码劣化问题。


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

iOS之WebViewJavascriptBridge浅析

iOS
前言 H5页面具有跨平台、开发容易、上线不需要跟随App的版本等优点,但H5页面也有体验不如native好、没有native稳定等问题。所以目前大部分App都是使用Hybrid混合开发的。 当然有了H5页面就少不了H5与native交互,交互就会用到bridg...
继续阅读 »

前言


H5页面具有跨平台、开发容易、上线不需要跟随App的版本等优点,但H5页面也有体验不如native好、没有native稳定等问题。所以目前大部分App都是使用Hybrid混合开发的。


当然有了H5页面就少不了H5与native交互,交互就会用到bridge的能力了。WebViewJavascriptBridge是一个native与JS进行消息互通的第三方库,本章会简单解析一下WebViewJavascriptBridge的源码和实现原理。


通讯原理


JavaScriptCore


JavaScriptCore作为iOS的JS引擎为原生编程语言OC、Swift 提供调用 JS 程序的动态能力,还能为 JS 提供原生能力来弥补前端所缺能力。
iOS中与JS通讯使用的是JavaScriptCore库,正是因为JavaScriptCore这种起到的桥梁作用,所以也出现了很多使用JavaScriptCore开发App的框架,比如RN、Weex、小程序、Webview Hybrid等框架。
如图:




当然JS引擎不光有苹果的JavaScriptCore,谷歌有V8引擎、Mozilla有SpiderMoney


JavaScriptCore本章只简单介绍,后面主要解析WebViewJavascriptBridge。因为uiwebview已经不再使用了,所以后面提到的webview都是wkwebview,demo也是以wkwebview进行解析。


源码解析


代码结构


除了引擎层外,还需要native、h5和WebViewJavascriptBridge三层才能完成一整个信息通路。WebViewJavascriptBridge就是中间那个负责通信的SDK。


WebViewJavascriptBridge的核心类主要包含几个:


  • WebViewJavascriptBridge_JS:是一个JS的字符串,作用是JS环境的Bridge初始化和处理。负责接收native发给JS的消息,并且把JS环境的消息发送给native。
  • WKWebViewJavascriptBridge/WebViewJavascriptBridge:主要负责WKWebView和UIWebView相关环境的处理,并且把native环境的消息发送给JS环境。
  • WebViewJavascriptBridgeBase:主要实现了native环境的Bridge初始化和处理。



初始化


WebViewJavascriptBridge是如何完成初始化的呢,首先要有webview容器,所以要对webview容器进行初始化,设置代理,初始化WebViewJavascriptBridge对象,加载URL。

    WKWebView* webView = [[NSClassFromString(@"WKWebView") alloc] initWithFrame:self.view.bounds];
webView.navigationDelegate = self;
[self.view addSubview:webView];
// 开启打印
[WebViewJavascriptBridge enableLogging];
// 创建bridge对象
_bridge = [WebViewJavascriptBridge bridgeForWebView:webView];
// 设置代理
[_bridge setWebViewDelegate:self];

这里加载的就是JSBridgeDemoApp这个本地的html文件。

    NSString* htmlPath = [[NSBundle mainBundle] pathForResource:@"JSBridgeDemoApp" ofType:@"html"];
NSString* appHtml = [NSString stringWithContentsOfFile:htmlPath encoding:NSUTF8StringEncoding error:nil];
NSURL *baseURL = [NSURL fileURLWithPath:htmlPath];
[webView loadHTMLString:appHtml baseURL:baseURL];

再看一下JSBridgeDemoApp这个html文件。

function setupWebViewJavascriptBridge(callback) {
// 第一次调用这个方法的时候,为false
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
// 第一次调用的时候,为false
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
// 把callback对象赋值给对象
    window.WVJBCallbacks = [callback];
// 加载WebViewJavascriptBridge_JS中的代码
// 相当于实现了一个到https://__bridge_loaded__的跳转
var WVJBIframe = document.createElement('iframe');
    WVJBIframe.style.display = 'none';
    WVJBIframe.src = 'https://__bridge_loaded__';
    document.documentElement.appendChild(WVJBIframe);
    setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
    }

// 驱动所有hander的初始化
setupWebViewJavascriptBridge(function(bridge) {
...
}

在JSBridgeDemoApp的script标签下,声明了一个名为setupWebViewJavascriptBridge的方法,在加载html后直接进行了调用。
setupWebViewJavascriptBridge方法中最核心的代码是:



 创建一个iframe标签,然后加载了链接为 https://bridge_loaded 的内容。相当于在当前页面内容实现了一个到 https://bridge_loaded 的内部跳转。
ps:iframe标签用于在网页内显示网页,也使用iframe作为链接的目标。


html文件内部实现了这个跳转后native端是如何监听的呢,在webview的代理里有一个方法:decidePolicyForNavigationAction
这个代理方法的作用是只要有webview跳转,就会调用到这个方法。代码如下:

// 只要webview有跳转,就会调用webview的这个代理方法
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
if (webView != _webView) { return; }
NSURL *url = navigationAction.request.URL;
__strong typeof(_webViewDelegate) strongDelegate = _webViewDelegate;

// 如果是WebViewJavascriptBridge发送或者接收消息,则特殊处理。否则按照正常流程处理
if ([_base isWebViewJavascriptBridgeURL:url]) {
if ([_base isBridgeLoadedURL:url]) {
// 是否是 https://__bridge_loaded__ 这种初始化加载消息
[_base injectJavascriptFile];
} else if ([_base isQueueMessageURL:url]) {
// https://__wvjb_queue_message__
// 处理WEB发过来的消息
[self WKFlushMessageQueue];
} else {
[_base logUnkownMessage:url];
}
decisionHandler(WKNavigationActionPolicyCancel);
return;
}

// webview的正常代理执行流程
...

从上面的代码中可以看到,如果监听的webview跳转不是WebViewJavascriptBridge发送或者接收消息就正常执行流程,如果是WebViewJavascriptBridge发送或者接收消息则对此拦截不跳转,并且针对消息进行处理。
当消息url是https://bridge_loaded 的时候,会去注入WebViewJavascriptBridge_js到JS中:

// 将WebViewJavascriptBrige_JS中的方法注入到webview中并且执行
- (void)injectJavascriptFile {
NSString *js = WebViewJavascriptBridge_js();
// 把javascript代码注入webview中执行
[self _evaluateJavascript:js];
// javascript环境初始化完成以后,如果有startupMessageQueue消息,则立即发送消息
if (self.startupMessageQueue) {
NSArray* queue = self.startupMessageQueue;
self.startupMessageQueue = nil;
for (id queuedMessage in queue) {
[self _dispatchMessage:queuedMessage];
}
}
}

[self _evaluateJavascript:js];就是执行webview中的evaluateJavaScript:方法。把JS写入webview。所以执行完此处代码JS当中就有bridge这个对象了。初始化完成。


总结:在加载h5页面后会调用setupWebViewJavascriptBridge方法,该方法内创建了一个iframe加载内容为 https://bridge_loaded ,该消息被decidePolicyForNavigationAction监听到,然后执行injectJavascriptFile去读取WebViewJavascriptBridge_js将WebViewJavascriptBridge对象注入到当前h5中。


WebViewJavascriptBridge 对象


整个WebViewJavascriptBridge_js文件其实就是一个字符串形式的js代码,里面包含WebViewJavascriptBridge和相关bridge调用的方法。

// 初始化Bridge对象,OC可以通过WebViewJavascriptBridge来调用JS里面的各种方法
window.WebViewJavascriptBridge = {
registerHandler: registerHandler, // JS中注册方法
callHandler: callHandler, // JS中调用OC的方法
disableJavscriptAlertBoxSafetyTimeout: disableJavscriptAlertBoxSafetyTimeout,
_fetchQueue: _fetchQueue, // 把消息转换成JSON串
_handleMessageFromObjC: _handleMessageFromObjC // OC调用JS的入口方法
};

WebViewJavascriptBridge对象里核心的方法有:


  • registerHandler:JS中注册方法
  • callHandler: JS中调用native的方法
  • _fetchQueue: 把消息转换成JSON字符串
  • _handleMessageFromObjC:native调用JS的入口方法

当初始化完成后,WebViewJavascriptBridge对象和对象里的方法就已经存在并且可用了。


JS和native是如何相互传递消息的呢?从上面的代码中可以看到如果JS想要发送消息给native就会调用callHandler方法;如果native想要调用JS方法那JS侧就必须先注册一个registerHandler方法。


相对应的我们看一下native侧是如何与JS传递消息的,其实接口标准是一致的,native调JS的方法使用callHandler方法:

id data = @{ @"dataFromOC": @"aaaa!" };
[_bridge callHandler:@"OCToJSHandler" data:data responseCallback:^(id response) {
NSLog(@"JS回调的数据是:%@", response);
}];

JS调native方法在native侧就必须先注册一个registerHandler方法:

    // 注册事件(h5调App)
[_bridge registerHandler:@"JSTOOCCallback" handler:^(id data, WVJBResponseCallback responseCallback) {
NSLog(@"JSTOOCCallback called: %@", data);
responseCallback(@"Response from JSTOOCCallback");
}];

也就是说native像JS发送消息的话,JS侧要先注册该方法registerHandler,native侧调用callHandler;
JS像native发送消息的话,native侧要先注册registerHandler,JS侧调用callHandler。这样才能完成双端通信。


如图:




native向JS发送消息


现在要从native侧向JS侧发送一条消息,方法名为:"OCToJSHandler",并且拿到JS的回调,具体实现细节如下:


JS侧


native向JS发送数据,首先要在JS侧去注册这个方法:

bridge.registerHandler('OCToJSHandler', function(data, responseCallback) {
...
})

这个registerHandler的实现在WebViewJavascriptBridge_JS是:

// web端注册一个消息方法,将注册的方法存储起来
function registerHandler(handlerName, handler) {
messageHandlers[handlerName] = handler;
}

就是将这个注册的方法存储到messageHandlers这个map中,key为方法名称,value为function(data, responseCallback) {}这个方法。


native侧


native侧调用bridge的callHandler方法,传参为data和一个callback回调

id data = @{ @"dataFromOC": @"aaaa!" };
[_bridge callHandler:@"OCToJSHandler" data:data responseCallback:^(id response) {
NSLog(@"JS回调的数据是:%@", response);
}];

接下来会走到WebViewJavascriptBridgeBase的-sendData: responseCallback: handlerName:方法,该方法中将"data"和"handlerName"存入到一个message字典中,如果存在callback会生成一个callbackId一并存入到message字典中,并且将该回调存入到responseCallbacks中,key为callbackId,value为这个callback。代码如下:

// 所有信息存入字典
NSMutableDictionary* message = [NSMutableDictionary dictionary];
if (data) {
message[@"data"] = data;
}
if (responseCallback) {
NSString* callbackId = [NSString stringWithFormat:@"objc_cb_%ld", ++_uniqueId];
self.responseCallbacks[callbackId] = [responseCallback copy];
message[@"callbackId"] = callbackId;
}
if (handlerName) {
message[@"handlerName"] = handlerName;
}
[self _queueMessage:message];

将message存储到队列等待执行,执行该条message时会先将message进行序列化,序列化完成后将message拼接到字符串WebViewJavascriptBridge._handleMessageFromObjC('%@');中,然后执行_evaluateJavascript执行该js方法。

// 把OC消息序列化、并且转化为JS环境的格式,然后在主线程中调用_evaluateJavascript
- (void)_dispatchMessage:(WVJBMessage*)message {
NSString *messageJSON = [self _serializeMessage:message pretty:NO];
NSString* javascriptCommand = [NSString stringWithFormat:@"WebViewJavascriptBridge._handleMessageFromObjC('%@');", messageJSON];
[self _evaluateJavascript:javascriptCommand];
}

_handleMessageFromObjC方法会将messageJSON传递给_dispatchMessageFromObjC进行处理。
首先将messageJSON进行解析,根据handlerName取出存储在messageHandlers中的方法。如果该message中存在callbackId,将callbackId作为参数生成一个回调放到responseCallback中。
代码如下:

function _doDispatchMessageFromObjC() {
// 解析发送过来的JSON
var message = JSON.parse(messageJSON);
var messageHandler;
var responseCallback;

// 主动调用
// 如果有callbackid
if (message.callbackId) {
// 将callbackid当做callbackResponseId再返回回去
var callbackResponseId = message.callbackId;
responseCallback = function(responseData) {
// 把消息从JS发送到OC,执行具体的发送操作
_doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
};
// 获取JS注册的函数,取出消息里的handlerName
var handler = messageHandlers[message.handlerName];
// 调用JS中的对应函数处理
handler(message.data, responseCallback);
}
}

handler方法其实就是名为"OCToJSHandler"的方法,这时就走到了registerHandler里的那个function(data, responseCallback) {}方法了。我们看一下方法内部的具体实现:

bridge.registerHandler('OCToJSHandler', function(data, responseCallback) {
// OC中传过来的数据
log('从OC传过来的数据是:', data)
// JS返回数据
var responseData = { 'dataFromJS':'bbbb!' }
responseCallback(responseData)
})

data就是从native传过来的数据,responseCallback就是保存的回调,然后又生成了新数据作为参数给到了这个回调。


responseCallback的实现是:

responseCallback = function(responseData) {
// 把消息从JS发送到OC,执行具体的发送操作
_doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
};

将该方法的handlerName、生成的callbackResponseId(也就是callbackId)以及JS返回的数据一起给到_doSend方法。


_doSend方法将message存储到sendMessageQueue消息列表中,并使用messagingIframe加载了一次https://wvjb_queue_message

// 把消息从JS发送到OC,执行具体的发送操作
function _doSend(message, responseCallback) {
// 把消息放入消息列表
sendMessageQueue.push(message);
// 发出js对oc的调用,让webview执行跳转操作,可以在decidePolicyForNavigationAction:中拦截到js发给oc的消息
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}

这时webview的监听方法decidePolicyForNavigationAction监听到了https://wvjb_queue_message 消息后还是执行WebViewJavascriptBridge._fetchQueue()去取数据,取到数据后根据responseId当初在_responseCallbacks中存储的callback,然后执行callback、移除responseCallbacks中的数据。到此为止,整个native向JS发送消息的过程就完成了。


总结:


  1. JS中先调用registerHandler将方法存储到messageHandlers中
  2. native调用callHandler:方法,将消息内容存储到message中,回调存储到responseCallbacks中。
  3. 将message消息序列化通过_evaluateJavascript方法执行_handleMessageFromObjC
  4. 将message解析,通过message.handlerName从messageHandlers取出该方法;根据message.callbackId生成回调
  5. 执行该方法,回调

JS向native发送消息


从JS向native发消息其实和native向JS发消息的接口层面是差不多的。


native侧


native侧首先要注册一个JSTOOCCallback方法

[_bridge registerHandler:@"JSTOOCCallback" handler:^(id data, WVJBResponseCallback responseCallback) {
responseCallback(@"Response from JSTOOCCallback");
}];

该方法也同样是将该方法的callback存储起来,存储到messageHandlers当中,key就是方法名"JSTOOCCallback",value就是callback。


JS侧


JS侧会调用callHandler方法:

// 调用oc中注册的那个方法
bridge.callHandler('JSTOOCCallback', {'foo': 'bar'}, function(response) {
log('JS 取到的回调是:', response)
})

这个callHandler方法同样会调用_doSend方法:将callback存储到responseCallbacks中,key为callbakid;将消息存储到sendMessageQueue中;messagingIframe执行https://wvjb_queue_message


native的decidePolicyForNavigationAction方法监听到该消息后同样通过WebViewJavascriptBridge._fetchQueue()去取消息。


根据callbackId创建一个responseCallback,根据message的handlerName从messageHandlers取出该回调,然后执行:

WVJBResponseCallback responseCallback = NULL;
NSString* callbackId = message[@"callbackId"];
if (callbackId) {
responseCallback = ^(id responseData) {
if (responseData == nil) {
responseData = [NSNull null];
}
WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
[self _queueMessage:msg];
};
} else {
responseCallback = ^(id ignoreResponseData) {
// Do nothing
};
}

WVJBHandler handler = self.messageHandlers[message[@"handlerName"]];

handler(message[@"data"], responseCallback);

调用完这个方法后,该消息已经收到,然后将回调的内容回调给JS。
通过上面的代码可以看到,回调JS的内容就是callbackId和responseData生成的message,调用_queueMessage方法。


_queueMessage方法上面已经看过了,就是序列化消息、加入队列、执行WebViewJavascriptBridge._handleMessageFromObjC('%@');方法。


JS收到该消息后,处理返回的消息,从responseCallbacks中根据message中的responseId取出callback并且执行。最后删除responseCallbacks中的数据,JS向native发送数据就完成了。


总结:


  1. native侧调用registerHandler方法注册方法,方法名为JSTOOCCallback,将消息存储到messageHandlers中,key为方法名,value为callback。
  2. JS侧调用callHandler方法:将responseCallback存储到responseCallbacks中;将message存储到sendMessageQueue中;messagingIframe执行 http://wvjb_queue_message
  3. native侧监听到该消息后调用WebViewJavascriptBridge._fetchQueue()去取数据
  4. 根据handlerName从messageHandlers中取出该callback;根据callbackId创建callback对象作为参数放到handlerName的方法中;执行该回调。

总结


综上,WebViewJavascriptBridge的核心流程就分析完了,最核心的点是JS通过加载iframe来通知native侧;native侧通过evaluateJavaScript方法去执行JS。


从整个SDK来看,设计的非常好,值得借鉴学习:


  • 使用外观模式统一调用接口,比如初始化WebViewJavascriptBridge的时候,不需要关心使用方使用的是UIWebView还是WKWebView,内部已经处理好了。
  • 接口统一,不管是native侧还是JS侧,调用方法就是callHandler、注册方法就是registerHandler,不需要关注内部实现,使用非常方便。
  • 代码简洁,逻辑清晰,层次分明。从类的分布就能很清晰的看出各自的功能是什么。
  • 职责单一,比如decidePolicyForNavigationAction方法只负责监听事件、_fetchQueue是负责把消息转换成JSON字符串返回、_doSend是发送消息到native、_dispatchMessageFromObjC是负责处理从OC返回的消息等。虽然decidePolicyForNavigationAction也能接收消息,但这样就不会这么精简了。
  • 扩展性好,目前decidePolicyForNavigationAction虽然只有初始化和发消息两个事件,如果有其他事件还可以再扩展,这也得益于方法设计的职责单一,扩展对原有方法影响会很小。

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

音频播放器-iOS

iOS
AudioPlaybackManager 该音频播放器基于 AVPlayer 实现在线/本地播放, 在线播放支持加载本地缓存。支持设置后台播放信息。支持远程控制。 可初始化、可单例。兼容 OC 调用。 代码结构  AudioPlaybackManag...
继续阅读 »

AudioPlaybackManager


该音频播放器基于 AVPlayer 实现在线/本地播放, 在线播放支持加载本地缓存。支持设置后台播放信息。支持远程控制。


可初始化、可单例。兼容 OC 调用。


代码结构



 AudioPlaybackManager 为实现基础播放类, 其余功能则分别位于不同文件中, 下边会根据该目录结构来进行对应功能的简单使用讲解。


播放设置


基础播放


设置 playerItem

let audio = Audio(audioURL: URL)
AudioPlaybackManager.shared.setupItem(audio, beginTime: 0.0)

针对 playerItem 添加了 3 个监听, 分别是:

  1. AVPlayerItem.status, 监听 playerItem 状态。

    • 当处于 readyToPlay 状态时, 会在此处获取音频总时长, 同时若 autoPlayWhenItemReady = true 时, 则会自动播放。

      若需要手动播放, 则可在收到 AudioPlaybackManager.readyToPlayNotification 通知或 audioPlaybackManagerRreadyToPlay(_:) 代理方法之后调用 play() 方法即可。

  2. AVPlayerItem.loadedTimeRanges, 监听缓存加载进度, 同步至 loadedTime

  3. AVPlayerItemDidPlayToEndTime, 监听播放完成, 同步至 playStatus = .playCompleted


属性监听


  • @objc dynamic var playStatus: PlayStatus = .prepare
enum PlayStatus: Int {
   case prepare, playing, paused, stop, playCompleted, error
}
  • @objc dynamic var playTime: Float64 = 0

    • 默认为 (1/30)s 回调 1 次
  • @objc dynamic var progress: Float = 0

    • 默认为 (1/30)s 回调 1 次
  • @objc dynamic var duration: Float64 = 0

  • @objc dynamic var loadedTime: Float64 = 0


以上属性均支持通过 KVO 监听。


播放控制

  • play()

  • pause()

  • togglePlayPause()

  • stop()

  • switchNext()

    • 收到 AudioPlaybackManager.nextTrackNotification 通知或 audioPlaybackManagerNextTrack(_:) 代理方法后重新设置 setupItem(_:beginTime:)
  • switchPrevious()

    • 收到 AudioPlaybackManager.previousTrackNotification 通知或 audioPlaybackManagerPreviousTrack(_:) 代理方法后重新设置 setupItem(_:beginTime:)

更多控制


  • skipForward(_ timeInterval: TimeInterval)
  • skipBackward(_ timeInterval: TimeInterval)
  • seekToPositionTime(_ positionTime: TimeInterval)
  • seekToProgress(_ value: Float)
  • beginRewind(rate: Float = -2.0)
  • beginFastForward(rate: Float = 2.0)
  • endRewindFastForward()

播放被其他 App 影响


中断


当电话、闹钟、其它非官方 App 播放(这里涉及到后台播放, 下边会讲)... 时, 若二者不支持混音播放, 那么当前播放则会被系统暂停。这里主动调用了 pause() 来跟随变更播放状态。


中断恢复播放


var shouldResumeWhenInterruptEnded = true, 若不期望自动恢复播放, 可将其置为 false


若中断方在结束播放后告知系统应该通知其他应用程序其已经停用了音频会话, 那么被中断的音频会话则可以选择是否继续播放。


一般系统 App 都会对此进行通知, 而部分第三方 App 可能没对此进行处理, 那么也将不能自动恢复播放。


ps: 由于目前没有混音播放的需求, 后续考虑是否要将中断通知转发给开发者来自主控制暂停/播放。


播放 Route 变更


外设变更涉及:


  1. 从外音播放改为耳机播放,继续播放;
  2. 耳机播放中,拿掉耳机(AirPods)自动暂停, 戴上继续播放;

总体可以概括为:

switch reason {
   case .newDeviceAvailable:
       play()
   case .oldDeviceUnavailable:
       pause()
   default: break
}

ps: 其他情况收到 route 变化通知如 AVAudioSession.Category 变更, 则不在该播放器考虑范畴内。


后台播放


  1. 开启后台播放权限

    1. 设置 setActiveSession(_ enabled: Bool)


在播放时设置为 true, 播放结束后设置为 false。如果仅在一个特定的控制器内播放的话, 在执行 deinit 方法中设置为 false 也是个不错的选择。


ps: 该方法设置 AVAudioSession.Category = .playback, AVAudioSession.Mode = .default。会保持应用程序音频在设备静音或屏幕锁定时能够继续播放。


在线播放加载本地缓存


var cacheEnabled: Bool, 提供了在线播放缓存开关, 默认关闭状态。


ps: 在线播放缓存引用了 VIMediaCache 第三方库, 支持自定义缓存目录, 默认存储在 tmp 目录下。想详细了解缓存流程的可以去看下, 文章写的很详细。


设置后台播放信息展示


var allowSetNowPlayingInfo: Bool, 默认为开启状态。


如需展示, 需要在设置 let audio = Audio(audioURL: URL) 时额外对其后台展示信息相关参数进行设置。


如需获取音频自身音频数据来进行展示, 则设置 useAudioMetadata = true 即可。


若音频不存在相关元数据, 则可以通过其他相关参数来进行设置。

    /// Audio url.
    open var audioURL: URL

    public init(audioURL: URL) {
        self.audioURL = audioURL
    }

    /// -------------- `MPNowPlayingInfoCenter` --------------

    /// Set `nowPlayingInfo` using audio metadata.
    ///
    /// Default is `false`.
    open var useAudioMetadata: Bool = false

    // Note: If `useAudioMetadata` is set to false, then you can set it through the following properties.

    /// Audio name.
    open var title: String?
    /// Album name.
    open var albumName: String?
    /// Artist.
    open var artist: String?

    /// Artwork.
    open var artworkImage: UIImage?
    open var artworkURL: URL?

ps: allowSetNowPlayingInfo = true 时播放进度相关信息会跟随一并设置。


效果图:




远程控制


简单远程控制方式

UIApplication.shared.beginReceivingRemoteControlEvents()
UIApplication.shared.endReceivingRemoteControlEvents()

AppDelegate 中实现

func remoteControlReceived(with event: UIEvent?) {
if let event = event, event.type == .remoteControl {
       switch event.subtype {
case .remoteControlPlay:
case ...
       }
   }
}

这种远程控制可满足大部分需求, 并且实现非常简单, 但是存在一个很大的问题, 就是无法实现进度条控制。


项目远程控制方式


采用 MPRemoteCommandCenter 方式。


基础控制功能

activatePlaybackCommands(_ enabled: Bool)
activatePreviousTrackCommand(_ enabled: Bool)
activateNextTrackCommand(_ enabled: Bool)
activateChangePlaybackPositionCommand(_ enabled: Bool)

长按 快进/快退


var remoteControlRewindRate: Float, 默认为 -2.0;


var remoteControlFastForwardRate: Float, 默认为 2.0;

activateSeekBackwardCommand(_ enabled: Bool)
activateSeekForwardCommand(_ enabled: Bool)

跳跃播放

复制代码
activateSkipForwardCommand(_ enabled: Bool, interval: Int = 0)
activateSkipBackwardCommand(_ enabled: Bool, interval: Int = 0)

ps: 开启跳跃播放会占用 上一首/下一首 位置。


关闭远程控制


在不需要远程控制功能时, 调用 deactivateAllRemoteCommands() 即可完全关闭。




项目


好了, 以上基本就是全部使用方法了。源代码及 Demo 可访问 Github 进行查看。


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

iOS 网速检测方案

iOS
背景 为了基于网络状况做更细致的业务策略,需要一套网速检测方案,尽量低成本的评估当前网络状况,所以我们希望检测数据来自于过往的网络请求,而不是专门耗费资源去网络请求来准确评估。 指标计算 一般 RTT 作为网速的主要评估指标,拿到批量的历史请求 RTT 值后,...
继续阅读 »

背景


为了基于网络状况做更细致的业务策略,需要一套网速检测方案,尽量低成本的评估当前网络状况,所以我们希望检测数据来自于过往的网络请求,而不是专门耗费资源去网络请求来准确评估。


指标计算


一般 RTT 作为网速的主要评估指标,拿到批量的历史请求 RTT 值后,要如何去计算得到较为准确的目标 RTT 值呢?


影响 RTT 值的变量主要是:


  1. 网络状况会随时间变化;
  2. 请求来自不同的服务器,性能有差异,容易受到长尾数据影响;

首先参考 Chrome 的 nqe 源码:chromium.googlesource.com/chromium/sr…


权重设计


查阅相关源码后,发现历史请求的 RTT 值会关联一个权重,用于最终的计算,找到计算 RTT 权重的核心逻辑:

void ObservationBuffer::ComputeWeightedObservations(
const base::TimeTicks& begin_timestamp,
int32_t current_signal_strength,
std::vector<WeightedObservation>* weighted_observations,
double* total_weight) const {

base::TimeDelta time_since_sample_taken = now - observation.timestamp();
double time_weight =
pow(weight_multiplier_per_second_, time_since_sample_taken.InSeconds());

double signal_strength_weight = 1.0;
if (current_signal_strength >= 0 && observation.signal_strength() >= 0) {
int32_t signal_strength_weight_diff =
std::abs(current_signal_strength - observation.signal_strength());
signal_strength_weight =
pow(weight_multiplier_per_signal_level_, signal_strength_weight_diff);
}

double weight = time_weight * signal_strength_weight;


可以看到权重主要来自两个方面:


  1. 信号权重:与当前信号强度差异越大的 RTT 值参考价值越低;
  2. 时间权重:距离当前时间越久的 RTT 值参考价值越低;

这个处理能减小网络状况随时间变化带来的影响。


半衰期设计


在计算两个权重的时候都是用pow(衰减因子, diff)计算的,那这个“衰减因子”如何得到的呢,以时间衰减因子为例:

double GetWeightMultiplierPerSecond(
const std::map<std::string, std::string>& params) {
// Default value of the half life (in seconds) for computing time weighted
// percentiles. Every half life, the weight of all observations reduces by
// half. Lowering the half life would reduce the weight of older values
// faster.
int half_life_seconds = 60;
int32_t variations_value = 0;
auto it = params.find("HalfLifeSeconds");
if (it != params.end() && base::StringToInt(it->second, &variations_value) &&
variations_value >= 1) {
half_life_seconds = variations_value;
}
DCHECK_GT(half_life_seconds, 0);
return pow(0.5, 1.0 / half_life_seconds);
}

其实就是设计一个半衰期,计算得到“每秒衰减因子”,比如这里就是一个 RTT 值和当前时间差异 60 秒则权重衰减为开始的一半。延伸思考一下,可以得到两个结论:


  1. 同等历史 RTT 值量级下,半衰期越小,可信度越高,因为越接近当前时间的网络状况;
  2. 同等半衰期下,历史 RTT 值量级越大,可信度越高,因为会抹平更多的服务器性能差异;

所以更进一步的话,半衰期可以根据历史 RTT 值的量级来进行调节,找到它们之间的平衡点。


加权算法设计


拿到权值后如何计算呢,我们最容易想到的是加权平均值算法,但它同样会受长尾数据的影响。


比如当某个 RTT 值比正常值大几十倍且权重稍高时,加权平均值也会很大,更优的做法是获取加权中值,这也是 nqe 的做法,伪代码为:

//按 RTT 值从小到大排序
samples.sort()
//目标权重是总权重的一半
desiredWeight = 0.5 * totalWeight
//找到目标权重对应的 RTT 值
cumulativeWeight = 0
for sample in samples
cumulativeWeight += sample.weight
If (cumulativeWeight >= desiredWeight)
return sample.RTT

进一步优化


通过历史网络请求样本数据计算加权中值,根据计算后的 RTT 值区间确定网速状态供业务使用,比如 Bad / Good,这种策略能覆盖大部分情况,但有两个特殊情况需要优化。


无网络访问场景


当用户一段时间没有访问网络缺乏样本数据时,引入主动探测策略,发起请求实时计算 RTT 值。


网络状况快速劣化场景


若在某一个时刻网络突然变得很差,大量请求堆积在队列中,由于我们 RTT 值依赖于网络请求落地,这时计算的目标 RTT 值具有滞后性。


为了解决这个问题,可以记录一个“未落地请求”的队列,每次计算 RTT 值之前,前置判断一下“超过某个阈值”的未落地请求“超过某个比例”,视为弱网状态,达到快速感知网络劣化的效果。


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

在 iOS 中使用 IdentifyLookup 进行短信过滤

iOS
垃圾短信是一个长期存在、令人困扰的问题。本文将介绍如何阻止这些短信、设备端的检测以及整合动态的服务器检测等。 Apple 在 WWDC 2017(iOS 11) 推出了 IdentityLookup 框架,让开发者可以参与到过滤短信的过程中。在 iOS 14,...
继续阅读 »

垃圾短信是一个长期存在、令人困扰的问题。本文将介绍如何阻止这些短信、设备端的检测以及整合动态的服务器检测等。


Apple 在 WWDC 2017(iOS 11) 推出了 IdentityLookup 框架,让开发者可以参与到过滤短信的过程中。在 iOS 14,Apple 新增了两种过滤类别:交易信息(Promotion)、推广信息(transaction)。在 WWDC 2022(iOS 16),针对这两种类别,Apple 新增了 12 种子类别,推广信息包括 9 种子类别:其他(Others)、财务(Finance)、订单(Orders)、提醒(Reminders)、健康(Health)、天气(Weather)、运营商(Carrier)、奖励(Rewards)、公共服务(PublicServices)。交易信息包括 3 种子类别:其他(Others)、优惠(Offers)、优惠券(Coupons)。




消息过滤流程


消息过滤通过应用程序扩展(App extension)来完成。当用户收到来自未知发件人的消息时,“消息” APP 通过询问 Message Filter Extension,来确定该消息的类别。Message Filter Extension 可以通过使用内置逻辑或推迟到关联服务器的分析来做出此决定。



IdentityLookup 仅适用于来自未知发件人的短信和彩信,它不适用于联系人列表中发件人的消息、不适用任何 iMessage 消息、不适用于回复发件人 3 次及以上的会话。





“消息” APP 使用一个 ILMessageFilterQueryRequest 对象将信息传递给 Message Filter Extension。Message Filter Extension 确定该消息的类别后,将 ILMessageFilterQueryResponse 对象返回给“消息” APP。


如果 App extension 无法自行做出决策,“消息” APP将会把有关信息发送到与 Message Filter Extension 关联的服务器,并将响应传递给 Message Filter Extension。Message Filter Extension 解析服务器的响应并返回最终的 ILMessageFilterQueryResponse 对象,如下图所示。




出于隐私原因,系统会处理与关联的服务器的所有通信;Message Filter Extension 无法直接访问网络,也无法将数据写入应用的共享容器中。


消息过滤实践


为 APP 新增 Message Filter Extension:











我们依次来看 MessageFilterExtension.swift 文件中的代码:

import IdentityLookup
final class MessageFilterExtension: ILMessageFilterExtension {}

ILMessageFilterExtension 是的主要类的抽象基类。在 Info.plist 中被设置 NSExtensionPrincipalClass,将在收到消息时被构造:




ILMessageFilterExtension 类无其他要求或限制:

open class ILMessageFilterExtension : NSObject {
}

MessageFilterExtension 实现了 ILMessageFilterQueryHandlingILMessageFilterCapabilitiesQueryHandling 协议:

extension MessageFilterExtension: ILMessageFilterQueryHandling, ILMessageFilterCapabilitiesQueryHandling {
// ...
}

ILMessageFilterQueryHandling


ILMessageFilterExtension 子类必须符合 ILMessageFilterQueryHandling协议,通过包含短信信息的 queryRequest 、提供请求关联网络服务器能力的 context,来进行短信类别的判断。最终返回提供包含类别信息的 response

@available(iOS 11.0, *)
public protocol ILMessageFilterQueryHandling : NSObjectProtocol {
   // 闭包
   func handle(_ queryRequest: ILMessageFilterQueryRequest,
               context: ILMessageFilterExtensionContext,
               completion: @escaping (ILMessageFilterQueryResponse) -> Void)
// 异步函数
   func handle(_ queryRequest: ILMessageFilterQueryRequest,
               context: ILMessageFilterExtensionContext
   ) async -> ILMessageFilterQueryResponse
}

queryRequest 的信息如下,包括发件人号码 sender、短信内容 messageBodyISO 国家代码 receiverISOCountryCode

@available(iOS 11.0, *)
open class ILMessageFilterQueryRequest : NSObject, NSSecureCoding {
   open var sender: String? { get }
   open var messageBody: String? { get }
   @available(iOS 16.0, *)
   open var receiverISOCountryCode: String? { get }
}

context 提供请求关联网络服务器能力,我们也只能使用该能力访问网络:

@available(iOS 11.0, *)
open class ILMessageFilterExtensionContext : NSExtensionContext {
// 闭包
   open func deferQueryRequestToNetwork(completion: @escaping (ILNetworkResponse?, Error?) -> Void)
// 异步函数
   open func deferQueryRequestToNetwork() async throws -> ILNetworkResponse
}


URL 记录在 Info.plistILMessageFilterExtensionNetworkURL 中,无法进行自定义。





response 定义如下,需要提供对应的类别和子类别:

@available(iOS 11.0, *)
open class ILMessageFilterQueryResponse : NSObject, NSSecureCoding {
   open var action: ILMessageFilterAction
   @available(iOS 16.0, *)
   open var subAction: ILMessageFilterSubAction
}

noneallowjunkpromotiontransaction 类别,noneallow 的行为相同:

@available(iOS 11.0, *)
public enum ILMessageFilterAction : Int, @unchecked Sendable {
   case none = 0
   case allow = 1
   case junk = 2
   @available(iOS 14.0, *)
   case promotion = 3
   @available(iOS 14.0, *)
   case transaction = 4
}

以及文章开头提到的 12 种子类别:

@available(iOS 16.0, *)
public enum ILMessageFilterSubAction : Int, @unchecked Sendable {
   case none = 0
   
   /// TRANSACTIONAL SUB-ACTIONS
   
   case transactionalOthers = 10000
   case transactionalFinance = 10001
   case transactionalOrders = 10002
   case transactionalReminders = 10003
   case transactionalHealth = 10004
   case transactionalWeather = 10005
   case transactionalCarrier = 10006
   case transactionalRewards = 10007
   case transactionalPublicServices = 10008
   
   /// PROMOTIONAL SUB-ACTIONS
   
   case promotionalOffers = 20001
   case promotionalCoupons = 20002
}

因此,整体的过滤代码框架如下,依次进行设备端的检测、服务器检测:

func handle(_ queryRequest: ILMessageFilterQueryRequest,
           context: ILMessageFilterExtensionContext,
           completion: @escaping (ILMessageFilterQueryResponse) -> Void
) {
   // 设备端的检测
   let (offlineAction, offlineSubAction) = self.offlineAction(for: queryRequest)
   switch offlineAction {
   case .allow, .junk, .promotion, .transaction:
       let response = ILMessageFilterQueryResponse()
       response.action = offlineAction
       response.subAction = offlineSubAction
       completion(response)
   case .none:
      // 服务器检测
       context.deferQueryRequestToNetwork() { (networkResponse, error) in
           let response = ILMessageFilterQueryResponse()
           if let networkResponse = networkResponse {
               (response.action, response.subAction) = self.networkAction(for: networkResponse)
           }
           completion(response)
       }
   @unknown default:
       break
   }
}

这里需要注意,Apple 定义了服务器检测网络请求的格式,开发者无法进行自定义:

POST /server-endpoint HTTP/1.1
Accept: */*
Content-Type: application/json; charset=utf-8
Content-Length: 148
{
  "_version": 1,
  "query": {
      "sender": "14085550001",
      "message": {
          "text": "This is a message"
      }
  },
  "app": {
      "version": "1.1"
  }
}

ILMessageFilterCapabilitiesQueryHandling


ILMessageFilterCapabilitiesQueryHandling 协议会更简单些:

@available(iOS 16.0, *)
public protocol ILMessageFilterCapabilitiesQueryHandling : NSObjectProtocol {
 // 闭包
   func handle(_ capabilitiesQueryRequest: ILMessageFilterCapabilitiesQueryRequest,
               context: ILMessageFilterExtensionContext,
               completion: @escaping (ILMessageFilterCapabilitiesQueryResponse
   ) -> Void)
// 异步函数
   func handle(_ capabilitiesQueryRequest: ILMessageFilterCapabilitiesQueryRequest,
               context: ILMessageFilterExtensionContext
   ) async -> ILMessageFilterCapabilitiesQueryResponse
}

其中,capabilitiesQueryRequest 无实际含义,context 同前文。需要提供的是 ILMessageFilterCapabilitiesQueryResponse:

@available(iOS 16.0, *)
open class ILMessageFilterCapabilitiesQueryResponse : NSObject, NSSecureCoding {
}

@available(iOS 16.0, *)
@available(macOS, unavailable)
extension ILMessageFilterCapabilitiesQueryResponse {

   @nonobjc final public var transactionalSubActions: [ILMessageFilterSubAction]

   final public var promotionalSubActions: [ILMessageFilterSubAction]
}

指定了 Message Filter Extension 可以显示的子类别。我们可以这样展示以显示子类别:

func handle(_ capabilitiesQueryRequest: ILMessageFilterCapabilitiesQueryRequest,
           context: ILMessageFilterExtensionContext,
           completion: @escaping (ILMessageFilterCapabilitiesQueryResponse) -> Void
) {
   let response = ILMessageFilterCapabilitiesQueryResponse()
   response.transactionalSubActions = [        .transactionalOthers,        .transactionalFinance,        .transactionalOrders,        .transactionalReminders,        .transactionalHealth,        .transactionalWeather,        .transactionalCarrier,        .transactionalRewards,        .transactionalPublicServices    ]
   response.promotionalSubActions = [                .promotionalOthers,        .promotionalOffers,        .promotionalCoupons,    ]
   completion(response)
}

在 iOS16 设备上,不同配置样式如下:


垃圾短信和垃圾电话上报



此外,我们可以一个 App Extension,让用户将不需要的短信和电话上报为垃圾内容。上报电话需要用户在最近列表中进行左滑后选择报告。对于在消息记录中的短信,用户可以按下报告垃圾信息按钮:












创建 Unwanted Communication Reporting Extension:











我们可以看到模版代码十分简单:

class UnwantedCommunicationReportingExtension: ILClassificationUIExtensionViewController {
   
   override func viewDidAppear(_ animated: Bool) {
       super.viewDidAppear(animated)
       // Notify the system when you have completed gathering information
       // from the user and you are ready with a classification response
       self.extensionContext.isReadyForClassificationResponse = true
   }
   
   // Customize UI based on the classification request before the view is loaded
   override func prepare(for classificationRequest: ILClassificationRequest) {
       // Configure your views for the classification request
   }
   
   // Provide a classification response for the classification request
   override func classificationResponse(for request:ILClassificationRequest) -> ILClassificationResponse {
       return ILClassificationResponse(action: .reportJunk)
   }
}

当用户上报时,系统会启动 App Extension。搜集用户外信息后,进行后续的上报或阻止,如下图所示。




具体来说,系统会依次:


  1. 实例化 App Extension 中的 ILClassificationUIExtensionViewController 子类。
  2. 调用实例的 prepare(for:)`方法并将控制器呈现给用户。
    1. 使用实例从用户那里收集数据,搜集完成 isReadyForClassificationResponse 设置为 true
    2. 如果用户按下取消按钮,系统将关闭 ILClassificationUIExtensionViewController 子类实例。

    1. 如果用户按下完成,系统将调用 classificationResponse(for:) 方法,传入一个 ILClassificationRequest 对象。

    1. 系统根据方法的 ILClassificationResponse 响应采取不同的操作。
@available(iOS 12.0, *)
open class ILClassificationResponse : NSObject, NSSecureCoding {
   open var action: ILClassificationAction { get }
   @available(iOS 12.1, *)
   open var userString: String?
   open var userInfo: [String : Any]?
   public init(action: ILClassificationAction)
}

ILClassificationAction 类型为:

/// Describes various classification actions.
@available(iOS 12.0, *)
public enum ILClassificationAction : Int, @unchecked Sendable {
   /// Indicate that no action is requested.
   case none = 0
   /// Report communication(s) as not junk.
   case reportNotJunk = 1
   /// Report communication(s) as junk.
   case reportJunk = 2
   /// Report communication(s) as junk and block the sender.
   case reportJunkAndBlockSender = 3
}

对于 ILClassificationAction.none,系统会关闭视图控制器,但不会采取任何其他操作。


对于 ILClassificationAction.reportNotJunkILClassificationAction.reportJunk,系统会根据 userInfo 属性生成报告,然后将其发布到扩展程序的 Info.plist 文件中指定的服务端(ILClassificationExtensionNetworkReportDestination)或者使用短信发到对应的号码(ILClassificationExtensionSMSReportDestination)。




对于 ILClassificationAction.reportJunkAndBlockSender,系统的响应就像在 ILClassificationAction.reportJunk 操作中一样。 但是,在报告步骤之后,系统会发出提示,让用户知道该号码将被阻止(拉黑)。


最后,为了保护用户隐私,系统会在 App Extension 终止后删除该容器。有关详细信息,请参阅关于 iOS 文件系统


参考资料




基于短信过滤能力。上线了喵喵消烦员 App:


喵喵消烦员是一款短信过滤工具软件。在如今信息爆炸的时代,您的隐私和安全由喵喵来守护!我们使用 scikit-learn,通过朴素贝叶斯算法对垃圾短信进行识别,通过 Core ML 将模型部署在本地,从而完成离线过滤任务。



  1. 隐私安全:我们不会索要任何位置、相机、通知、无线数据(网络)等权限,用户的数据不会被保存,同时也不可能被上传,所有操作均在本地完成。

  2. 高效精准:通过先进的算法技术,自动识别并过滤掉垃圾短信、诈骗信息、广告推销等不必要打扰的内容。

  3. 自定义设置:支持自定义关键词过滤,方便您针对个人需求进行个性化设置,避免不必要的干扰。

  4. 更新迭代:我们会通过版本升级定期更新过滤模型,确保始终能够准确地识别和拦截最新的垃圾短信和诈骗信息。


模型、App 还在不断调整和优化,欢迎使用和提供建议!




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

数组去重的多种方式

iOS
前言 从数组中删除重复项是一项常见的任务,在 Swift 中,标准库没有直接提供一个系统函数给我们,必须自己实现这样的方法。 实现数组去重的方法有很多,今天来介绍一些常用的方法。 1、使用 Set 去重 Set 也是一个集合,只是它不包含重复项,利用这个特点,...
继续阅读 »

前言


从数组中删除重复项是一项常见的任务,在 Swift 中,标准库没有直接提供一个系统函数给我们,必须自己实现这样的方法。


实现数组去重的方法有很多,今天来介绍一些常用的方法。


1、使用 Set 去重


Set 也是一个集合,只是它不包含重复项,利用这个特点,我们可以简单的给一个数组去重:

let array: [Int] = [1, 1, 3, 3, 2, 2]
let set: Set<Int> = Set(array)
print(set)


上边的代码会打印去重之后的 [1, 2, 3],但结果也可能是 [3, 1, 2],也可能是 [3, 2, 1],这就涉及到 Set 的原始设计了,它内部的元素是无序的,不能保证固定的顺序,如果你对顺序有要求,就不能用 Set 来实现了。


2、巧用字典去重


我们都知道字典中是无法存储相同 Key 的,也就可以利用 Key 的唯一性来去重:

let array: [Int] = [1, 1, 3, 3, 2, 2]
var dic: [Int: Int] = [:]
array.forEach { dic[$0] = 0 }
print(dic.keys)


先把 array 遍历一遍,所有元素作为字典的 key 存储起来,最后再取 dic.keys 获得去重之后的数组。


但是字典的 key 也一样是无序的,而且使用字典会带来额外的性能开销,因此不推荐这种方式。


上边提到这两种方案都无法保证顺序,如果需要保证去重后的顺序和原数组保持一致,请看下边的几个方案。


3、利用 NSOrderedSet


NSOrderedSet 是 OC 时代的产物,继承自 NSObject,它可以像 Set 一样实现去重,也可以保证顺序:

let array: [Int] = [1, 1, 3, 3, 2, 2]
let orderSet = NSOrderedSet(array: array)
print(orderSet.array)


最终打印 [1, 2, 3],顺序和原数组保持一致,但是需要注意这玩意性能比 Set 差很多。


4、遍历数组去重


这也是最符合直觉的方法,把数组遍历一遍,创建个新数组,如果这个元素没有加入新数组就加进去,如果加过了就抛掉:

let array: [Int] = [1, 1, 3, 3, 2, 2]
var newArray: [Int] = []
array.forEach { item in
    if !newArray.contains(item) {
        newArray.append(item)
    }
}
print(newArray)


最终打印 [1, 3, 2],也是保持顺序的。


为了更方便调用还可以将这个方法写个数组扩展:

extension Array where Element: Hashable {
    var unique: Self {
        var newArray: Self = []
        forEach { ele in
            if !newArray.contains(ele) {
                newArray.append(ele)
            }
        }
        return newArray
    }
}


这样调用的时候就方便了:

let array: [Int] = [1, 1, 3, 3, 2, 2]
print(array.unique) // [1, 3, 2]


5、filter 高阶函数 + Set


Set 在插入元素的时候调用 insert 函数,这个函数返回一个元组,第一个值是一个 Bool 类型代表是否插入成功,如果已经包含了这个元素则不能插入成功,另一个是被插入的这个元素,这在 Set 的函数声明中可以看得出来:

func insert(_ newMember: Element) -> (inserted: Bool, memberAfterInsert: Element)


我们可以利用这个特性,再加上 filter 这个高阶函数,来给集合 Array 写一个扩展:

extension Array where Element: Hashable {
    var unique: Self {
        var seen: Set<Element> = []
        return filter { seen.insert($0).inserted }
    }
}


调用方法和上边的方式一样。


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

Swift - 闭包

iOS
定义 闭包是一个自包含的函数代码块,可以在代码中被传递和引用。闭包可以捕获和存储其所在上下文中任意常量和变量的引用**。 闭包的语法有三种形式:全局函数、嵌套函数和闭包表达式。 全局函数是一个有名字但不会捕获任何值的闭包潜逃函数是一个有名字并可以捕获其封闭函数...
继续阅读 »

定义


  • 闭包是一个自包含的函数代码块,可以在代码中被传递和引用
  • 闭包可以捕获和存储其所在上下文中任意常量和变量的引用**。

闭包的语法有三种形式:全局函数、嵌套函数和闭包表达式。


  • 全局函数是一个有名字但不会捕获任何值的闭包
  • 潜逃函数是一个有名字并可以捕获其封闭函数域内值的闭包
  • 闭包表达式是一个利用轻量级语法所写的可以捕获其上下文中变量或常量值的匿名闭包

闭包表达式


闭包表达式的一般形式

{ (parameters) -> return type in

statements

}

以数组的sorted(by:)方法为例

let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in
return s1 > s2
})


写成一行

names.sorted(by: { (s1: String, s2: String) -> Bool in return s1 > s2})

根据上下文推断类型


  • sorted(by:)方法被一个字符串数组调用,Swift 可以推断其参数和返回值的类型,因此其参数必须是 (String, String) -> Bool
  • 这意味着(String, String) 和 Bool 类型并不需要作为闭包表达式定义的一部分。因为所有的类型都可以被正确推断,返回箭头(->)和围绕在参数周围的括号也可以被省略:
names.sorted(by: { s1, s2 in return s1 > s2})

单表达式闭包的隐式返回


  • 单行表达式闭包可以通过省略 return 关键字来隐式返回单行表达式的结果
names.sorted(by: { s1, s2 in s1 > s2})

参数名称缩写


  • Swift 自动为内联闭包提供了参数名称缩写功能,你可以直接通过 $0$1$2 来顺序调用闭包的参数,以此类推。
  • 闭包接受的参数的数量取决于所使用的缩写参数的最大编号。
  • in 关键字也同样可以被省略,因为此时闭包表达式完全由闭包函数体构成:
names.sorted(by: {s1 > s2})

运算符方法


  • Swift 的 String 类型定义了关于大于号(>)的字符串实现,其作为一个函数接受两个 String 类型的参数并返回 Bool 类型的值。而这正好与 sorted(by:) 方法的参数需要的函数类型相符合。因此,你可以简单地传递一个大于号,Swift 可以自动推断找到系统自带的那个字符串函数的实现:
names.sorted(by: >)

尾随闭包


尾随闭包是一种特殊的闭包语法,它可以在函数调用的括号外部以简洁的方式提供闭包作为函数的最后一个参数。
使用尾随闭包的优势在于增加了代码的可读性和简洁性。当闭包作为函数的最后一个参数时,将闭包放在括号外部,可以使函数调用更加清晰,更接近于自然语言的阅读顺序。

func calculate(a: Int, b: Int, closure: (Int, Int) -> Int) {
let result = closure(a, b)
print(result)
}

// 调用函数时使用尾随闭包
calculate(a: 5, b: 3) { (x, y) -> Int in
return x + y
}

// 如果闭包只包含一个表达式,可以省略 return 关键字
calculate(a: 5, b: 3) { (x, y) in
x + y
}

// 省略参数的类型和括号
calculate(a: 5, b: 3) { x, y in
x + y
}

// 使用 $0, $1 等缩写形式代替参数名
calculate(a: 5, b: 3) {
$0 + $1
}


如果一个函数接受多个闭包,需要省略第一个尾随闭包的参数标签,并为其余尾随闭包添加标签。



值捕获


闭包可以在其被定义的上下文中捕获常量或变量。即使定义这些常量和变量的原作用域已经不存在,闭包仍然可以在闭包函数体内引用和修改这些值。



可以捕获值的闭包最简单的形式是嵌套函数,也就是定义在其他函数的函数体内的函数。嵌套函数可以捕获其外部函数所有的参数以及定义的常量和变量。



注意:



如果将闭包赋值给一个类实例的属性,并且该闭包通过访问该实例或其成员捕获了该实例,将会造成一个循环引用。



捕获列表


默认情况下,闭包会捕获附近作用域中的常量和变量,并使用强引用指向它们。你可以通过一个捕获列表来显示指定它的捕获行为。


捕获列表在参数列表之前,由中括号括起来,里面是由逗号分隔的一系列表达式。一旦使用了捕获列表,就必须使用in关键字,即使省略了参数名、参数类型和返回类型。


捕获列表中的项会在闭包创建时被初始化。每一项都会用闭包附近作用域中的同名常量或者变量的值初始化。例如下面的代码实例中,捕获列表包含a而不包含b,这将导致这两个变量有不同的行为。

var a = 0
var b = 0
let closure = { [a] in
print(a, b)
}

a = 10
b = 10
closure()
// 打印“0 10”

如果捕获列表中的值是类类型,可以使用weakunowned来修饰它,闭包会分别用弱引用、无主引用来捕获该值:

myFunction { print(self.title) }                    // 隐式强引用捕获
myFunction { [self] in print(self.title) } // 显式强引用捕获
myFunction { [weak self] in print(self!.title) } // 弱引用捕获
myFunction { [unowned self] in print(self.title) } // 无主引用捕获

在捕获列表中,也可以将任意表达式的值绑定到一个常量上。该表达式会在闭包被创建时进行求值,闭包会按照制定的引用类型来捕获表达式的值:

// 以弱引用捕获 self.parent 并赋值给 parent
myFunction { [weak parent = self.parent] in print(parent!.title) }

解决闭包的循环强引用


在定义闭包时同时定义捕获列表作为闭包的一部分,通过这种方式可以解决闭包和类实例之间的循环强引用。捕获列表定义了闭包体内捕获一个或者多个引用类型的规则。跟解决两个类实例间的循环强引用一样,声明每个捕获的引用为弱引用或无助引用,而不是强引用。应当根据代码关系来决定使用弱引用还是无主引用。


使用规则

  • 在闭包和捕获的实例总是互相引用并且同时销毁时,将闭包内的捕获定义为无主引用

  • 相反,在被捕获的引用可能会变为nil,将闭包内的捕获定义为弱引用,弱引用总是可选类型,并且当引用的实例被销毁后,弱引用的值会自动置为nil。这使我们可以在闭包体内检查它们是否存在


注意



如果被捕获的实例绝对不会变为nil,应该使用无主引用,而不是弱引用。



闭包是引用类型


无论你将函数和闭包赋值给一个常量还是变量,你实际上都是将常量或变量的值设置为对应函数或闭包的引用


逃逸闭包


当一个闭包作为参数传到一个函数中,但是这个闭包在函数之后才被执行,称该闭包从函数中逃逸


在参数名之前标注@escaping指明这个闭包是允许逃逸出这个函数。


一种能使闭包"逃逸"出函数的方法是,将这个闭包包存在一个函数外部定义的变量中。例子:很多异步操作的函数接受一个闭包参数作为completion handler。这类函数会在异步操作开始之后立刻返回,但是闭包直到异步操作结束后才会被调用。这种情况下,闭包需要"逃逸"出函数,因为闭包需要在函数返回之后被调用:

var completionHandlers: [() -> Void] = []
func someFunctionWithEscapingClosure(completionHandler: @escaping () -> Void) {
completionHandlers.append(completionHandler)
}

注意



将一个闭包标记为 @escaping 意味着你必须在闭包中显式地引用 self



自动闭包


自动闭包是一种自动创建的闭包,用于包装传递给函数作为参数的表达式。这种闭包不接受任何参数,当它被调用的时候,会返回被包装在其中的表达式的值。这种便利语法让你能够省略闭包的花括号,用一个普通的表达式来代替显式的闭包。

// customersInLine is ["Ewa", "Barry", "Daniella"]
func serve(customer customerProvider: @autoclosure () -> String) {
print("Now serving \(customerProvider())!")
}
serve(customer: customersInLine.remove(at: 0))
// 打印“Now serving Ewa!”

总结


Swift 的闭包有以下几个主要的知识点:


  1. 闭包表达式(Closure Expressions):闭包表达式是一种在简短的几行代码中完成自包含的功能代码块。比如数组的排序方法 sorted(by:)
  2. 尾随闭包(Trailing Closures):如果你需要将一个很长的闭包表达式作为一个函数的最后一个参数,使用尾随闭包是很有用的。尾随闭包是一个书写在函数或方法的括号之后的闭包表达式。
  3. 值捕获(Value Capturing):闭包可以在其定义的上下文中捕获和存储任何常量和变量的引用。这就是所谓的闭包的值捕获特性。
  4. 闭包是引用类型(Closures Are Reference Types):无论你将函数/方法或闭包赋值给一个常量还是变量,你实际上都是将引用赋值给了一个常量或变量。如果你对这个引用进行了修改,那么它将影响原始数据。
  5. 逃逸闭包(Escaping Closures):一个闭包可以“逃逸”出被定义的函数并在函数返回后被调用。逃逸闭包通常存储在定义了该闭包的函数的外部。
  6. 自动闭包(Autoclosures):自动闭包能让你延迟处理,因为代码段不会被执行直到你调用这个闭包。自动闭包很有用,用来包装那些需要被延迟执行的代码。

Swift 闭包和OC Block


相似点:


  1. 都是可以捕获和存储其所在上下文的变量和常量的引用的代码块。
  2. 都可以作为参数传递给函数或方法,或者作为函数或方法的返回值。
  3. 都可以在代码块中定义局部变量和常量。
  4. 都可以访问其被创建时所处的上下文环境。

区别:


  1. 语法:Swift 的闭包语法更简洁明了,使用大括号 {} 来定义闭包,而 Objective-C 的 Block 语法相对复杂,使用 ^ 符号和大括号 ^{} 来定义 Block。
  2. 内存管理:Objective-C 的 Block 对捕获的对象默认使用强引用,需要注意避免循环引用;而 Swift 的闭包对捕获的变量默认使用强引用,但通过使用捕获列表(capture list)可以实现对捕获变量的弱引用或无引用。
  3. 类型推断:Swift 的闭包对于参数和返回值的类型具有类型推断的能力,可以省略类型注解;而 Objective-C 的 Block 需要明确指定参数和返回值的类型。
  4. 逃逸闭包:Swift 可以将闭包标记为 @escaping,表示闭包可能会在函数返回之后才被调用;而 Objective-C 的 Block 默认是可以在函数返回后被调用的。

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

iOS实现宽度不同无限轮播图

iOS
背景 项目中需要实现一个不同宽度的图片的无限轮播图效果,而且每次滚动,只滚到下一个图片。由于业界实现的轮播图效果都是等宽图片,所以需要重新根据“以假乱真”的原理,设计一款不同宽度的轮播效果; 演示效果 底部是个collectionView,顶部盖了个透明的sc...
继续阅读 »

背景


项目中需要实现一个不同宽度的图片的无限轮播图效果,而且每次滚动,只滚到下一个图片。由于业界实现的轮播图效果都是等宽图片,所以需要重新根据“以假乱真”的原理,设计一款不同宽度的轮播效果;


演示效果


底部是个collectionView,顶部盖了个透明的scrollView,传入的数据源是:

NSArray *imageWidthArray = @[@(200), @(60), @(120)];



实现思路


  1. 传入一个存储图片宽度的数组,计算出屏幕可见的个数,比如下图,假如可见数为3个;

  2. 左、右两侧各有2个灰块,用于实现以假乱真的数据;(两侧各需生成的灰块数=屏幕可见数-1)

    • 比如当前看到123,左滑会滚到231,再左滑会滚到312,此时设置contentOffset,切到前面那个312;
    • 比如当前看到123,右滑会滚到312,再右滑会滚到231,此时设置contentOffset,切到后面那个231;
    1. 为了性能方面的考虑,使用的是collectionView;
    2. 关于每次滚动,只滚到下一个,实现方式则是在collectionView上面盖一个scrollView,设置其isPagingEnabled = YES; scrollView里面的页数和数据源保持一致(方便计算滚到哪个page);





完整的代码实现


Github Demo


ViewController:

#import "ViewController.h"
#import "MyCollectionViewCell.h"

#define padding 10.f
#define margin 16.f
#define scrollViewWidth (self.view.bounds.size.width - 2 * margin)
#define scrollViewHeight 200.f

@interface ViewController ()<UIScrollViewDelegate, UICollectionViewDelegate, UICollectionViewDataSource>

@property (nonatomic, strong) UICollectionView *collectionView;
@property (nonatomic, strong) UIScrollView *topScrollView;
@property (nonatomic, strong) UIPageControl *pageControl;
@property (nonatomic, strong) NSArray *imageWidthArray; // 用户传入,图片宽度数组
@property (nonatomic, assign) NSInteger canSeeViewCount; // 屏幕最多可见几个view
@property (nonatomic, strong) NSMutableArray *imageWidthMuArray;
@property (nonatomic, strong) NSMutableArray *imageContentOffsetXArray;
@property (nonatomic, strong) NSMutableArray *currentPageMuArray;

@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    [self setupViewWithImageWidthArray:@[@(200), @(60), @(120)]];
//    [self setupViewWithImageWidthArray:@[@(150), @(80),@(60), @(120)]];
}

-(void)setupViewWithImageWidthArray:(NSArray *)imageWidthArray {
    // 根据机型宽度,计算屏幕可见数量
    self.canSeeViewCount = imageWidthArray.count;
    CGFloat checkWidth = 0;
    for (NSInteger i = 0; i < imageWidthArray.count; i ++) {
        checkWidth += [imageWidthArray[i] floatValue];
        if (checkWidth >= scrollViewWidth) {
            self.canSeeViewCount = i + 1;
        }
    }

    self.imageWidthArray = imageWidthArray;
    self.imageContentOffsetXArray = [NSMutableArray arrayWithCapacity:self.imageWidthArray.count];

    // 插入头尾数据(前后插入可见数-1个)、生成currentPageMuArray
    self.imageWidthMuArray = [NSMutableArray array];
    self.currentPageMuArray = [NSMutableArray array];
    for (NSInteger i = self.imageWidthArray.count - (self.canSeeViewCount - 1); i < self.imageWidthArray.count; i ++) {
        [self.imageWidthMuArray addObject:self.imageWidthArray[i]];
        [self.currentPageMuArray addObject:@(i)];
    }
    [self.imageWidthMuArray addObjectsFromArray:self.imageWidthArray];

    for (NSInteger i = 0; i < self.imageWidthArray.count; i ++) {
        [self.currentPageMuArray addObject:@(i)];
    }

    for (NSInteger i = 0; i < (self.canSeeViewCount - 1); i ++) {
        [self.imageWidthMuArray addObject:self.imageWidthArray[i]];
        [self.currentPageMuArray addObject:@(i)];
    }

    CGFloat collectionViewContentSizeWidth = 0;
    for (NSInteger i = 0; i < self.imageWidthMuArray.count; i ++) {
        CGFloat imageWidth = [self.imageWidthMuArray[i] floatValue];
        if ( i > 0) {
            collectionViewContentSizeWidth += padding;
        }
        [self.imageContentOffsetXArray addObject:@(collectionViewContentSizeWidth)];
        collectionViewContentSizeWidth += imageWidth;
    }

    // collectionView
    UICollectionViewFlowLayout *flowLayout = [[UICollectionViewFlowLayout alloc] init];
    flowLayout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
    flowLayout.minimumInteritemSpacing = padding;
    flowLayout.minimumLineSpacing = padding;

    UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:CGRectMake(margin, 100, scrollViewWidth, scrollViewHeight) collectionViewLayout:flowLayout];
    [collectionView registerClass:[MyCollectionViewCell class] forCellWithReuseIdentifier:@"MyCollectionViewCell"];
    collectionView.dataSource = self;
    collectionView.delegate = self;
    collectionView.bounces = NO;
    collectionView.showsHorizontalScrollIndicator = NO;
    collectionView.backgroundColor = [UIColor brownColor];
    [self.view addSubview:collectionView];
    collectionView.contentSize = CGSizeMake(collectionViewContentSizeWidth, 0);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [collectionView setContentOffset:CGPointMake([self.imageContentOffsetXArray[self.canSeeViewCount - 1] floatValue], 0)];
    });
    self.collectionView = collectionView;

    // topScrollView
    UIScrollView *topScrollView = [[UIScrollView alloc] initWithFrame:collectionView.frame];
    topScrollView.showsHorizontalScrollIndicator = NO;
    [topScrollView setPagingEnabled:YES];
    topScrollView.backgroundColor = [UIColor clearColor];
    topScrollView.delegate = self;
    topScrollView.bounces = NO;
    [self.view addSubview:topScrollView];
    self.topScrollView = topScrollView;
    topScrollView.contentSize = CGSizeMake(self.imageWidthMuArray.count * scrollViewWidth, 0);
    [topScrollView setContentOffset:CGPointMake((self.canSeeViewCount - 1) * scrollViewWidth, 0)];

    // pageControl
    CGFloat pageControlHeight = 50.f;
    UIPageControl *pageControl = [[UIPageControl alloc] initWithFrame:CGRectMake(margin, 100 + scrollViewHeight-pageControlHeight, scrollViewWidth, pageControlHeight)];
    pageControl.numberOfPages = self.imageWidthArray.count;
    pageControl.currentPage = 0;
    [self.view addSubview:pageControl];
    self.pageControl = pageControl;
}

#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    if (scrollView == self.collectionView) {
        return;
    }

    // 页面整数部分
    NSInteger floorPageIndex = floor(scrollView.contentOffset.x / scrollView.frame.size.width);

    // 小数部分
    CGFloat pageRate = scrollView.contentOffset.x / scrollView.frame.size.width - floor(scrollView.contentOffset.x / scrollView.frame.size.width);
    CGFloat imageContentOffsetX = [self.imageContentOffsetXArray[floorPageIndex] floatValue];
    CGFloat imageWidth = [self.imageWidthMuArray[floorPageIndex] floatValue];
    self.collectionView.contentOffset = CGPointMake(imageContentOffsetX + (imageWidth + 10.f) * pageRate, 0);
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    NSInteger rightIndex = (self.canSeeViewCount - 1) + (self.imageWidthArray.count) - 1;
    NSInteger leftIndex = (self.canSeeViewCount - 1) - 1;

    // 右边卡到尾时
    if (self.collectionView.contentOffset.x == [self.imageContentOffsetXArray[rightIndex] floatValue]) {
        [self.collectionView setContentOffset:CGPointMake([self.imageContentOffsetXArray[leftIndex] floatValue], 0)];
    }

    // 左边卡到头时
    else if (self.collectionView.contentOffset.x == 0) {
        [self.collectionView setContentOffset:CGPointMake([self.imageContentOffsetXArray[self.imageWidthArray.count] floatValue], 0)];
    }

    // 右边卡到尾时
    if (self.topScrollView.contentOffset.x == scrollViewWidth * rightIndex) {
        [self.topScrollView setContentOffset:CGPointMake(scrollViewWidth * leftIndex, 0)];
    }

    // 左边卡到头时
    if (self.topScrollView.contentOffset.x == 0) {
        [self.topScrollView setContentOffset:CGPointMake(scrollViewWidth * self.imageWidthArray.count, 0)];
    }

    // 设置currentPage
    NSInteger floorPageIndex = floor(scrollView.contentOffset.x / scrollView.frame.size.width);
    self.pageControl.currentPage = [self.currentPageMuArray[floorPageIndex] intValue];
}

#pragma mark - UICollectionViewDelegate, UICollectionViewDataSource
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return self.imageWidthMuArray.count;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    MyCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"MyCollectionViewCell" forIndexPath:indexPath];
    cell.labelText = [NSString stringWithFormat:@"%.0f", [self.imageWidthMuArray[indexPath.item] floatValue]];
    return cell;
}

- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    return CGSizeMake([self.imageWidthMuArray[indexPath.item] floatValue], scrollViewHeight);
}

@end

MyCollectionViewCell:

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface MyCollectionViewCell : UICollectionViewCell

@property (nonatomic, copy) NSString *labelText;

@end

NS_ASSUME_NONNULL_END
#import "MyCollectionViewCell.h"
#import "Masonry.h"

@interface MyCollectionViewCell()

@property (nonatomic, strong) UILabel *label;

@end

@implementation MyCollectionViewCell

- (instancetype)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        [self setupUI];
    }
    return self;
}

- (void)setupUI {
self.backgroundColor = [UIColor grayColor];
    UILabel *label = [[UILabel alloc] init];
    label.textAlignment = NSTextAlignmentCenter;
    label.font = [UIFont boldSystemFontOfSize:18];
    [self.contentView addSubview:label];
    [label mas_makeConstraints:^(MASConstraintMaker *make) {
        make.edges.equalTo(self.contentView);
    }];
    self.label = label;
}

- (void)setLabelText:(NSString *)labelText {
    _labelText = labelText;
    self.label.text = labelText;
}

-(void)prepareForReuse {
    [super prepareForReuse];
    self.label.text = @"";
}
@end

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

升级Xcode 15后,出现大量Duplicate symbols问题的解决方案

升级到Xcode 15后,原先Xcode14可以编译的项目出现大量Duplicate symbols,且引用报错指向同一个路径(一般为Framework)下的同一个文件。经过查找相关资料,查到可通过在Xcode -> Target -> Build...
继续阅读 »

升级到Xcode 15后,原先Xcode14可以编译的项目出现大量Duplicate symbols,且引用报错指向同一个路径(一般为Framework)下的同一个文件。经过查找相关资料,查到可通过

在Xcode -> Target -> Build Setting -> Other Linker Flags 添加一行"-ld64"

即可解决该问题

原因是Xcode15采用了新的链接器(Linker),被称作“ld_prime”。新的连接器有诸多好处,尤其是对合并库的支持方面,具体可以查看WWDC 2023 SESSION 10268 Meet mergeable libraries.。然而,链接器的升级可能会出现不兼容老库的情况出现。遇到这种情况,可以通过恢复旧的连接器来解决这个问题。从Other Linker Flags添加"-ld64"后,就会覆盖Xcode编译时选择的链接器,因此可以正常访问。

收起阅读 »

iOS 开发:分享一个可以提高开发效率的技巧

iOS
前言 在日常的开发中,要想提高开发效率,重要的是要集中精力,今天来讲一个我自己日常在用的方法,我认为提高了我的开发效率,大家也可以尝试一下。 我们做开发都很讨厌写代码的过程中被打断,可能你在找一个 bug,或者在做一个很难的需求,好不容易有了思路,结果一被打断...
继续阅读 »

前言


在日常的开发中,要想提高开发效率,重要的是要集中精力,今天来讲一个我自己日常在用的方法,我认为提高了我的开发效率,大家也可以尝试一下。


我们做开发都很讨厌写代码的过程中被打断,可能你在找一个 bug,或者在做一个很难的需求,好不容易有了思路,结果一被打断,思路全忘了。所以在进入开发前,我会尽可能的把可能打断我的的因素屏蔽掉。比如我会关掉社交软件(尤其是微信),关掉软件推送。然后每过两个小时左右上一次社交软件,集中去处理消息,处理完了退掉继续工作


使用 Xcode 的时候我会开启全屏模式,这可以帮助我集中注意力,而不会分散其他应用程序的注意力,接下来讲讲如何把 Xcode 和模拟器同时进入全屏模式。


Xcode 和模拟器并行的全屏模式


最终的全屏模式如图所示,整个屏幕只有左边是 Xcode,右边是模拟器(当然你也可以调整顺序)。



这是一个能让你完全专注的环境,不被顶部的菜单栏和底部的程序坞栏内容分散注意力。


设置全屏只需这样操作:

  1. 打开 Xcode 和模拟器

  2. 点击 Xcode 左上角第三个按钮,开启全屏,或者使用快捷键 control + command + F

  3. 点击快捷键 control + ⬆️上箭头 打开程序控制,或者使用触控板上的四个手指向上滑动。

  4. 然后将你的模拟器拖入到屏幕顶部 Xcode 所在的窗口中,当拖动到窗口左侧或者右侧时,会显示一个加号,放置在上面即可

  5. 最后点击 Xcode 和模拟器所在的窗口就完成了



最后


保持专注是写好代码和提高效率的一种途径,我见过一些程序员一边写代码,一边还在用手机刷剧,这种写出的代码质量不可能很高,一心二用的开发效率也是很低的。


保持专注本身就是一种技能,刚开始你可能会觉得不习惯(没有微信消息、没有热点资讯),但当你适应了之后,你就会发现你的代码质量和效率都有一定提升,而省下来的时间足以做更多的事情了。


而且我发现每两个小时集中处理一次消息的策略还可以让处理信息的质量变高,比如以前在写代码的时候来了一条微信消息,你点开之后发现不是很重要,可以稍后再回,就先去写代码了,但是当你写完代码时可能已经忘记了回微信消息的事情(因为这条消息已经是已读状态了)。而集中处理可以把未读消息集中处理掉,不容易遗漏。


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

iOS小技能:Xcode13的使用技巧

iOS
引言 Xcode13新建项目不显示Products目录的解决方案Xcode13新建的工程恢复从前的Info.plist同步机制的方法自动管理签名证书时拉取更新设备描述文件的方法。 I 显示Products目录的解决方案 问题:Xcode13 新建的项目不显示P...
继续阅读 »

引言


  1. Xcode13新建项目不显示Products目录的解决方案
  2. Xcode13新建的工程恢复从前的Info.plist同步机制的方法
  3. 自动管理签名证书时拉取更新设备描述文件的方法。

I 显示Products目录的解决方案


问题:Xcode13 新建的项目不显示Products目录


解决方式: 修改project.pbxproj 文件的productRefGroup配置信息


效果:

应用场景:Products目录的app包用于快速打测试包。


1.1 从Xcodeeproj 打开project.pbxproj



1.2 修改productRefGroup 的值


将mainGroup 对应的值复制给productRefGroup 的值,按command+s保存project.pbxproj文件,Xcode将自动刷新,Products目录显示出来了。



1.3 应用场景


通过Products目录快速定位获取真机调试包路径,使用脚本快速打包。


打包脚本核心逻辑:在含有真机包路径下拷贝.app 到新建的Payload目录,zip压缩Payload目录并根据当前时间来命名为xxx.ipa。

#!/bin/bash
echo "==================(create ipa file...)=================="
# cd `dirname $0`;
rm -rf ./Target.ipa;
rm -rf ./Payload;
mkdir Payload;
APP=$(find . -type d | grep ".app$" | head -n 1)
cp -rf "$APP" ./Payload;
data="`date +%F-%T-%N`"
postName="$data"-".ipa"
zip -r -q "$postName" ./Payload;
rm -rf ./Payload;
open .
# 移动ipa包到特定目录
mkdir -p ~/Downloads/knPayload
cp -a "$postName" ~/Downloads/knPayload
open ~/Downloads/knPayload
echo "==================(done)=================="
exit;






II 关闭打包合并Info.plist功能


Xcode13之前Custom iOS Target Properties面板和Info.plist的配置信息会自动同步。


Xcode13新建的工程默认开启打包合并Info.plist功能,不再使用配置文件(Info.plist、entitlements),如果需要修改配置,直接在Xcode面板target - Info - Custom iOS Target Propertiesbuild settings中设置。




Projects created from several templates no longer require configuration files such as entitlements and Info.plist files. Configure common fields in the target’s Info tab, and build settings in the project editor.



2.1 设置Info.plist为主配置文件


由于GUI配置面板没有配置文件plist的灵活,不支持查看源代码。所以我们可以在BuildSetting Generate Info.plist File设置为NO,来关闭打包合并功能。



关闭打包合并功能,重启Xcode使配置生效,Custom iOS Target Properties面板的信息以info.plist的内容为准。



每次修改info.plist都要重启Xcode,info.plist的信息才会同步到Custom iOS Target Properties面板。 



2.2 注意事项


注意: 关闭打包合并Info.plist功能 之前记得先手动同步Custom iOS Target Properties面板的信息到Info.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>iOS逆向</string>
<key>CFBundleIdentifier</key>
<string>blog.csdn.net.z929118967</string>
<key>CFBundleName</key>
<string>YourAppName</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict>
<key>UILaunchScreen</key>
<dict/>
</dict>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~iphone</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>


III 自动管理签名证书时如何拉取最新设备描述文件?


方法:根据描述文件的创建时间来删除旧的自动管理证书的描述文件



 



原理:在~/Library/MobileDevice/Provisioning\ Profiles 文件夹中删除之前的描述文件,然后系统检测到没有描述文件则会自动生成一个新的


see also


iOS第三方库管理规范:以Cocoapods为案例



kunnan.blog.csdn.net/article/det…



iOS接入腾讯优量汇开屏广告教程



kunnan.blog.csdn.net/article/det…


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

开发没切图怎么办?矢量图标(iconFont)上手指南

iOS
需求: 有时候我们自己想独立开发一些App,但苦恼没有设计给icon切图? 这可怎么办? 今天我们来介绍一种比较高效且高质量的替代方案:使用矢量图标 —— iconFont。 一、iconFont简介 iconFont:是阿里巴巴提供的一个矢量图标库。简单...
继续阅读 »

需求:

有时候我们自己想独立开发一些App,但苦恼没有设计给icon切图?

这可怎么办?

今天我们来介绍一种比较高效且高质量的替代方案:使用矢量图标 —— iconFont



一、iconFont简介



iconFont:是阿里巴巴提供的一个矢量图标库。简单来说,就是可以把icon转换成font,再通过文本展示出来。官网链接

支持:WebiOSAndroid平台使用。



二、iOS端简单使用指南


第一步:


登录iconFont,挑选你需要的icon,并把它们加入购物车,下载代码。

  • 挑选统一风格的icon

    • 全局搜索想要的icon

    • 将需要使用的icon加入到购物车

    • 下载代码




第二步:


解压下载的压缩包,注意demo_index.htmliconFont.ttf文件。打开工程将ttf导入到项目中,并在info.plist中配置。


  • 压缩文件,找到demo_index.htmliconFont.ttf



  • iconFont.ttf文件导入项目:



第三步:


打开demo_index.html预览iconFont所对应的Unicode编码。并在项目中应用。


  • 打开demo_index.html文件


  • swift使用方法如下,用格式\u{编码}使用Unicode编码
//...
label.font = UIFont.init(name: "iconFont", size: 26.0)
label.text = "\u{e658}"
//...

  • Objective-C使用方法如下,用格式\U0000编码使用Unicode编码
//...
label.font = [UIFont fontWithName:@"uxIconFont" size: 34];;
label.text = @"\U0000e658";
//...

这样,在没有设计提供切图的情况下,就可以用LabeliconFont字体代替切图达成ImageView的效果了。


三、iconFont原理


先把icon通过像素点描述成自定义字体(svg格式字体),然后打包成ttf格式的文件,再通过对应的unicode对应到相关的icon


四、可能遇到的一些问题


  • ttf文件导入冲突问题:

由于从iconFont上打包生成的ttf文件,字体名均为“iconFont”,因此从官网上下载的ttf文件,字体名均为“iconFont”。因此多ttf文件引入时,会有冲突。


解决方案:用一些工具修改字体名,再导入多个ttf文件。(记得在info.plist文件里配置)


  • Unicode变化问题:

尽量使用一个账号下载ttf资源,不同的环境下可能会导致生成的Unicode不同。从而给项目替换icon带来成本。


  • 版权问题:

iconFont目前应该不支持商用,除非有特别的许可。
自己独立写一些小项目的时候可以使用。


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