普通视图

发现新文章,点击刷新页面。
昨天 — 2025年2月16日掘金 iOS

C 语言是如何编译执行的?

2025年2月16日 14:06

C 语言的编译和执行过程分为 预处理、编译、汇编、链接 四个阶段,最终生成可执行文件。


1. 预处理(Preprocessing)

  • 作用:处理源代码中的预处理指令(如 #include#define 等),生成纯 C 代码。
  • 输入文件.c(源文件)
  • 输出文件.i(预处理后的文件)
  • 关键操作
    • 展开头文件(#include
    • 替换宏(#define
    • 条件编译(#ifdef#ifndef
  • 命令示例
    gcc -E main.c -o main.i
    

2. 编译(Compilation)

  • 作用:将预处理后的代码转换为汇编代码。
  • 输入文件.i(预处理后的文件)
  • 输出文件.s(汇编文件)
  • 关键操作
    • 词法分析 → 语法分析 → 语义分析 → 中间代码生成 → 代码优化 → 汇编代码生成
  • 命令示例
    gcc -S main.i -o main.s
    

3. 汇编(Assembly)

  • 作用:将汇编代码转换为机器指令(目标文件)。
  • 输入文件.s(汇编文件)
  • 输出文件.o(目标文件)
  • 关键操作
    • 将汇编指令逐行翻译为机器码。
  • 命令示例
    gcc -c main.s -o main.o
    

4. 链接(Linking)

  • 作用:将多个目标文件(如库文件、其他模块)合并为最终可执行文件。
  • 输入文件.o(目标文件) + 库文件(如 libc.a
  • 输出文件:可执行文件(如 a.out
  • 关键操作
    • 符号解析(解决函数和变量的引用)。
    • 地址重定位(分配内存地址)。
  • 命令示例
    gcc main.o -o main
    

完整流程

# 一步完成所有阶段
gcc main.c -o main

# 分步执行
gcc -E main.c -o main.i    # 预处理
gcc -S main.i -o main.s    # 编译
gcc -c main.s -o main.o    # 汇编
gcc main.o -o main         # 链接

执行程序

  • 运行可执行文件:
    ./main
    

接下来演示如何将多个目标文件、静态库和动态库合并为最终的可执行文件。假设项目结构如下:

project/
├── main.c          # 主程序
├── modules/
│   ├── utils.c     # 工具模块
│   └── math.c      # 数学模块
├── libs/
│   ├── static/     # 静态库源码
│   │   └── helper.c
│   └── dynamic/    # 动态库源码
│       └── algo.c
└── headers/        # 头文件
    ├── utils.h
    ├── math.h
    ├── helper.h
    └── algo.h

步骤 1:编写代码

1.1 main.c

#include <stdio.h>
#include "utils.h"
#include "math.h"
#include "helper.h"
#include "algo.h"

int main() {
    print_message("Starting complex example");
    
    int a = 10, b = 5;
    printf("Add: %d\n", add(a, b));
    printf("Multiply: %d\n", multiply(a, b));
    
    static_function();    // 来自静态库
    dynamic_function();   // 来自动态库
    
    return 0;
}

1.2 modules/utils.c

#include "utils.h"

void print_message(const char* msg) {
    printf("[LOG] %s\n", msg);
}

1.3 modules/math.c

#include "math.h"

int add(int a, int b) {
    return a + b;
}

int multiply(int a, int b) {
    return a * b;
}

1.4 libs/static/helper.c

#include "helper.h"

void static_function() {
    printf("This is a static library function\n");
}

1.5 libs/dynamic/algo.c

#include "algo.h"

void dynamic_function() {
    printf("This is a dynamic library function\n");
}

步骤 2:编译目标文件

# 编译主程序和模块
gcc -c main.c -Iheaders -o main.o
gcc -c modules/utils.c -Iheaders -o utils.o
gcc -c modules/math.c -Iheaders -o math.o

# 编译静态库
gcc -c libs/static/helper.c -Iheaders -o helper.o
ar rcs libhelper.a helper.o  # 创建静态库 libhelper.a

# 编译动态库
gcc -c -fPIC libs/dynamic/algo.c -Iheaders -o algo.o
gcc -shared algo.o -o libalgo.so  # 创建动态库 libalgo.so

步骤 3:链接所有文件

gcc \
  main.o utils.o math.o \
  -L. -lhelper -L. -lalgo \
  -Iheaders \
  -Wl,-rpath=./  # 指定运行时动态库搜索路径
  -o final_program

关键参数解释

  • -L.:指定库文件的搜索路径(当前目录)
  • -lhelper:链接静态库 libhelper.a
  • -lalgo:链接动态库 libalgo.so
  • -Wl,-rpath=./:告诉可执行文件运行时在 ./ 目录查找动态库

步骤 4:运行程序

# 确保动态库路径可用
export LD_LIBRARY_PATH=./:$LD_LIBRARY_PATH

# 执行程序
./final_program

输出结果

[LOG] Starting complex example
Add: 15
Multiply: 50
This is a static library function
This is a dynamic library function

如何自动化

自动化编译和链接可以通过多种工具和技术实现,以下是一些常见的方法,适用于不同规模和复杂度的项目。以之前的示例项目为基础,逐步说明如何实现自动化。


方法 1:使用 Makefile 自动化

1.1 编写 Makefile

# 定义变量
CC = gcc
CFLAGS = -Iheaders -Wall -Wextra
LDFLAGS = -L. -lhelper -L. -lalgo -Wl,-rpath=./

# 目标文件
OBJS = main.o utils.o math.o

# 默认目标
all: final_program

# 主程序依赖项
final_program: $(OBJS) libhelper.a libalgo.so
$(CC) $^ -o $@ $(LDFLAGS)

# 生成静态库
libhelper.a: helper.o
ar rcs $@ $^

# 生成动态库
libalgo.so: algo.o
$(CC) -shared $^ -o $@

# 通用编译规则(自动推导 .c → .o)
%.o: %.c
$(CC) -c $< $(CFLAGS) -o $@

# 清理生成文件
clean:
rm -f *.o *.a *.so final_program

.PHONY: all clean

1.2 使用命令

# 一键编译并链接
make

# 清理生成的文件
make clean

方法 2:使用 CMake 自动化(跨平台推荐)

2.1 编写 CMakeLists.txt

cmake_minimum_required(VERSION 3.10)
project(ComplexExample)

# 设置头文件目录
include_directories(headers)

# 添加主程序和模块
add_executable(final_program
  main.c
  modules/utils.c
  modules/math.c
)

# 添加静态库(helper)
add_library(helper STATIC libs/static/helper.c)
target_link_libraries(final_program helper)

# 添加动态库(algo)
add_library(algo SHARED libs/dynamic/algo.c)
target_link_libraries(final_program algo)

# 设置动态库输出路径
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})

2.2 使用命令

# 生成构建系统(如 Unix Makefiles)
mkdir build
cd build
cmake ..

# 编译和链接
make

# 运行程序
./final_program

方法 3:使用 Shell 脚本自动化

3.1 编写 build.sh

#!/bin/bash

# 编译目标文件
gcc -c main.c -Iheaders -o main.o
gcc -c modules/utils.c -Iheaders -o utils.o
gcc -c modules/math.c -Iheaders -o math.o

# 编译静态库
gcc -c libs/static/helper.c -Iheaders -o helper.o
ar rcs libhelper.a helper.o

# 编译动态库
gcc -c -fPIC libs/dynamic/algo.c -Iheaders -o algo.o
gcc -shared algo.o -o libalgo.so

# 链接所有文件
gcc main.o utils.o math.o -L. -lhelper -lalgo -Wl,-rpath=./ -o final_program

# 清理中间文件(可选)
rm -f *.o

echo "Build completed! Run with: ./final_program"

3.2 使用命令

# 赋予执行权限
chmod +x build.sh

# 运行脚本
./build.sh

自动化工具对比

工具 适用场景 优点 缺点
Makefile 中小型项目、Unix/Linux 环境 灵活、高度可配置 语法复杂,跨平台支持弱
CMake 跨平台项目、大型工程 支持多种生成器(如 VS、Xcode) 学习曲线较陡
Shell 快速简单任务 无需额外工具 难以处理复杂依赖关系

扩展自动化场景

  1. 自动测试集成
    MakefileCMakeLists.txt 中添加 test 目标,运行单元测试:

    test: final_program
        ./final_program --test
    
  2. 版本控制集成
    结合 Git Hook,在提交代码前自动编译验证:

    # .git/hooks/pre-commit
    #!/bin/sh
    make && ./final_program --smoke-test
    
  3. 持续集成(CI)
    在 GitHub Actions 或 GitLab CI 中配置自动化流程:

    # .github/workflows/build.yml
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - name: Build
            run: |
              make
              ./final_program --test
    

通过以上方法,可以显著减少手动操作,提升开发效率并降低错误率。

iOS扫码组件优化

作者 楞楞
2025年2月16日 13:27

技术调研

问题背景

一句话:商户打印的条形码和二维码不规范,同时现有组件的技术比较老,导致扫码识别效率低。

  1. 商户打印条形码和二维码编码格式,尺寸和边框距离等影响因素不统一,同时打印机有误差,导致部分残次二维码识别不出来。
  2. 部分场景需要同时识别条形码和二维码,且大小不一样,造成识别效率较低。
  3. 机型方面,考虑到目前优化的是质检小工具app,三大仓机器主要为iphone12及以上,优先兼容高版本机器,之后再考虑低版本机器。

目标

提高现有扫码模块的识别效率,从识别花费时间,识别率,兼容的条码类型提高

技术背景

条形码和二维码对比

特性 条形码 二维码
样式:123456789
数据存储能力 容量小,通常只能存储20个字符左右 容量大,可存储数千个字符
数据类型 数字和有限的字母字符(如Code 39、Code 128) 数字、字母、汉字、URL等多种信息
物理尺寸 需要较长的物理空间 数据密度高,占用空间小
读取设备 专用条码扫描仪,带摄像头设备 智能手机、平板电脑等带摄像头的设备
读取方向 只能从一个方向(通常是水平)读取 可以从多个方向(360度)读取
读取速度 读取速度快,适合少量信息 读取速度快,即使包含大量数据
准确性 对污损和损坏容错性较低 对污损和部分损坏容错能力高,能够纠错和恢复数据
常见应用 零售商品标识、库存管理、图书馆书籍管理、物流标签等 移动支付、电子票务、信息链接、产品追溯、营销广告等
标签成本 印刷成本低,标签制作简单 印刷成本略高,但相差不大
实施难度 系统集成和使用成本较低 需要较高的技术支持和设备(如智能手机应用)
优点 成本低,实施简单,读取速度快,适合编码少量信息的应用场景 数据容量大,灵活性高,读取方向不限,容错能力强
缺点 数据容量有限,物理尺寸较大,容错性差,对污损和损坏敏感 标签制作成本稍高,实施需要一定的技术支持

条形码生成原理与格式

条形码通常由以下几个部分组成:

  • 起始符:标志条形码的起始部分,帮助扫描器定位和确定读取方向。
  • 数据部分:包含实际的信息,通过黑白条纹的组合表示。
  • 校验码:用于验证条形码数据的正确性。
  • 终止符:标志条形码的结束部分。

下面是多个黑线和白线如何组成一个字符的原理,以code128为例子:

  • 具有A、B、C三种不同的编码类型,可提供标准ASCII中128个字元的编码使用
  • 允许双向扫描(正着反着都能扫)
  • 可自行决定是否加上检验位(但付款码必须要校验位)

为了方便表达,我们用b代表一条黑色像素宽度的线,s代表一条白色像素宽度的线,当bs组合时,中间不留任何缝隙,单个b和s对应的线的宽度是固定的。

ABC三种编码的部分字元的编码表如下:

BandCode是Code 128的核心编码,表示条纹和空白的宽度。每个字符由6个数字组成,每个数字表示对应条或空白的宽度(1、2或3个模块)。例如,ID 0的BandCode为“212222”,表示:

• 条:宽度2

• 空白:宽度1

• 条:宽度2

• 空白:宽度2

• 条:宽度2

• 空白:宽度2

条形码的识别原理

条形码识别是通过检测图像中条形码的黑白条纹,并将这些条纹转换为数字或字母信息。条形码的识别过程可以分为以下几个步骤:

  1. 图像捕获
  2. 图像预处理操作
    灰度化:将彩色图像转换为灰度图像,以简化后续处理。
    二值化:将灰度图像转换为黑白图像,突出条形码的黑白条纹。
    去噪:移除图像中的杂点和噪声,确保条纹清晰。
  3. 特征提取,提取条形码的关键特征,即黑白条纹的宽度和排列顺序。

特征提取步骤包括:

扫描条纹:从左到右(或从上到下)扫描条形码,记录黑条和白条的宽度。例如,记录为一系列宽度值:[2, 1, 3, 1, 2, …]。

识别模式:根据条纹的宽度和组合识别起始符、中间分隔符、数据位和校验位。

  1. 解码

根据提取的特征,将条纹和空白的组合转换为对应的数字或字符。

解码步骤包括:

匹配编码表:每种条形码标准(如UPC、EAN)有其特定的编码表,将扫描到的条纹组合与编码表进行匹配,转换为相应的数字。例如,条纹组合“黑-白-黑-白”对应字符“C”。

校验验证:使用校验位验证解码的数据是否正确,确保识别的准确性。

二维码生成原理和格式

  1. 数据编码

将输入的字符串或数据(如文本、URL)转换为二进制数据。

二维码支持多种编码模式:数字、字母、二进制等。

  1. 纠错编码

使用**纠错码(Reed-Solomon编码)**将数据编码为可以恢复的形式,即使二维码部分损坏也能恢复。

不同的纠错级别(L、M、Q、H)对应不同的容错能力。

  1. 数据结构化

将编码后的数据放入特定的结构中,包括定位符(用于定位二维码位置)、格式信息(包含纠错级别和掩码模式)、版本信息(定义二维码的大小)等。

  1. 掩码应用

为减少图像噪声和提升识别率,二维码图案应用特定的掩码算法(如按特定规则翻转部分黑白块)。

  1. 绘制二维码图像

根据上述步骤生成的二维码矩阵绘制图像,最终形成二维码。

二维码的识别原理

  1. 图像采集
  2. 图片预处理:灰度化,二值化,噪音去除
  3. 二维码定位:定位图案,定位区域确定
  4. 二维码结构分析:数据区域提取
  5. 数据解码:错误检测与纠正,解码数据
  6. 输出结果

识别效果影响因素

  1. 打印的条码是否规范:

下面这个条码就比较不规范,下面图片中的条形码1中的红色线指向的黑线,很明细非常细,这导致条形码根据图纸解析出来的编码,无法找到正确解码。

条形码1

条形码2

  1. 印刷材料和表面处理

材料:不同材料的反光度和吸墨性会影响扫描效果。例如,光滑或反光的材料可能导致反射光干扰扫描。

表面光洁度:粗糙或不均匀的表面可能导致条码/二维码的图案变形,影响识别。

  1. 环境条件

强光,阴影会干扰扫描设备的读取

  1. 位置和角度

安装位置:条码/二维码的位置不当(如弯曲、折叠处)会影响扫描设备的读取。

角度:条码/二维码与扫描设备的角度不合适可能导致部分信息无法被识别。理想的扫描角度通常是垂直于条码/二维码表面。

  1. 扫描设备性能

高分辨率和高灵敏度的扫描设备能够更准确地读取复杂或微小的条码/二维码。

设备的扫描速度影响快速移动物体上的条码/二维码的识别效率。

  1. 软件算法

先进的图像处理和解码算法能够提高在复杂环境下的识别率,减少误读和漏读。例如微信扫码,就针对破损条形码做了特殊优化。

  1. cpu和gpu

不分算法可能会使用到机器学习等算法,将会导致识别效果和处理器能力有关。

现有扫码能力

  1. AVFoundation
  2. ZbarSDK
  3. ZZ 封装的 ZbarSDK
  4. ZXingObjc

方案选择

库名 优点 缺点 语言支持 支持条码类型
AVFoundation - 原生框架,性能稳定 - 支持广泛的条码格式 - 不依赖外部库 - 需要较多代码处理界面和逻辑 - 扫描界面自定义复杂 Swift, Objective-C QR, Code128, EAN, UPC 等多种类型
ZBarSDK - 历史悠久,曾广泛使用 - 轻量级库,支持多种格式 - 已停止维护,不再更新 - 在最新 iOS 版本上可能存在兼容性问题 Objective-C QR, EAN, UPC 等
ZXingObjC - 支持多种条码格式 - 轻量级开源库 - 支持多平台(iOS 和 Android) - 依赖 Objective-C,可能对纯 Swift 项目不够理想 - 集成配置略复杂 Swift, Objective-C QR, EAN, UPC, Code128, PDF417, Data Matrix 等
Vision Framework - 原生库,集成简单 - 利用机器学习,准确性较高 - 仅支持 iOS 11 及以上版本 - 仅支持二维码和条形码 Swift, Objective-C QR, EAN, UPC
MTBBarcodeScanner - 易于使用,快速集成 - 提供友好的 API,支持自定义扫描区域 - 仅限于条形码和二维码 - 社区支持有限,功能较简单 Swift, Objective-C QR, EAN, UPC
RSBarcodes_Swift - 支持生成和识别多种条码 - 简单易用 - 支持自定义扫描界面 - 较少的社区支持 - 功能更新缓慢 Swift QR, Code128, EAN, PDF417 等
BarcodeScanner - 易于集成,API 友好 - 扫描性能较好 - 主要支持二维码和少量条形码类型 - 社区支持较少 Swift QR, EAN, UPC
Scandit SDK - 商业化 SDK,性能极高 - 支持非常多条码格式,企业级解决方案 - 强大的文档支持 - 收费库,价格较高 - 集成较复杂 Swift, Objective-C 支持市面上几乎所有条形码类型
Dynamsoft Barcode Reader - 企业级解决方案,支持多种条码格式 - 高速扫描性能,支持模糊和受损条码 - 文档详细,商业支持强 - 收费库 - 集成复杂 Swift, Objective-C QR, EAN, UPC, Data Matrix, PDF417 等
Google ML Kit (Barcode Scanning) - Google 提供的机器学习框架,稳定且功能强大 - 跨平台支持 - 功能较为基础,条码类型有限 - 依赖 Google 的 SDK Swift, Objective-C QR, EAN, UPC, PDF417

demo验证

转转封装的zbar

教程:dashen.zhuanspirit.com/pages/viewp…

  1. 注意使用特殊的分支,而不是master分支,建议使用下面的分支
pod 'ModuleScanner/Core', :git => 'http://gitlab.zhuanspirit.com/zz-ios-open/ios_module_scanner.git', :branch => 'ios_module_scanner_test', :modular_headers => true
  1. 注意本地的zbarSDk版本
pod 'ZBarSDK' ,                  :git=>'git@gitlab.zhuanspirit.com:zz-zlj-ios/thirdFrameworks/ZBarSDK.git', :tag => '1.3.6', :modular_headers => true

使用系统能力 AvFoundation+ZXing 辅助解析

iOS扫码优化-原生解析+ZXing辅助解析iOS扫码组件的优化,使用原生AV框架解析的同时,用ZXing解码库辅助解析 - 掘金 (juejin.cn)

使用apple 自带的 vision 视觉识别库,底层使用了机器学习

Apple Vision

先说结论:

识别效果不错,且支持大部分的二维码和条形码

但是在低端机器上,识别破损条形码的表现欠佳,例如iphone7,iphone xr max,和其他的几个算法没有拉开差距。

iphone13pro 扫码 效果不错

iphone7 扫码 效果一般 (这个后续了解到,vision支持的最低版本是ios11,而iOS11最早搭载与iphone8)

可能的原因:

  1. 摄像头质量较差,分辨率和低光性能比后续设备较为落后
  2. 算力和性能较差,Vision框架计算依赖于CPU和GPU
  3. 在就设备上,即使使用了最新的算法,受限性能瓶颈,无法重复利用这些优化

下面是潜在的一些坑:

  1. 性能问题,低端机识别性能
不同机型 不同破损情况条形码

各机型分布:

结论:高端机型上的识别效果差不多

设备型号 活跃设备数 条形码识别数量(总3) 二维码识别数量(总3)
苹果iPhone Xs 1006 2 3
苹果iPhone 8 Plus 657 2 3
苹果iPhone 12 369 2 3
苹果iPhone 13 Pro 192 2 3
支持编码格式实验(条码兼容性)

由于目前没有xs测试机,使用和xs相机模组一样的xs max来进行实验。

结论:目前这四种主流的条码,都是兼容的

image.png

识别率简单测试

多个条码,随机位置扫码(包含一些比较苛刻的角度和距离),识别成功次数/扫码次数

设备型号 多个条码,识别率多次实验取平均
苹果iPhone Xs Max 45%
苹果iPhone 8 Plus 35.2%
苹果iPhone 12 72.3%
苹果iPhone 13 Pro 78.6%

对比

各种特殊条码测试

结论:微信的扫码模块针对各种条码都可以识别,甚至对于破损的条码都做了特殊优化。ZZ zbar scanner 在 (旧防拆码 有红色痕迹遮挡 )这种类型的识别效果不好。

image.png

条码编码类型兼容测试

结论:zbar scanner在EAN-13和,UPC-A 这两种编码的识别能力欠佳

image.png

查看代码,可能是没有设置这两个条码类型

识别效率

目前还没有做定量分析,下面只是做一个简单的定性分析和简单演示

测试机器为iphone xs max

  1. 标准且干净的条码

结论:目测,看起来都差不多

Vision scanner

ZZ zbar scanner

zbar scanner

AV Foundation

  1. 有破损的条码

结论:zabr和vision半斤八两,都差强人意

iphone xr max 识别破损条码

  1. 电脑屏幕前的二维码 (开发和测试经常会使用到的场景)

结论:vision库比zbar的好一些,zbar在一些角度和距离下回扫描不到

屏幕前二维码识别

针对不同机型

iphone xr max 和 iphone 12 ,二者扫描破损的二维码

在iphone xr max 上,vision库的效果和zbar 差不多

在iphone 12上,vision库的效果比zbar要好很多

iphone xr max 识别破损条码

iphone 12 识别破损条码

可能的原因:

  1. 摄像头模组不同,vsion 算法对图片的清晰度和分辨率要求更高
  2. gpu处理器不同,vsion算法使用的是机器学习,对处理器要求较高
  3. 系统版本不同,xr max的系统为15.6, iphone 12的系统为17.5.1,这之间的版本,针对vison算法有做优化。

下面是核心代码块

    // 处理视频帧
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        if isScanningPaused { return } // 如果扫描暂停,不处理帧

        // 每隔10帧处理一次
        frameCounter += 1
        if frameCounter < frameThreshold {
            return
        }
        frameCounter = 0

        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }

        if isProcessingFrame { return }
        isProcessingFrame = true

        // 创建条形码检测请求
        let request = VNDetectBarcodesRequest { (request, error) in
            DispatchQueue.main.async {
                self.isProcessingFrame = false

                guard let results = request.results, !results.isEmpty else {
                    // 清除检测框
                    self.clearDetectionOverlay()
                    return
                }

                AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))

                self.detectionOverlay.sublayers?.removeAll()  // 移除之前的检测框
                
                // 暂停扫描
                self.isScanningPaused = true
                
                if self.captureSession.isRunning {
                    self.captureSession.stopRunning()
                }
                
                // 遍历检测结果
                for result in results {
                    if let barcodeObservation = result as? VNBarcodeObservation {
                        self.drawBoundingBox(for: barcodeObservation)
                        self.resultLabel.text = "扫描结果: (barcodeObservation.payloadStringValue ?? "---")"
                    }
                }
            }
        }

        // 创建 Vision 请求处理器
        let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
        DispatchQueue.global(qos: .userInitiated).async {
            do {
                try handler.perform([request])
            } catch {
                print("请求失败: (error)")
            }
        }
    }

深入原理

Vision 库

Vison 与 Core ML 的关系

Vision 是 Apple 在 WWDC 2017 推出的图像识别框架。

Core ML 是 Apple 在 WWDC 2017 推出的机器学习框架。

Vison的功能:

  • 对象检测与识别:能够定位图像中的物体边界框并进行分类识别。例如,可以检测图像或实时摄像头画面中的人脸、二维码、条形码,或者利用深度学习模型识别特定对象(如花卉、车辆、动物)。
  • 文本检测与识别:Vision可以在图像中检测文本区域,并与文本识别(通常需要和Core ML或苹果内置OCR能力结合)进行整合,实现从图像或拍摄画面中提取文字。
  • 人脸特征与表情分析:除了检测人脸位置,Vision还能识别人脸特征点(如眼睛、鼻子和嘴巴的位置),甚至通过表情分析来判断笑容、闭眼等状态。
  • 图像注册与配准:Vision提供了图像配准(Image Alignment)和光流分析(Optical Flow)的能力,可用于图像稳定、AR场景中的场景对齐等应用。
  • 基于Core ML的自定义模型集成:Vision的VNCoreMLRequest类允许开发者将自定义的深度学习模型(通过Core ML转换)整合进Vision工作流,从而利用Vision进行更专业的检测和分类任务。

Core ML的功能:

  • 多模型类型支持:Core ML支持多种类型的机器学习模型,包括但不限于深度神经网络(CNN、RNN、Transformer等)、传统的树模型(如XGBoost)、线性回归模型以及特定领域的模型,如推荐系统模型。
  • 高性能与硬件加速:Core ML会自动在设备上利用GPU、ANE(Apple Neural Engine)或CPU进行加速推理,以获得更快的执行速度和更高的能效。
  • 易于集成:开发者可通过Core ML Tools将来自TensorFlow、PyTorch、Caffe等框架的训练模型转换成Core ML模型文件(.mlmodel)。
  • 隐私与安全:在设备端进行模型推断意味着数据不需要上传至云端处理,保护了用户隐私并能在无网络环境下正常工作。

下面是框架图片。

Vision与Core ML的协同工作方式
在典型的视觉智能应用中,Vision框架负责图像的基础预处理和关键特征提取步骤,例如人脸检测、特征点定位、图像裁剪和图像旋转等。随后,利用VNCoreMLRequest将图像处理结果传递给已经转换为Core ML格式的自定义模型进行分类、检测或预测,从而实现更高级的计算机视觉功能。

简而言之:

  • Vision侧重于图像与视频的分析、特征提取和高层次的视觉任务,为开发者提供更为直观的API进行对象、人脸、文本等识别。
  • Core ML则是通用的机器学习推理框架,负责在本地高效运行各种机器学习模型,包括那些可被Vision调用的自定义视觉模型。

技术方案

目标

初步目标:实现一个扫码组件:简单易用,扫描花费的时间少,兼容的条码类型多

进阶目标:可移植性强,可扩展性强

技术实现

要点:

组件要点:

  1. 底层接口封装 支持替换算法,即功能协议声明。
  2. ui 可以自定义,即ui协议声明。
  3. view和viewController 嵌入和跳转两种模式
  4. 多个扫描目标,作为附加能力可配置
  5. 一些可配置的小功能:支持双击和拖拽,放大和缩小 镜头,手电筒,相册
  6. 可以结合使用多种算法,谁识别快,用谁的结果

项目要点:

  1. 阿波罗 ab灰度方案
  2. 埋点分析收益,旧版本埋点分析,新版本新模块分析,

后续优化点:统计多个条码的识别率,以及条码定位框的准确度。

!目前zz scanner组件:

本身是一个objc库,依赖zbar和ZZViewController这两个objc库

现有功能,基于zbar的基础扫码能力,不支持ui自定义和底层算法替换。无统一协议

  1. 可以ui可以复用的不多,现有组件实现的比较简单,不支持替换底层算法等功能
  2. 该模块为oc组件,swift混编有点麻烦

demo ui

参考:

微信 转转

质检小工具 扫描控制器 质检小工具 嵌入式扫码页面

初步效果

演示效果

代码框架设计:

思考🤔:

  1. 一些通用的代码,例如开始视频流传输,多个二维码框选和处理等重复逻辑,如何抽离和公用?

使用基类的方法,对于视频里的开启,多个二维码框选,作为积累的函数实现,子类可以调用这种能力

  1. 对于扫多个码这种能力,如何做成可配置的呢?

一般情况识别到数据直接打印,扫多个码并标注本质上是在识别后,加了一个数据边框的处理。

  1. ui的自定义范围要到什么程度,如何进行ui的配置?
  2. 结合多个扫码算法的结果,看下是否可行

目前支持的自定义有:

(1):中央识别框的大小

(2):标题栏文字

(3):各种按钮和输入框是否隐藏

交互方案:

  1. 默认支持红点标记功能,当只有目标,自动跳转;当出现多个,暂停扫描,等待用户选中其中一个进行跳转
  2. 默认返回多个结果,支持红点标记可配置,开启配置后,红点和暂停扫码能力捆绑

实用主义方案:

1.默认:支持多个结果数据,无需暂停,直接跳转

2.可配置

(1)标点功能 ,单个自动跳转,多个暂停

框架图

注意事项:

  1. 对于输入框,可以作为vc的视图添加,
  2. 对于底下的按钮,可以从下往上布局

上线计划

代码实现

bundle id: org.cocoapods.demo.ios-module-base-scanner-Example

数据分析

添加扫码埋点,记录平均扫码时间

就扫描模块:

// 扫码花费时间

static NSString *const kZLJCheckTools_scan_time_data = @"qualityToolAppScanTime";

// 正常质检模块扫扫码花费时间

static NSString *const kZLJCheckTools_normalCheck_scan_time = @"normalCheckScanTime";

复盘

历史数据

10.15 正式环境,正常质检模块扫描时间 去除无效数据后,平均扫描时间为1.025s,主要机型为 iphone12

昨天以前掘金 iOS

【HarmonyOS】鸿蒙原生实现应用间跳转之App Linking

2025年2月14日 15:24

开发语言:ArkTs
开发工具:DevEco Studio 5.0.0 Release
API版本:API 12

使用App Linking进行跳转时,系统会根据接口传入的uri信息(HTTPS链接)将用户引导至目标应用中的特定内容,无论应用是否已安装,用户都可以访问到链接对应的内容,跳转体验相比Deep Linking方式更加顺畅。

本文演示A应用(拉起方)拉起B应用(被拉起方)。

B应用(被拉起方)开发

一、后端/云端开发:

1. 在AppGallery Connect开通App Linking服务。

① 登录AppGallery Connect,点击“我的项目”。 ② 在项目列表中点击您的项目。 ③ 在左侧导航栏中选择“增长 > App Linking”,进入App Linking页面,点击“立即开通”。 在这里插入图片描述 ④ 如果您的项目此时未设置数据处理位置,请在提示框内启用数据处理位置和设置默认数据处理位置,点击“确定”。 在这里插入图片描述 ⑤ 进入“项目设置 > 常规”页面,选择创建的HarmonyOS应用,查看应用的APP ID,后续开发需要使用该ID。 在这里插入图片描述

2. 在开发者网站上关联应用。

在开发者的网站域名服务器上做如下配置。后续当您配置该网站域名时,系统会通过此文件确认哪些应用才是合法归属于此域名的,使链接更加安全可靠。 ① 创建域名配置文件applinking.json,内容如下:

1、appIdentifier填写创建应用时生成的APP ID。 2、同一个网站域名可以关联多个应用,只需要在apps列表里放置多个appIdentifier元素即可,其中每个appIdentifier元素对应每个应用。

{
 "applinking": {
   "apps": [
     {
       "appIdentifier": "5765880207854042357"
     }
   ]
 }
}

② 将配置文件放在域名服务器的固定目录下: https://domain.name/.well-known/applinking.json

例如:开发者的服务器域名为www.blink.com,则必须将applinking.json文件放在如下位置: https://www.blink.com/.well-known/applinking.json

3. 在AppGallery Connect关联网址域名。

基于HarmonyOS应用链接能力,需要为HarmonyOS应用创建关联的网址域名。如果用户已安装HarmonyOS应用,则用户点击域名下网址链接后,系统会默认打开该HarmonyOS应用内的相关页面。 ① 登录AppGallery Connect,点击“我的项目”。 ② 在项目列表中点击您的项目。 ③ 在左侧导航栏中选择“增长 > App Linking”,选择“应用链接(API>=12适用)”页签,点击“创建”。 在这里插入图片描述 ④ 填写HarmonyOS应用关联的网址域名,即创建域名配置文件的网址,例如:https://www.blink.com。必须输入精确的域名,不可输入包含特殊字符的模糊网址。

不可以在域名后面添加/,即不支持https://www.example.com/形式。

在这里插入图片描述 ⑤ 设置完成后点击“发布”,AGC会对该网站域名的配置文件所包含的应用与本项目内的应用列表进行交集校验。

二、客户端开发:

1. 在DevEco Studio中配置关联的网址域名。

在应用的module.json5文件中进行如下配置,以声明应用关联的域名地址,并开启域名校验开关。

  • entities列表中必须包含entity.system.browsable
  • actions列表中必须包含ohos.want.action.viewData
  • uris列表中必须包含schemehttpshost为域名地址的元素,可选属性包含pathpathStartWithpathRegex,具体请参见uris标签说明
  • domainVerify设置为true,表示开启域名校验开关。
{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ts",
        "icon": "$media:icon",
        "label": "$string:EntryAbility_label",
        // 请将exported配置为true;如果exported为false,仅具有权限的系统应用能够拉起该应用,否则无法拉起应用
        "exported": true,
        "startWindowIcon": "$media:icon",
        "startWindowBackground": "$color:start_window_background",
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "action.system.home"
            ]
          },
          {
            "entities": [
              // entities必须包含"entity.system.browsable"
              "entity.system.browsable"
            ],
            "actions": [
              // actions必须包含"ohos.want.action.viewData"
              "ohos.want.action.viewData"
            ],
            "uris": [
              {
                // scheme须配置为https
                "scheme": "https",
                // host须配置为关联的域名
                "host": "www.blink.com",
                // path可选,表示域名服务器上的目录或文件路径,例如www.blink.com/path1/中的path1
                // 如果应用只能处理部分特定的path,则此处应该配置应用所支持的path,避免出现应用不能处理的path链接也被引流到应用中的问题
                "path": "path1"
              }
            ],
            // domainVerify须设置为true
           "domainVerify": true
          }
          // 若有其他跳转能力,如推送消息跳转、NFC跳转,可新增一个skill对象,防止与App Linking业务冲突
        ]
      }
    ]
  }
}

2. 处理传入的链接。

在应用的Ability(如EntryAbility)的onCreate()或者onNewWant()生命周期回调中添加如下代码,以处理传入的链接。

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { url } from '@kit.ArkTS';

export default class EntryAbility extends UIAbility {
  // 冷启动
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 从want中获取传入的链接信息。
    // 如传入的url为:https://www.blink.com/open?action=scan
    let uri = want?.uri 
    if (uri) {
      // 从链接中解析query参数,拿到参数后,开发者可根据自己的业务需求进行后续的处理。
      let urlObject = url.URL.parseURL(want?.uri);
      let action = urlObject.params.get('action')
      // 例如,当action为scan时,打开扫一扫。
      if (action === "scan"){
         //...
      }
    }
  }

  // 热启动
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 从want中获取传入的链接信息。
    // 如传入的url为:https://www.blink.com/open?action=scan
    let uri = want?.uri 
    if (uri) {
      // 从链接中解析query参数,拿到参数后,开发者可根据自己的业务需求进行后续的处理。
      let urlObject = url.URL.parseURL(want?.uri);
      let action = urlObject.params.get('action')
      // 例如,当action为scan时,打开扫一扫。
      if (action === "scan"){
         //...
      }
    }
  }
}

三、前端开发

开发链接对应的H5网页,应用未安装时呈现网页版内容。

A应用(拉起方)开发

一、客户端开发

1、判断B应用(被拉起方)是否安装

① 在entry模块的module.json5文件中配置querySchemes属性,声明想要查询的URL scheme

{
  "module": {
    //...
    "querySchemes": [
      "blink"
    ]
  }
}

② 调用bundleManager.canOpenLink()接口。

let canOpen = bundleManager.canOpenLink(link);

2. 通过openLink接口拉起。

可根据业务需求选择合适的方式。

方式一: 仅以App Linking的方式打开应用。 将appLinkingOnly参数设为true,若有匹配的应用,则直接打开目标应用。若无App Linking匹配的应用,则抛异常给开发者进行处理。

方式二: 以App Linking优先的方式打开应用。 将appLinkingOnly参数设为false或者默认,则为App Linking优先的方式打开应用。若有App Linking匹配的应用,则直接打开目标应用。若无App Linking匹配的应用,则尝试以浏览器打开链接的方式打开应用。

import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct Index {
  build() {
    Button('start link', { type: ButtonType.Capsule, stateEffect: true })
      .width('87%')
      .height('5%')
      .margin({ bottom: '12vp' })
      .onClick(() => {
        let context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
        let link: string = "https://www.blink.com/open?action=scan";
        // 仅以App Linking的方式打开应用
        context.openLink(link, { appLinkingOnly: true })
          .then(() => {
            console.info('openlink success.');
          })
          .catch((error: BusinessError) => {
            console.error(`openlink failed. error:${JSON.stringify(error)}`);
          });
      })
  }
}

3. 通过系统浏览器或ArkWeb拉起。

ArkWeb深度集成了App Linking的能力,当用户在系统浏览器或者集成ArkWeb的应用的网页上点击某个链接时,若有链接匹配的应用,系统则会通过App Linking能力优先拉起目标应用,并在应用内展示相应的内容。此机制有如下限制:

  • 如果用户当前浏览的网页的域名与点击的App Linking链接的域名完全一致,则系统会继续在系统浏览器或ArkWeb中打开该链接,以维持连贯的用户浏览体验。
  • 如果域名不完全一致(例如:example.comapp.example.com),则系统会通过App Linking能力优先拉起目标应用,并在应用内展示相应的内容。

验证应用被拉起效果

  • 对应用进行手动签名。
  • 编译打包,并安装应用至调试设备。
  • 在A应用(拉起方)应用中通过App Linking拉起此应用,详细请参考“拉起方实现跳转指导”。
  • 查看集成效果。

不能使用DevEco Studio的自动签名功能,必须使用手动签名,否则无法拉起应用。

在UITableview的cell中支持在当前屏幕中上下滑动触发自定义事件

2025年2月13日 15:41

在UITableview的cell中监听用户对某个cell的滑动,达到一定距离之后触发自定义事件,比如点击事件(比如增加某个cell的点击率,比如ad)

需求

需要在 UITableViewCellUICollectionViewCell 中支持滑动触发事件,并且每个 cellitem 都有自己的触发策略(如最短间隔时间和滑动距离)。为了满足这一需求,我们可以进一步封装功能代码,提供一个高度可复用的工具类,同时允许每个 cell 独立配置自己的触发策略。

实现思路

  1. 封装滑动事件处理器

    • 创建一个独立的工具类(如 CellSwipeHandler),用于处理滑动事件。

    • 每个 cell 可以持有一个 CellSwipeHandler 实例,并配置自己的触发策略。

  2. 支持自定义触发策略

    • 每个 cell 可以设置自己的滑动距离阈值和触发间隔。
  3. 事件回调

  • 通过 Block 或代理模式将滑动事件回调给 cell,由 cell 处理具体的业务逻辑。

代码实现

1. 封装滑动事件处理器 

#import <UIKit/UIKit.h>

// 定义事件回调的 Block 类型
typedef void (^CellSwipeEventHandler)(void);

@interface CellSwipeHandler : NSObject

// 初始化方法,传入需要监听手势的视图
- (instancetype)initWithView:(UIView *)view;

// 设置向下滑动事件的回调
@property (nonatomic, copy) CellSwipeEventHandler onScrollDown;

// 设置向上滑动事件的回调
@property (nonatomic, copy) CellSwipeEventHandler onScrollUp;

// 设置滑动距离阈值(默认 100px)
@property (nonatomic, assign) CGFloat scrollThreshold;

// 设置触发间隔(默认 2 秒)
@property (nonatomic, assign) NSTimeInterval triggerInterval;

@end

#import "CellSwipeHandler.h"

@interface CellSwipeHandler ()

@property (nonatomic, weak) UIView *view; // 弱引用视图
@property (nonatomic, assign) CGPoint startPanPoint; // 记录滑动手势的起始点
@property (nonatomic, strong) NSDate *lastTriggerTime; // 记录上次触发事件的时间

@end

@implementation CellSwipeHandler

- (instancetype)initWithView:(UIView *)view {
    self = [super init];
    if (self) {
        _view = view;
        _scrollThreshold = 100.0; // 默认滑动距离阈值
        _triggerInterval = 2.0;    // 默认触发间隔
        [self setupPanGesture];
    }
    return self;
}

- (void)setupPanGesture {
    // 添加滑动手势识别器
    UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePanGesture:)];
    [self.view addGestureRecognizer:panGesture];
}

- (void)handlePanGesture:(UIPanGestureRecognizer *)gesture {
    if (gesture.state == UIGestureRecognizerStateBegan) {
        // 记录滑动的起始点
        self.startPanPoint = [gesture locationInView:self.view];
    } else if (gesture.state == UIGestureRecognizerStateEnded) {
        // 获取手势结束时的位置
        CGPoint endPoint = [gesture locationInView:self.view];
        
        // 计算滑动的垂直距离
        CGFloat verticalDistance = endPoint.y - self.startPanPoint.y;
        
        // 判断是否超过滑动距离阈值
        if (fabs(verticalDistance) > self.scrollThreshold) {
            // 检查触发间隔
            NSDate *now = [NSDate date];
            if (!self.lastTriggerTime || [now timeIntervalSinceDate:self.lastTriggerTime] > self.triggerInterval) {
                if (verticalDistance > 0) {
                    // 向下滑动超过阈值
                    if (self.onScrollDown) {
                        self.onScrollDown();
                    }
                } else {
                    // 向上滑动超过阈值
                    if (self.onScrollUp) {
                        self.onScrollUp();
                    }
                }
                // 更新上次触发时间
                self.lastTriggerTime = now;
            } else {
                NSLog(@"触发间隔不足 %.1f 秒,忽略本次事件", self.triggerInterval);
            }
        }
    }
}

@end

2. 在 UITableViewCell 或 UICollectionViewCell 中使用 CellSwipeHandler

#import "CustomTableViewCell.h"
#import "CellSwipeHandler.h"

@interface CustomTableViewCell ()

@property (nonatomic, strong) CellSwipeHandler *swipeHandler; // 强引用滑动事件处理器

@end

@implementation CustomTableViewCell

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        // 初始化滑动事件处理器
        self.swipeHandler = [[CellSwipeHandler alloc] initWithView:self.contentView];
        
        // 设置滑动距离阈值(例如 150px)
        self.swipeHandler.scrollThreshold = 150.0;
        
        // 设置触发间隔(例如 3 秒)
        self.swipeHandler.triggerInterval = 3.0;
        
        // 设置向下滑动事件的回调
        __weak typeof(self) weakSelf = self;
        self.swipeHandler.onScrollDown = ^{
            [weakSelf handleScrollDown];
        };
        
        // 设置向上滑动事件的回调
        self.swipeHandler.onScrollUp = ^{
            [weakSelf handleScrollUp];
        };
    }
    return self;
}

- (void)handleScrollDown {
    NSLog(@"Cell %@ 向下滑动超过 %.1fpx,触发自定义事件", self, self.swipeHandler.scrollThreshold);
    // 在这里添加你的自定义事件代码
}

- (void)handleScrollUp {
    NSLog(@"Cell %@ 向上滑动超过 %.1fpx,触发自定义事件", self, self.swipeHandler.scrollThreshold);
    // 在这里添加你的自定义事件代码
}

@end

代码说明

  1. CellSwipeHandler

    • 封装了滑动手势的处理逻辑,支持自定义滑动距离阈值和触发间隔。

    • 通过 Block 回调将滑动事件传递给 cell

  2. CustomTableViewCell

    • 每个 cell 持有一个 CellSwipeHandler 实例,并配置自己的触发策略。

    • handleScrollDownhandleScrollUp 方法中处理具体的业务逻辑。

  3. 高度可复用

  • CellSwipeHandler 可以在任何 UITableViewCellUICollectionViewCell 中使用。

  • 每个 cell 可以独立配置滑动距离阈值和触发间隔。

优点

  • 高度可配置:每个 cell 可以独立设置滑动距离阈值和触发间隔。

  • 模块化设计:将滑动逻辑封装到 CellSwipeHandler 中,便于维护和扩展。

  • 复用性强CellSwipeHandler 可以在任何 cell 中使用,支持多种业务场景。

2024 再探ObjC-Category:动态特性与运行时实现的极致之美

作者 布多
2025年2月12日 18:04

由 布多(budo) 发布于 2024-12-11 • 最后更新于2025-01-09

前言

Category 是 ObjC 中一个基础且重要的概念。本文将从 Runtime 源码入手,向你介绍 Category 的概念以及底层的实现原理。

Category 概念

Category 主要是用来给已存在的类动态添加方法实现,也可扩展协议和属性。基于此特性,我们可以用 Category 实现如下功能:

  • 将一个庞大的类分解成多个 Category,每个 Category 只完成少量的任务,从而提高模块化和代码解耦程度。

  • 在不继承的情况下给已有类动态的添加新方法。

  • 模拟多继承,比如让已有类支持新协议。

Category 之编译期实现细节

创建一个 ObjC 源代码文件并将其命名为 test_category.m,然后在文件内输入如下代码:

#import <Foundation/Foundation.h>

@interface NSObject (WXLCategory)<NSCopying>

@property () NSInteger wxl_ist_prot;

@property (class) NSInteger wxl_cls_prot;

- (void)wxl_ist_func;
+ (void)wxl_cls_func;

@end

@implementation NSObject (WXLExtension)

- (void)wxl_ist_func {}
+ (void)wxl_cls_func {}

@end

这里我特意只写了方法的实现,而没有写属性和协议的实现,后面会解释为什么。

使用 xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc test_category.m 命令可以将上述代码编译为 C++ 文件,代码精简后如下所示:

struct category_t {
    const char *name;
    struct class_t *cls;
    const struct method_list_t *instance_methods;
    const struct method_list_t *class_methods;
    const struct protocol_list_t *protocols;
    const struct prop_list_t *instanceProperties;
    // Fields below this point are not always present on disk.
    const struct prop_list_t *_classProperties;
};

static struct category_t _OBJC_$_CATEGORY_NSObject_$_WXLCategory 
__attribute__ ((used, section ("__DATA, __objc_const"))) = 
{
"NSObject",
0,
(const struct method_list_t *)&_OBJC_$_CATEGORY_INSTANCE_METHODS_NSObject_$_WXLCategory,
(const struct method_list_t *)&_OBJC_$_CATEGORY_CLASS_METHODS_NSObject_$_WXLCategory,
(const struct protocol_list_t *)&_OBJC_CATEGORY_PROTOCOLS_$_NSObject_$_WXLCategory,
(const struct prop_list_t *)&_OBJC_$_INSTANCE_PROP_LIST_NSObject_$_WXLCategory,
(const struct prop_list_t *)&_OBJC_$_CLASS_PROP_LIST_NSObject_$_WXLCategory,
};

_classProperties 变量是我参考 Runtime 源码后手动加上的,你编译的代码可能会没有。

从编译后的代码中不难看出,一个 Category 对象,其底层其实就是1个 category_t 的结构体对象,这个结构体中包含了实例属性、类属性、实例方法、类方法以及协议等变量用来保存分类中的相关数据。

最后,编译器会把 category_t 相关数据保存在 Mach-O 文件的 objc_const 数据段下,等待运行时解析。

Category 之运行时实现细节:探索内部实现原理

相关代码整理后如下所示(代码有点长,不想看可以跳过,后面有解释):

本文使用的 Runtime 源码出自 objc4-928.2,为了方便大家阅读,我会对代码样式和排版略作修改以及删减一些不影响代码主逻辑的冗余代码。

我在 这里 维护了一个可以直接运行调试的 Runtime 项目,方便大家直接调试源码。

void
load_images(const struct _dyld_objc_notify_mapped_info* info) {
    // 检查该模块是否有 +load 方法的实现。
    if (!hasLoadMethods((const headerType *)info->mh,
                        info->sectionLocationMetadata)) return;
    
    // 加载所有分类。
    loadAllCategoriesIfNeeded();
}

static bool didInitialAttachCategories = false;

void loadAllCategoriesIfNeeded() {
    // 控制不要重复加载分类数据,每加载一个模块时都可能会来到这里。
    if (!didInitialAttachCategories) {
        /*
         遍历所有模块并加载它们的分类数据。
         
         注意:在加载第1个模块时就会执行该函数,
         这意味着在加载第1个模块时就会把所有模块中的分类数据都加载,
         而不是只加载当前模块中的分类数据。
         */
        for (auto *hi = FirstHeader; hi != NULL; hi = hi->getNext()) {
            load_categories_nolock(hi);
        }
        
        didInitialAttachCategories = true;
    }
}

static void load_categories_nolock(header_info *hi) {
    // 当前模块是否有分类类属性。
    bool hasClassProperties = hi->info()->hasCategoryClassProperties();
    
    size_t count;
    
    // 遍历当前模块中的所有分类数据并进行处理。
    auto processCatlist = [&](category_t * const *catlist) {
        for (unsigned i = 0; i < count; i++) {
            category_t *cat = catlist[i];
            Class cls = remapClass(cat->cls);
            
            // 把 cat、cls、hi 包装一下,方便后面调用函数时传参。
            locstamped_category_t lc{cat, cls, hi};
            
            // 检查这个分类中是否有实例方法、协议、实例属性。
            if (cat->instanceMethods ||
                cat->protocols ||
                cat->instanceProperties) {
                // 检查 cls 是否已实现,
                if (cls->isRealized()) {
                    // 将分类中的实例方法、协议、实例属性添加到 cls 上。
                    attachCategories(cls, &lc, 1, cls, ATTACH_EXISTING);
                } else {
                    // 将分类数据和类对象保存起来,等类对象实现后再进行加载。
                    objc::unattachedCategories.addForClass(lc, cls);
                }
            }
            
            /*
             检查这个分类中是否有类方法、协议、类属性。
             
             注意 `(hasClassProperties && cat->_classProperties)` 这段代码,
             可能是因为使用类属性的项目非常少,所以加入了这一个判断。
             
             这里是在给元类对象添加方法和属性,但是,元类对象是没有协议的。不清楚这
             里为什么要判断 `cat->protocols`。
             
             另外,在后面获取分类的协议列表时也有判断,如果是给元类添加的话就直接返回 
             NULL。感觉这段代码其实可以删掉,不知道是否有其它隐情?
             */
            if (cat->classMethods ||
                cat->protocols ||
                (hasClassProperties && cat->_classProperties)) {
                if (cls->ISA()->isRealized()) {
                    // 将分类中的类方法、类属性加载到元类上。
                    attachCategories(cls->ISA(), &lc, 1, cls, 
                    ATTACH_EXISTING | ATTACH_METACLASS);
                } else {
                    // 将分类数据和元类对象保存起来,等元类对象实现后再进行加载。
                    objc::unattachedCategories.addForClass(
                    lc.reSignedForMetaclass(cls), 
                    cls->ISA());
                }
            }
        }
    };
    
    processCatlist(hi->catlist(&count));
}

/*
 cls: 需要把分类数据添加到哪个类上。
 如果添加的是实例方法、实例属性、协议,这个参数就是类对象;
 如果添加的是类方法、类属性,这个参数就是元类对象。
 
 cats_list: 需要被添加的分类数据,注意这是一个数组。
 
 catsListKey: 分类所属的类对象。
 不管添加的是实例方法还是类方法,始终指向该分类所属的类对象,不会是元类对象。
 */
static void
attachCategories(Class cls,
                 const locstamped_category_t *cats_list,
                 uint32_t cats_count,
                 Class catsListKey,
                 int flags) {
    constexpr uint32_t ATTACH_BUFSIZ = 64;
    
    /*
     一个临时的缓存结构。分类中的方法、属性、协议会被临时添加到这个缓存对象中去,
     当缓存容量满了,或者分类中的数据加载完了,再一次性添加到类中去。
     */
    struct Lists {
        ReversedFixedSizeArray<method_list_t *, ATTACH_BUFSIZ> methods;
        ReversedFixedSizeArray<property_list_t *, ATTACH_BUFSIZ> properties;
        ReversedFixedSizeArray<protocol_list_t *, ATTACH_BUFSIZ> protocols;
    };
    
    Lists normalLists;
    
    bool isMeta = (flags & ATTACH_METACLASS);
    auto rwe = cls->data()->extAllocIfNeeded();
    
    for (uint32_t i = 0; i < cats_count; i++) {
        // entry 的原型:locstamped_category_t {cat, cls, hi}
        auto& entry = cats_list[i];
        
        // 获取分类中的方法列表。
        method_list_t *mlist = entry.getCategory(catsListKey)->methodsForMeta(isMeta);
        Lists *lists = &normalLists;
        bool isPreattached = 
        entry.hi->info()->dyldCategoriesOptimized() && !DisablePreattachedCategories;
        
        if (mlist) {
            if (lists->methods.isFull()) {
                // 将缓存中的方法全部添加到类/元类中。
                rwe->methods.attachLists(lists->methods.array, 
                lists->methods.count, 
                isPreattached, 
                PrintPreopt ? "methods" : nullptr);
                
                // 清空缓存。
                lists->methods.clear();
            }
            
            // 将分类中的方法添加到缓存。
            lists->methods.add(mlist);
        }
        
        // 获取分类中的属性列表。
        property_list_t *proplist =
        entry.getCategory(catsListKey)->propertiesForMeta(isMeta, entry.hi);
        if (proplist) {
            if (lists->properties.isFull()) {
                // 将缓存中的属性全部添加到类/元类中。
                rwe->properties.attachLists(lists->properties.array, 
                lists->properties.count, 
                isPreattached, 
                PrintPreopt ? "properties" : nullptr);
                
                lists->properties.clear();
            }
            
            // 将分类中的属性添加到缓存。
            lists->properties.add(proplist);
        }
        
        // 获取分类中的协议列表,内部会通过 isMeta 判断如果是元类就返回 NULL。
        protocol_list_t *protolist = 
        entry.getCategory(catsListKey)->protocolsForMeta(isMeta);
        if (protolist) {
            if (lists->protocols.isFull()) {
                // 将缓存中的协议全部添加到类/元类中。
                rwe->protocols.attachLists(lists->protocols.array, 
                lists->protocols.count, 
                isPreattached, 
                PrintPreopt ? "protocols" : nullptr);
                
                lists->protocols.clear();
            }
            
            // 将分类中的协议添加到缓存。
            lists->protocols.add(protolist);
        }
    }
    
    // 将缓存里的方法、属性、协议全部添加到类/元类中。
    auto attach = [&](Lists *lists, bool isPreattached) {
        // 将缓存里的方法添加到类/元类中。
        rwe->methods.attachLists(lists->methods.begin(), 
        lists->methods.count, 
        isPreattached, 
        PrintPreopt ? "methods" : nullptr);
        
        // 将缓存里的属性添加到类/元类中。
        rwe->properties.attachLists(lists->properties.begin(), 
        lists->properties.count, 
        isPreattached, 
        PrintPreopt ? "properties" : nullptr);
        
        // 将缓存里的协议添加到类中。
        rwe->protocols.attachLists(lists->protocols.begin(), 
        lists->protocols.count, 
        isPreattached, 
        PrintPreopt ? "protocols" : nullptr);
    };
    
    attach(&normalLists, false);
}


/*
 这个函数的作用是把方法、属性、协议添加到类/元类中,
 不一定是添加分类中的数据,也有可能是添加类自身的数据。
 */
void attachLists(List* const * addedLists,
                 uint32_t addedCount,
                 bool preoptimized,
                 const char *logKind) {
    if (addedCount == 0) return;
    
    // 这个分支通常是用来添加类自身的方法、属性、协议。
    if (storage.isNull() && addedCount == 1) {
        storage.set(*addedLists);
        
        // 这个分支通常是用来处理第1个分类的数据。
    } else if (storage.isNull() || storage.template is<List *>()) {
        // 0 or 1 list -> many lists
        
        // 获取旧数据。
        List *oldList = storage.template dyn_cast<List *>();
        uint32_t oldCount = oldList ? 1 : 0;
        uint32_t newCount = oldCount + addedCount;
        
        // 开辟一个新数组,足以容纳旧数据加上分类中的新数据。
        array_t *array = (array_t *)malloc(array_t::byteSize(newCount));
        storage.set(array);
        array->count = newCount;
        
        /*
         将类中原来的数据(方法、属性、协议)放到数组最后面。
         这一步保证了类自身的数据永远处于列表的最后面。         
         */
        if (oldList) array->lists[addedCount] = oldList;
        
        // 将分类中的数据插入到数组的前面。
        for (unsigned i = 0; i < addedCount; i++)
            array->lists[i] = addedLists[i];
            
        // 这个分支通常是用来加载第2个及之后的分类数据。
    } else if (array_t *array = storage.template dyn_cast<array_t *>()) {
        // many lists -> many lists
        uint32_t oldCount = array->count;
        uint32_t newCount = oldCount + addedCount;
        array_t *newArray = (array_t *)malloc(array_t::byteSize(newCount));
        newArray->count = newCount;
        
        // 把旧数据按照之前的顺序放到新数组的最后面。
        for (int i = oldCount - 1; i >= 0; i--)
            newArray->lists[i + addedCount] = array->lists[i];
        
        /*
         将分类数据依次添加到数组的前面,
         这一步操作会导致最后编译的分类数据将会被添加在数组的最前面。
         */
        for (unsigned i = 0; i < addedCount; i++)
            newArray->lists[i] = addedLists[i];
            
        free(array);
        storage.set(newArray);
        
        /*
         一般情况下不会来到这个分支,
         在之前的函数 load_categories_nolock 中有这样一行代码:
         `objc::unattachedCategories.addForClass(lc, cls);`
         这个分支就是用来处理这种情况。
         */
    } else if (auto *listList = storage.template dyn_cast<relative_list_list_t<List> *>()) {
        // list-of-lists -> many lists
        auto listListBegin = listList->beginLists();
        uint32_t oldCount = listList->countLists();
        uint32_t newCount = oldCount + addedCount;
        array_t *newArray = (array_t *)malloc(array_t::byteSize(newCount));
        newArray->count = newCount;
        
        uint32_t i;
        for (i = 0; i < addedCount; i++) {
            newArray->lists[i] = addedLists[i];
        }
        
        for (; i < newCount; i++) {
            newArray->lists[i] = *listListBegin;
            ++listListBegin;
        }
        
        storage.set(newArray);
    }
}

从源码中不难发现,Category 中的数据(方法、属性、协议)都是在运行时通过 Runtime 动态添加到类中一个叫做 rwe 的对象中。

在 rwe 这个对象中拥有三个变量,分别是:方法列表、属性列表、协议列表,这个变量其实就是一个二维数组。以方法列表为例,类自身的所有方法是一个数组,每个 Category 中的所有方法是一个数组,它们都被放在这个二维数组中,注意,类自身的方法列表放在这个二维数组的最后面,最后编译的那个 Category 中的方法列表放在这个二维数组的最前面。

正是因为这个特点才导致了 Category 中的方法实现会覆盖与类本身同名的方法实现。所以,在开发过程中我们经常会看到很多框架都会给 Category 的方法和属性添加前缀,其目的就是为了降低重名的可能性。

有些人说 Category 不支持添加实例变量是因为 category_t 结构体中没有 ivars 字段。其实并不是添加一个字段的事,根本原因是因为开发者可能会用 Category 给已经编译好的类(例如系统类)添加数据,而这些类的内存布局与地址已经固定死了,如果要给它添加实例变量势必要修改其内存布局与地址。

另外,也不能给 Category 添加 weak 属性,如果一定要添加 weak 属性的话,可以采用中间者模式,即给 Category 添加一个中间者对象,然后给这个中间类声明一个 weak 属性。关于 weak 指针的更多细节请看我的另一篇文章 深入理解iOS中的 weak 指针

我在网上看到有些人说为什么要把 Category 设计成使用 Runtime 运行时加载,直接设计成编译时加载不是更好吗?他们的想法是:“在给项目中某个类(这个类是在项目中创建的),例如 CustomClass 创建分类时,编译器其实能拿到 CustomClass 的实现文件,那么只要把分类中的方法和这个类自身的方法合并不就行了,这样还能实现在 Category 中给这个类添加实例变量。” 乍一看没啥问题。但是,Category 还支持给已经编译好的类(例如系统类)添加方法实现,而这些类的布局和地址已经固定死了,因而不能这么干。

在阅读源码的过程中,我还发现了一些其它问题:

  1. 在前面的 test_category.m 文件中,我特意没有在实现中写上属性和协议的实现。因为经过我的调试发现,Runtime 在解析 Category 中的属性和协议时,只看声明并不看实现,只要有属性、协议声明,不管有没有实现都会被添加到类的属性列表和协议列表中。但是在解析方法的时候是反过来的,只会把有方法实现的那些方法添加到方法列表中。

  2. 在第1个函数 load_images 中有这么一个判断:如果该模块中没有 +load 方法实现就不添加 Category 数据。我查阅了许多资料,但是都没有找到可信的证据解释为什么要这样做?如果你知道为什么的话还请留言告知。

  3. Runtime 会在 loadAllCategoriesIfNeeded 函数内一次性加载所有模块中的分类数据,而不是遍历一个模块加载一个模块中的分类数据。我想了一下,这么做有以下好处:降低程序的复杂度和提高性能。如果是遍历一个模块加载一个模块的数据,那就不能只使用一个全局变量 didInitialAttachCategories 来标记分类数据是否已加载?可能要维护一个字典,例如 key 是模块名称,value 表示该模块是否已加载分类。这么做显然比维护一个全局变量成本更高。

Category 与 Extension 的区别

经常有人把 Category 和 Extension 拿到一起来说,可能是因为它们的声明方式有点像吧,以下是 Category 和 Extension 的声明代码:

// Category 声明
@interface Person (CategoryName) @end

// Extension 声明
@interface Person () @end

从代码来看,Category 似乎只多了一个 name 而已。所以导致很多人以为它们的底层实现可能差不多,但其实它们的实现压根不一样。

Extension 的特点

从功能和底层实现上来看,其实 Extension 和 Interface(类声明) 更像一些,Interface 能干的事,它基本上都能干,除了不能指定父类。

Interface 一般是用来对外提供接口数据,但有时候我们会想把一些属性、方法、实例变量隐藏起来。Extension 就是专门用来干这个的,因为 Interface 只能有一个,但 Extension 可以有多个。Extension 和类声明都是编译特性,你可以在 Extension 中声明实例变量、属性、方法、协议,这和在 Interface 中写本质上是一样的。

需要注意一点,虽然在 Extension 中可以声明实例变量,但仅在拥有 implementation 实现的这个文件中这么做才可以,例如以下代码就可以,因为这个文件中拥有 Book 的实现:

@interface Book () {
    NSString *_name;
}
@end

@implementation Book
- (void)testFun {
    _name = @"";
    NSLog(@"%@", _name);
}
@end

例如下面的代码就不行,因为这个文件中没有 Book 类的实现,此时你会得到一个编译错误:

@interface Book () {
    NSString *_name;
}
@end

@implementation Book (CategoryName)
- (void)testFun {
    _name = @"";
    NSLog(@"%@", _name);
}
@end

另外,虽然在任何地方都能使用 Extension,但是和上面的实例变量一样,如果需要编译器自动生成实现代码(例如属性),那就不能在 implementation 之外的文件使用,切记!!!

Extension 还有一个好用的功能就是声明私有方法,这样就能在后面的代码中直接调用这个方法了,而不是写成这样:[self performSelector:@selector(testFun)],网上有很多人是使用 Category 干这个事,其实 Extension 也可以,个人感觉这样更优雅。

Category 的特点

与 Extension 相比,Category 是编译器加上 Runtime 共同完成的。编译器负责将 Category 编译成 category_t 对象,然后添加到 Mach-O 文件中。Runtime 负责在运行时将 category_t 中的数据解析并添加到对应的类中。

总结

从苹果提供的源码中我们不难发现,其实 Category 的底层实现并不复杂,其本质就是将 Category 转化成一个结构体用来保存相关数据(属性、方法、协议),然后通过 Runtime 在运行时将这个结构体中的数据解析出来并且添加到类中。而这个类对象内部有一个二维数组来存储每个分类中的数据。

揭开 iOS 中 weak 指针的神秘面纱:从原理到实践

作者 布多
2025年2月12日 17:58

由 布多(budo) 发布于 2023-10-21 • 最后更新于 2025-02-10

前言

weak 指针是 iOS 开发中一个非常基础的概念,在开发过程中我们经常使用它,它到底是怎么实现的?这篇文章将从 Runtime 源码入手,为你介绍 weak 指针的实现原理;让你知其然,更知其所以然。

weak 指针之编译期实现

当我们初始化一个 weak 指针时: __weak typeof(obj) weakObj = obj;,编译器其实会把它们转换成类似这样的代码:objc_initWeak((void *)&weakObj, obj);

weak 指针的初始化入口

从上图的断点中我们也可以发现,weak 指针调用了 objc_initWeak 函数来完成初始化。在 Runtime 源码中我们可以找到 objc_initWeak 相关的实现细节。

本文引用的 Runtime 源码版本是 objc4-906,为了更方便阅读,我对代码样式和排版略作了修改以及删减了那些不影响主逻辑的冗余代码。

我在 这里 维护了一个可以直接运行调试的 Runtime 项目,方便大家在自己的电脑上直接调试。

weak 指针之运行时实现

与 weak 指针初始化相关的函数有以下 4 个:

// 初始化一个全新的 weak 指针。
id objc_initWeak(id *location, id newObj) {
    if (!newObj) {
        *location = nil;
        return nil;
    }

    return storeWeak<DontHaveOld, DoHaveNew, DoCrashIfDeallocating>(location, (objc_object*)newObj);
}

// 和前面的 objc_initWeak 一样,
// 但是,如果指向的对象(即 newObj)正在释放的话不要报错而是返回 nil。
id objc_initWeakOrNil(id *location, id newObj) {
    if (!newObj) {
        *location = nil;
        return nil;
    }

    return storeWeak<DontHaveOld, DoHaveNew, DontCrashIfDeallocating>(location, (objc_object*)newObj);
}

// 释放 weak 指针当前指向的对象,并将它指向新对象(即 newObj)。
id objc_storeWeak(id *location, id newObj) {
    return storeWeak<DoHaveOld, DoHaveNew, DoCrashIfDeallocating>(location, (objc_object *)newObj);
}

// 和 objc_storeWeak 一样。
// 但是,如果指向的对象(即 newObj)正在释放的话不要报错而是返回 nil。
id objc_initWeakOrNil(id *location, id newObj) {
    if (!newObj) {
        *location = nil;
        return nil;
    }

    return storeWeak<DontHaveOld, DoHaveNew, DontCrashIfDeallocating>(location, (objc_object*)newObj);
}

关于函数 objc_initWeak 和 objc_storeWeak 的区别:

  • objc_initWeak:用来初始化一个全新的 weak 指针。例如以下场景:

    NSObject *obj = ...;
    __weak id weakPtr = obj;// 在初始化的时候就指向了对象。
    
  • objc_storeWeak:当 weak 指针被赋值的时候调用。例如以下场景:

    NSObject *obj = ...;
    __weak id weakPtr;
    weakPtr = obj;// 先初始化,后赋值。
    

从以上 4 个初始化函数不难发现,它们最终都调用了同一个函数 storeWeak,区别就是传递给函数的模板参数略有不同。

weak 指针的初始化细节:storeWeak

storeWeak 函数的相关代码整理后如下所示:

enum HaveOld { DontHaveOld = false, DoHaveOld = true };
enum HaveNew { DontHaveNew = false, DoHaveNew = true };

enum CrashIfDeallocating {
    DontCrashIfDeallocating = false, DoCrashIfDeallocating = true
};

/*
location: weak 指针的内存地址,即 `__weak id weakPtr;` 中的 &weakPtr。
newObj: weak 指针要指向的对象,即 `__weak id weakPtr = obj;` 中的 obj。
*/
template <HaveOld haveOld, HaveNew haveNew, 
enum CrashIfDeallocating crashIfDeallocating>
static id storeWeak(id *location, objc_object *newObj) {
    // 当 newObj 的类型还未初始化的时候会用到。
    Class previouslyInitializedClass = nil;
    
    // 保存 weak 指针当前指向的对象。
    id oldObj;
    
    // SideTable 是用来存储弱引用关联的 1 个数据结构,后面会单独讲。
    SideTable *oldTable;
    SideTable *newTable;

 retry:
    if (haveOld) {
        oldObj = *location;
        // 调用全局函数 SideTables 获取 oldObj 对应的 SideTable 对象。
        oldTable = &SideTables()[oldObj];
    } else {
        oldTable = nil;
    }
    
    if (haveNew) {
        newTable = &SideTables()[newObj];
    } else {
        newTable = nil;
    }
    
    // 该函数不是线程安全的,所以执行到这里的时候,
    // weak 指针的值有可能被其它线程修改了,
    // 如果是的话就跳转到 retry 重新获取数据。
    if (haveOld && *location != oldObj) {
        goto retry;
    }

    if (haveNew && newObj) {
        Class cls = newObj->getIsa();
        
        /*
        检查 newObj 的类型是否已经完成初始化。
        一般都不会发生这种情况,除非你在 +initialize 中对该对象进行弱引用,
        例如以下场景:
        @implementation Person
        + (void)initialize {
           Person *obj = [[self alloc] init];
           // 此时 Person 类还未完成初始化操作。
           __weak typeof(obj) weakObj = obj;
        }
        @end
        */
        if (cls != previouslyInitializedClass &&  
            !cls->isInitialized()) {  
            
            // 对 newObj 类型进行初始化操作,然后跳转 retry 重新获取数据。
            class_initialize(cls, newObj);
            previouslyInitializedClass = cls;

            goto retry;
        }
    }

    if (haveOld) {
        // 如果 weak 指针当前持有了一个对象,先解除与这个对象的弱引用关联。
        weak_unregister_no_lock(&oldTable->weak_table, oldObj, location);
    }

    if (haveNew) {
        // 将 weak 指针与新对象建立弱引用关联。
        newObj = weak_register_no_lock(&newTable->weak_table,
        (id)newObj,
        location,
        /*
        之前在初始化函数那里提到过带 OrNil 后缀和不带后缀的区别,
        重点就在这里,带后缀的函数这里会传递 ReturnNilIfDeallocating,
        不带后缀的会传递 CrashIfDeallocating。
        函数内部会检查 newObj 是否正在释放过程中,
        如果是的话就会使用这个参数来决定怎么处理。
        */
        crashIfDeallocating ? CrashIfDeallocating : ReturnNilIfDeallocating);
        
        // 修改 newObj 对象的 isa 中关于是否有弱引用的标记。
        if (!_objc_isTaggedPointerOrNil(newObj)) {
            newObj->setWeaklyReferenced_nolock();
        }

        // 将 weak 指针指向 newObj。
        *location = (id)newObj;
    }
        
    /* 
    这个函数我翻了往年的 runtime 源码,发现是从 objc4-818.2 开始引入的。
    它的内部逻辑大致如下:
    
    1. 检查对象是否实现了手动引用计数。
    2. 如果支持的话,再判断对象是否有实例方法 _setWeaklyReferenced 的实现,如果有的话就调用。
    */
    callSetWeaklyReferenced((id)newObj);

    return (id)newObj;
}

这个函数就是 weak 指针初始化的最终函数。从函数中不难发现,它主要就干了 2 件事:

  1. 调用 weak_unregister_no_lock:将 weak 指针与当前对象解除弱引用关联。
  2. 调用 weak_register_no_lock:将 weak 指针与新对象建立弱引用关联。

weak 指针解除关联的细节:weak_unregister_no_lock

void
weak_unregister_no_lock(weak_table_t *weak_table,
                        id referent_id,
                        id *referrer_id) {
    // weak 指针当前指向的对象,即 `__weak id weakPtr = obj;` 中的 obj。
    objc_object *referent = (objc_object *)referent_id;
    // weak 指针的内存地址,即 `__weak id weakPtr;` 中的 &weakPtr。
    objc_object **referrer = (objc_object **)referrer_id;

    // referent 对象的弱引用表。
    weak_entry_t *entry;

    if (!referent) return;

    // 从 weak_table 中取出 referent 对应的弱引用表。
    if ((entry = weak_entry_for_referent(weak_table, referent))) {
        // 从弱引用表中移除 weak 指针的地址,即移除 referrer。
        remove_referrer(entry, referrer);
        
        // 移除后检查一下表是否为空,
        // 如果是的话就删除这个表。
        bool empty = true;
        if (entry->out_of_line() &&
            entry->num_refs != 0) {
            empty = false;
        } else {
            for (size_t i = 0; i < 4; i++) {
                if (entry->inline_referrers[i]) {
                    empty = false; 
                    break;
                }
            }
        }

        if (empty) {
            weak_entry_remove(weak_table, entry);
        }
    }
}

// 从弱引用表中获取某个对象对应的那张表数据。
static weak_entry_t *
weak_entry_for_referent(weak_table_t *weak_table, objc_object *referent) {
    // 下面的逻辑是一段典型的哈希表实现细节,关于哈希表的实现原理请自行了解。
    weak_entry_t *weak_entries = weak_table->weak_entries;

    if (!weak_entries) return nil;

    size_t begin = hash_pointer(referent) & weak_table->mask;
    size_t index = begin;
    size_t hash_displacement = 0;
    
    while (weak_table->weak_entries[index].referent != referent) {
        index = (index+1) & weak_table->mask;
        if (index == begin) bad_weak_table(weak_table->weak_entries);
        hash_displacement++;
        if (hash_displacement > weak_table->max_hash_displacement) {
            return nil;
        }
    }
    
    return &weak_table->weak_entries[index];
}

// 从弱引用表中移除指定的 weak 指针。
static void
remove_referrer(weak_entry_t *entry, objc_object **old_referrer) {
    // 系统在存储弱指针数据的时候会采用 2 套方案。
    // 如果数据量比较小就会使用静态数组存放。
    // 这里的逻辑就是判断,如果是静态数组方案就执行这段逻辑。
    if (!entry->out_of_line()) {
        for (size_t i = 0; i < 4; i++) {
            if (entry->inline_referrers[i] == old_referrer) {
                entry->inline_referrers[i] = nil;
                return;
            }
        }
        return;
    }

    // 如果是动态数组存储的话,就执行这段逻辑。
    size_t begin = w_hash_pointer(old_referrer) & (entry->mask);
    size_t index = begin;
    size_t hash_displacement = 0;
    
    while (entry->referrers[index] != old_referrer) {
        index = (index+1) & entry->mask;
        if (index == begin) bad_weak_table(entry);
        hash_displacement++;
        if (hash_displacement > entry->max_hash_displacement) {
            // 弱引用表中没有这个弱指针数据,不需要移除。
            return;
        }
    }
    
    entry->referrers[index] = nil;
    entry->num_refs--;
}

// 从弱引用表中移除某张表。
static void
weak_entry_remove(weak_table_t *weak_table, weak_entry_t *entry) {
    // 如果是动态数组方案的话,释放创建的动态数组。
    if (entry->out_of_line()) free(entry->referrers);
    
    memset(entry, 0, sizeof(*entry));

    weak_table->num_entries--;

    // 如果需要的话,对弱引用表进行缩容。
    weak_compact_maybe(weak_table);
}

代码稍微有点多,但整体的逻辑比较清晰。weak_unregister_no_lock 函数中主要做了 3 件事:

  1. 调用 weak_entry_for_referent 从弱引用表中获取指定的那张表数据。
  2. 调用 remove_referrer 从表中移除弱指针(即 referrer)。
  3. 检查表是否为空,是的话就调用 weak_entry_remove 从 weak_table 中移除这张表。

移除弱引用关联,本质上就是从 weak_table 中找到对象对应的弱引用数组,然后从数组中找到需要移除的 weak 指针并将其置空。weak_table 其实就是一个哈希表,关于哈希表的实现原理请自行了解。

weak 指针建立关联的细节:weak_register_no_lock

id 
weak_register_no_lock(weak_table_t *weak_table,
                      id referent_id,
                      id *referrer_id,
                      WeakRegisterDeallocatingOptions deallocatingOptions) {
    // weak 指针当前指向的对象,即 `__weak id weakPtr = obj;` 中的 obj。
    objc_object *referent = (objc_object *)referent_id;
    // weak 指针的内存地址,即 `__weak id weakPtr;` 中的 &weakPtr。
    objc_object **referrer = (objc_object **)referrer_id;

    // 这里是确保 weak 指针要指向的那个对象是有效的(即没有正在释放)。
    if (deallocatingOptions == ReturnNilIfDeallocating ||
        deallocatingOptions == CrashIfDeallocating) {
        bool deallocating;
        if (!referent->ISA()->hasCustomRR()) {
            deallocating = referent->rootIsDeallocating();
        } else {
            auto allowsWeakReference = (BOOL(*)(objc_object *, SEL))
            lookUpImpOrForwardTryCache((id)referent, @selector(allowsWeakReference),
                                       referent->getIsa());
            if ((IMP)allowsWeakReference == _objc_msgForward) {
                return nil;
            }
            deallocating =
            ! (*allowsWeakReference)(referent, @selector(allowsWeakReference));
        }

        // 如果正在释放的话,根据参数 deallocatingOptions 来决定执行什么操作。
        if (deallocating) {
            if (deallocatingOptions == CrashIfDeallocating) {
                _objc_fatal("Cannot form weak reference to instance (%p) of "
                            "class %s. It is possible that this object was "
                            "over-released, or is in the process of deallocation.",
                            (void*)referent, object_getClassName((id)referent));
            } else {
                return nil;
            }
        }
    }

    weak_entry_t *entry;
    // weak_entry_for_referent 函数在前面的 weak_unregister_no_lock 中已解释过。
    if ((entry = weak_entry_for_referent(weak_table, referent))) {
        append_referrer(entry, referrer);
    }  else {
        // 找不到对应的表,创建一个新表。
        weak_entry_t new_entry(referent, referrer);
        // 如果需要的话,对 weak_table 进行扩容。
        weak_grow_maybe(weak_table);
        // 将新表插入到 weak_table 中。
        weak_entry_insert(weak_table, &new_entry);
    }
    
    return referent_id;
}

// 向弱引用表中添加一个 weak 指针。
static void
append_referrer(weak_entry_t *entry, objc_object **new_referrer) {
    // 如果当前采用的是静态数组方案就执行这段逻辑。
    if (!entry->out_of_line()) {
        // 尝试在静态数组中插入数据。
        for (size_t i = 0; i < 4; i++) {
            if (entry->inline_referrers[i] == nil) {
                entry->inline_referrers[i] = new_referrer;
                return;
            }
        }

        // 来到这里说明静态数组已经存满了,后面的逻辑是把静态数组转为动态数组存储。
        weak_referrer_t *new_referrers = (weak_referrer_t *)
            calloc(4, sizeof(weak_referrer_t));
        
        for (size_t i = 0; i < 4; i++) {
            new_referrers[i] = entry->inline_referrers[i];
        }
        
        entry->referrers = new_referrers;
        entry->num_refs = 4;
        entry->out_of_line_ness = 2;
        entry->mask = 4 - 1;
        entry->max_hash_displacement = 0;
    }

    // 如果数组的元素数量大于等于总容量的 3/4,则对数组进行扩容并插入新数据。
    if (entry->num_refs >= (entry->mask + 1) * 3/4) {
        return grow_refs_and_insert(entry, new_referrer);
    }
    
    size_t begin = w_hash_pointer(new_referrer) & (entry->mask);
    size_t index = begin;
    size_t hash_displacement = 0;
    
    while (entry->referrers[index] != nil) {
        hash_displacement++;
        index = (index+1) & entry->mask;
        if (index == begin) bad_weak_table(entry);
    }
    
    // hash_displacement 保存的是此次遇到的哈希冲突次数,
    // 之所以要保存这个值,是因为取值的时候也会遇到哈希冲突,
    // 此时需要和这个值进行比较,如果大于这个值的话就说明哈希表中没有要找的数据。
    if (hash_displacement > entry->max_hash_displacement) {
        entry->max_hash_displacement = hash_displacement;
    }
    
    weak_referrer_t &ref = entry->referrers[index];
    ref = new_referrer;
    entry->num_refs++;
}

建立关联和解除关联的逻辑相似,本质都是通过 weak_entry_for_referent 获取对应的表。然后在通过 weak 指针进行一系列哈希运算,从而拿到要添加/要删除的数组索引,最终对数组元素进行添加/删除操作。

截止到这里,关于 weak 指针的整个初始化过程已经全部讲完了。如果你还想了解更多细节的话可以继续阅读后面的内容。

深入剖析 SideTable 类型

SideTable 是用来存储弱引用数据和引用计数的一个数据结构,由于这篇文章只涉及弱引用相关问题,所以在后面的源码中我会特意去掉与文章不相关的内容,如果你想了解全部细节的话请阅读源码。

struct SideTable {
 // 你可以把它理解成一个哈希表,
    // key 是对象;value 是对象对应的弱引用表数据。
    weak_table_t weak_table;
};

struct weak_table_t {
    /*
    这是一个数组,数组中的每个元素是一个 weak_entry_t 对象。
    weak_entry_t 里存储了弱引用的相关数据。
    */
    weak_entry_t *weak_entries;
    // 数组 weak_entries 的元素数量。
    size_t    num_entries;
    // 数组 weak_entries 的长度减一,求数组索引时需要用到。
    uintptr_t mask;
    /*
    记录在存储元素时遇到的最大的哈希冲突次数。
    在查找元素的时候会被用到,
    如果查找过程中哈希冲突次数大于这个值就说明哈希表中没有要找的数据。
     */
    uintptr_t max_hash_displacement;
};

// 真正用来存储弱引用数据的结构。
struct weak_entry_t {
    // weak 指针所指向的那个对象(类似哈希表中的 key)。
    id referent;
    
    /*
    从以下代码可以发现:系统内部在存储弱引用数据的时候有 2 套方案,
    如果数据比较少就使用静态数组 inline_referrers 存储;
    如果数据比较多就使用动态数组 referrers 存储。
    */
    union {
        struct {
            // 用来存储弱引用的动态数组。
            id *referrers;
            /*
            一个标记位,如果这个值是 2 的话就表示使用的是动态数组。
            因为 objc 对象的内存地址的最后一位只会是 0x8 或 0x0,
            即:如果采用的是静态数组方案的话,这个位置的数据绝对是 0。
            */
            uintptr_t        out_of_line_ness : 2;
            // 数组 referrers 的元素数量。
            uintptr_t        num_refs : 62;
            // 数组 referrers 的长度减一,和 weak_table_t 中的 mask 功能一样。
            uintptr_t        mask;
            // 和 weak_table_t 中的 max_hash_displacement 功能一样。
            uintptr_t        max_hash_displacement;
        };
        
        struct {
            // 用来存储弱引用的静态数组。
            id inline_referrers[4];
        };
    };
};

你可以把 SideTable 理解成一个获取弱引用表的入口,weak_table 类似一个大哈希表,这个哈希表的 key 是 objc 对象,value 是对应的弱引用数组。关于 weak 指针的操作,例如解除关联和建立关联,其实就是从 weak_table 中获取其对应的弱引用数组,然后从这个数组中移除或添加对应的弱指针地址。

weak 指针自动赋值 nil 的实现细节:weak_clear_no_lock

众所周知,weak 指针在对象被释放之后会自动指向 nil,那么它到底是如何实现的呢?

关于释放流程的函数调用顺序这里就不具体展开了,在 objc4-906 版本中,其函数调用顺序如下:dealloc => _objc_rootDealloc => rootDealloc => object_dispose => objc_destructInstance => clearDeallocating => clearDeallocating_slow => weak_clear_no_lock,我们重点看一下最后一个函数 weak_clear_no_lock 的实现细节。

void 
weak_clear_no_lock(weak_table_t *weak_table, id referent_id) {
    // 正在释放的对象。
    objc_object *referent = (objc_object *)referent_id;

    // 获取当前对象对应的弱引用表。
    weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);
    if (entry == nil) {
        return;
    }

    weak_referrer_t *referrers;
    size_t count;
    
    // 获取弱指针数组和数组容量。
    if (entry->out_of_line()) {
        referrers = entry->referrers;
        count = (entry->mask ? entry->mask + 1 : 0);
    } else {
        referrers = entry->inline_referrers;
        count = 4;
    }
    
    // 重点:系统会遍历弱指针数组中的每个地址,并将它们赋值为 nil。
    for (size_t i = 0; i < count; ++i) {
        objc_object **referrer = referrers[i];
        if (referrer) {
            if (*referrer == referent) {// 等价于 weakSelf == self(self 表示当前正在释放的对象)
                *referrer = nil;// 等价于 weakSelf = nil;
            } else if (*referrer) {
                REPORT_WEAK_ERROR("__weak variable at %p holds %p instead of %p. "
                                  "This is probably incorrect use of "
                                  "objc_storeWeak() and objc_loadWeak().",
                                  referrer, (void*)*referrer, (void*)referent);
            }
        }
    }
    
    // 从弱引用表中移除这张表。
    weak_entry_remove(weak_table, entry);
}

从源码中不难发现,系统会在对象释放的时候,获取其对应的弱引用表,然后遍历这个表中的 weak 指针并将其赋值为 nil。

到此为止,关于 weak 指针的所有内容就讲完了。

总结

在 runtime 初始化的时候,会调用一个全局函数 side_tables_init 初始化一个全局数组,数组的元素是 SideTable 对象,可以通过全局函数 SideTables 拿到这个数组以及对象对应的 SideTable 对象。

SideTable 中有一个变量 weak_table,你可以将它理解成一个哈希表,哈希表的 key 是对象,value 是一个数组,数组中的元素就是指向这个 key 的 weak 指针地址。

weak 指针的初始化操作就是拿到这个对象对应的弱指针数组,然后往数组里面把 weak 指针的内存地址添加进去。

如果 weak 指针需要指向别的对象,需要拿到旧对象对应的弱指针数组并将数组中存放 weak 指针的那个位置置空,然后拿到新对象对应的弱指针数组并将 weak 指针添加进去。

如果对象释放了,就拿到这个对象对应的弱指针数组并挨个将里面的 weak 指针赋值为 nil。

为了加深自己的理解,我模仿系统的实现写了一个示例项目 WeakPointer,我在这个项目里还给分类属性也支持了 weak 特性,感兴趣的同学可以参考一下。

关于 weak 指针的一些疑问与解答

为什么不能给 Category 添加 weak 属性?

我们一般是这样初始化一个 weak 指针:__weak id weakPtr = obj;,从源码中我们知道编译器会把代码转换成这样:objc_storeWeak((void *)&weakPtr, obj);

从这里可以发现,要实现 weak 特性,你必须能拿到 obj 对象和 weak 指针的内存地址,而 Category 中的属性是依靠 runtime 中的 objc_setAssociatedObjectobjc_getAssociatedObject 这 2 个函数来实现的,我们拿不到 weak 指针的内存地址,故而无法给 Category 的属性支持 weak 特性。

类的属性之所以支持 weak 特性,是因为编译器能拿到这个属性的成员变量的地址(即 weak 指针的内存地址)。

如果你一定要给 Category 添加 weak 属性的话,有以下 2 个思路(建议选择第 2 个):

  1. 参考我的这个项目 WeakPointer 模仿系统的实现手动维护一个弱引用表来支持 Category 的 weak 属性。

  2. 创建一个中间类,给中间类声明一个 weak 属性,Category 的属性强引用这个中间类,中间类的 weak 属性指向真正的对象。

    示例代码:

    @interface WeakTarget : NSObject
    
    @property (nonatomic, weak) id weakObj;
    
    @end
    
    @interface Person(Category)
    
    @property (nonatomic, weak) id weakObj;
    
    @end
    
    @implementation Person (Category)
    
    - (void)setWeakObj:(NSObject *)obj {
        WeakTarget *target = [[WeakTarget alloc] init];
        target.weakObj = obj;
    
        objc_setAssociatedObject(self, @selector(weakObj), target, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    
    - (id)weakObj {
        WeakTarget *target = objc_getAssociatedObject(self, @selector(weakObj));
        return target.weakObj;
    }
    
    @end
    
    @implementation WeakTarget @end
    

为什么在 block 中不能使用 weak 指针访问其成员变量。

这是我在项目中实际遇到的一个问题,伪代码如下:

__weak typeof(self) weakSelf = self;
    
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    weakSelf->_propertyName;// 使用 weak 指针直接访问成员变量。
});

上面的代码无法通过编译,如果改成这样就没问题了:weakSelf.propertyName;

众所周知,在 objc 里访问属性最终还是会访问成员变量。那为什么访问属性就正常,访问成员变量就会报错呢?

之所以会这样,是因为现在的编译器比较智能,考虑的比较多。weakSelf 在运行时有可能为 nil 从而导致崩溃,编译器认为这样的代码不安全所以报错。但是使用 weakSelf 访问属性是安全的,因为访问属性实际上是调用了属性的 get/set 方法,在 objc 里对 nil 调用方法是不会导致异常。

上面的报错代码可以改成以下代码来解决编译报错:

dispatch_async(dispatch_get_global_queue(0, 0), ^{
    if (weakSelf) {// 这一步很重要,因为这里的 weak 指针有可能已经是 nil 了。
        __strong typeof(weakSelf) strongSelf = weakSelf;
        strongSelf->_propertyName;
    }
});

为什么在对象没有弱引用时也会执行 weak_clear_no_lock

在研究 weak 指针自动赋值 nil 的过程中,我发现,对象只要曾经被 weak 指针指向过,在对象释放的时候即使没有指向它的 weak 指针,也会执行到 weak_clear_no_lock 函数。

在 storeWeak 函数中会调用这行代码设置对象被 weak 指针指向的标记:newObj->setWeaklyReferenced_nolock();

但是,当对象没有任何 weak 指针指向时,weak_unregister_no_lock 函数中并没有调用相关函数将标记设置为 false。

这会导致在对象释放的时候,即调用到 rootDealloc 函数时无法执行快速释放逻辑。

inline void
objc_object::rootDealloc() {
    if (isTaggedPointer()) return;

    if (fastpath(isa().nonpointer                     &&
    // 如果对象曾经被 weak 指针指向过,即使现在没有了,weakly_referenced 也是 true。
                 !isa().weakly_referenced             &&
                 !isa().has_assoc                     &&
                 !isa().getClass(false)->hasCxxDtor() &&
                 !isa().has_sidetable_rc)) {
        free(this);
    } else {
        object_dispose((id)this);
    }
}

我也不太清楚为什么这么做?如果你知道其中的具体细节的话,还请留言告知。

深入理解 iOS UIScene:多窗口时代的应用程序架构

作者 Lexiaoyao20
2025年2月12日 15:32

随着 iPadOS 的推出和 iOS 对多任务处理需求的增加,Apple 在 iOS 13 中引入了 UIScene 这一新概念,彻底改变了应用生命周期和窗口管理的模式。本文将深入探讨 UIScene 的概念、工作原理以及实际应用场景。

1. UIScene 概述

UIScene 代表的是一个用户界面场景,可以是一个窗口或者一个任务。例如,当用户在 iPad 上打开多个 Safari 标签页时,每个标签页就是一个独立的 UIScene

UIScene 的引入将“应用”与“界面实例”解耦,每个场景(Scene)代表一个独立的用户交互单元。

关键组件

  1. UIWindowScene

    • 继承自 UIScene
    • 管理一组窗口的容器
    • 持有显示配置信息,如界面方向、大小等
  2. UISceneDelegate

    • 处理场景级别的生命周期事件
    • 负责场景的 UI 设置和状态恢复
    • 管理场景的前后台转换
  3. UISceneSession

    • 维护场景的持久化配置
    • 在应用程序重启后恢复场景状态
    • 存储场景相关的用户活动

2. 生命周期管理

在引入 UIScene 后,iOS 应用的生命周期管理发生了变化。以前我们通常依赖 AppDelegate 来管理整个应用的生命周期,比如启动、进入后台等事件。而现在,UIScene 允许每个窗口(或场景)有自己的生命周期。

  • AppDelegate 负责应用级的事件处理。
  • SceneDelegate 负责每个独立场景(窗口)的事件处理。

UIScene 的生命周期状态包括:

enum UIScene.ActivationState {
    case unattached  // 场景未连接
    case foregroundInactive  // 前台非活动状态
    case foregroundActive    // 前台活动状态
    case background         // 后台状态
}

以下是常见的 UIScene 生命周期方法:

  • scene(_:willConnectTo:options:):当新场景(窗口)准备与应用连接时调用,通常在这里设置场景的界面。
  • sceneDidBecomeActive(_:):场景激活时调用,表示该场景开始显示。
  • sceneWillResignActive(_:):场景即将失去激活状态时调用。
  • sceneDidEnterBackground(_:):场景进入后台时调用。
  • sceneWillEnterForeground(_:):场景从后台恢复时调用。
  • sceneDidDisconnect(_:):场景断开时调用,通常在应用关闭时或场景被销毁时触发。

3. 如何使用 UIScene

以下是在iOS项目中使用 UIScene 的基本步骤:

1. 启用 Scenes 支持

在项目的 Info.plist 文件中,添加 UIApplicationSceneManifest 键以启用 Scene 支持。这个配置将告诉系统应用支持多场景(多个窗口)。

// 在 Info.plist 中启用多窗口支持
<key>UIApplicationSupportsMultipleScenes</key>
<true/>

// 场景配置
<key>UISceneConfigurations</key>
<dict>
    <key>UIWindowSceneSessionRoleApplication</key>
    <array>
        <dict>
            <key>UISceneConfigurationName</key>
            <string>Default Configuration</string>
            <key>UISceneDelegateClassName</key>
            <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
        </dict>
    </array>
</dict>

2. 创建 SceneDelegate 类

每个 UIScene 都需要一个对应的 SceneDelegate 类。SceneDelegateUIScene 生命周期的管理者,类似于 AppDelegate,但它管理的是单个场景。

class SceneDelegate: UIResponder, UISceneDelegate {
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        // 设置窗口和根视图控制器
    }
    
    func sceneDidDisconnect(_ scene: UIScene) {
        // 场景断开连接时的清理工作
    }
    
    func sceneDidBecomeActive(_ scene: UIScene) {
        // 场景变为活动状态
    }
    
    func sceneWillResignActive(_ scene: UIScene) {
        // 场景即将变为非活动状态
    }

    func sceneDidEnterBackground(_ scene: UIScene) {
        // 场景进入后台
    }

    func sceneWillEnterForeground(_ scene: UIScene) {
        // 场景将进入前台
    }
}

3. 场景创建

iPad 上可以有多个 UIScene,这意味着同一个应用可以拥有多个窗口,用户可以同时查看和操作这些窗口。系统会根据需要创建新的 UIScene 实例,通常是通过长按应用图标或拖动应用窗口来启动新场景。

AppDelegate 中,你可以监听场景的创建和销毁事件:

func application(_ application: UIApplication,
                 configurationForConnecting connectingSceneSession: UISceneSession,
                 options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    return UISceneConfiguration(name: "Default Configuration",
                              sessionRole: connectingSceneSession.role)
}

4. 状态恢复(可选)

UIScene 还提供了强大的状态恢复机制,你可以根据需要来决定是否使用该功能。

func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
    // 返回需要保存的状态
    let activity = NSUserActivity(activityType: "com.example.app.browsing")
    activity.userInfo = ["lastViewedPage": currentPage]
    return activity
}

func scene(_ scene: UIScene, restoreInteractionStateWith stateRestorationActivity: NSUserActivity) {
    // 恢复保存的状态
    if let page = stateRestorationActivity.userInfo?["lastViewedPage"] as? String {
        navigateToPage(page)
    }
}

4. 最佳实践

  1. 合理规划场景配置

    • 根据应用程序的具体需求配置场景
    • 考虑不同设备类型的场景表现
  2. 正确处理状态转换

    • 在适当的生命周期回调中保存和恢复状态
    • 处理好资源的分配和释放
  3. 注意性能优化

    • 避免在场景切换时进行耗时操作
    • 合理管理内存和系统资源

5. 总结

UIScene 的优势:

  • 多窗口支持:iPad 上可以通过多窗口功能,允许用户在同一个应用中同时查看不同的内容或执行多个任务。
  • 生命周期细化:每个窗口(或场景)有自己的生命周期,这对于处理多个活跃窗口的状态非常有用。
  • 优化的用户体验:通过场景管理,开发者能够根据不同的使用场景来调整界面和行为,提升用户体验。

UIScene 架构的引入使 iOS 应用程序在多窗口环境下的开发变得更加灵活和强大。通过合理使用 UIScene,我们可以为用户提供更好的多任务体验,同时保持应用程序的性能和稳定性。

6. 参考资源

swift拾遗(条件语句)

2025年2月12日 11:02

控制流

通过分支、循环和提前退出等方式组织代码。

Swift 提供了多种控制流语句。其中包括 while 循环,用于多次执行一项任务;ifguardswitch 语句,根据特定条件执行不同的代码分支;以及 breakcontinue 等语句,用于将执行流转移到代码中的其他位置。Swift 还提供了 for-in 循环,可轻松遍历数组、字典、区间、字符串及其他序列。此外,Swift 提供了 defer 语句,用于封装在离开当前作用域时要执行的代码。

Swift 的 switch 语句比许多类 C 语言中的 switch 语句功能强大得多。case 可以匹配多种不同的模式,包括区间匹配、元组以及转换为特定类型。switch 语句中匹配的值可以绑定到临时常量或变量,以便在 case 主体中使用,并且每个 case 都可以使用 where 子句来表达复杂的匹配条件。

for-in 循环

使用 for-in 循环遍历序列,例如数组中的元素、数字范围或字符串中的字符。

以下示例使用 for-in 循环遍历数组中的元素:

image.png

你还可以遍历字典以访问其键值对。当遍历字典时,字典中的每个元素都作为一个 (key, value) 元组返回,并且你可以将 (key, value) 元组的成员分解为显式命名的常量,以便在 for - in 循环体中使用。在下面的代码示例中,字典的键被分解为一个名为 animalName 的常量,字典的值被分解为一个名为 legCount 的常量。

image.png

字典遍历顺序

字典的内容本质上是无序的,对其进行遍历并不能保证按特定顺序获取元素。具体而言,你向字典中插入元素的顺序并不能决定遍历它们的顺序。有关数组和字典的更多信息,请参阅“集合类型”。

使用 for-in 循环遍历数值区间

你还可以在数值区间上使用 for-in 循环。以下示例打印了 5 的乘法表的前几项:

image.png

正在遍历的序列是一个从 1 到 5(包含 1 和 5)的数字区间,这由闭区间运算符(...)表示。index 的值被设为该区间中的第一个数字(即 1),然后执行循环体内的语句。在这个例子中,循环体里只有一条语句,它会打印出当前 index 值对应的 5 的乘法表中的一项。语句执行完毕后,index 的值会更新为区间中的第二个值(即 2),接着再次调用 print(_:separator:terminator:) 函数。这个过程会一直持续,直到到达区间的末尾。

在上述示例中,index 是一个常量,其值会在每次循环开始时自动设置。因此,index 在使用前无需声明。只需将其包含在循环声明中,它就会被隐式声明,而无需使用 let 声明关键字。

如果你不需要序列中的每个值,可以使用下划线来替代变量名,从而忽略这些值。

image.png

上面的示例计算了一个数的另一个数次幂(在这个例子中是 3 的 10 次幂)。它从起始值 1(即 3 的 0 次幂)开始,将其乘以 3,共乘 10 次,使用的是一个从 1 到 10 的闭区间。对于这个计算,每次循环时具体的计数器值是不需要的 —— 代码只是让循环正确执行指定的次数。用下划线字符(_)代替循环变量可以忽略这些具体的值,并且在每次循环迭代期间不会访问当前值。

在某些情况下,你可能不想使用包含两个端点的闭区间。以在手表表盘上绘制每分钟的刻度线为例。你要绘制 60 条刻度线,从 0 分钟开始。这时可以使用半开区间运算符(..<),它包含下限但不包含上限。有关区间的更多信息,请参阅“区间运算符”。

image.png

有些用户可能希望他们的用户界面(UI)中有更少的刻度线。他们可能更倾向于每 5 分钟设置一个刻度。可以使用 stride(from:to:by:) 函数来跳过不需要的刻度。

image.png

通过使用 stride(from:through:by:) 函数,也可以实现闭区间的步长遍历: image.png

上述示例使用 for-in 循环来遍历区间、数组、字典和字符串。不过,只要类型符合 Sequence 协议,你就可以使用这种语法来遍历任何集合,包括你自己定义的类和集合类型。

while 循环

while 循环会持续执行一组语句,直到某个条件变为 false。当在第一次迭代开始之前还不知道迭代次数时,使用这类循环是最合适的。Swift 提供了两种 while 循环:

  • while:在每次循环开始时计算条件。
  • repeat-while:在每次循环结束时计算条件。

while

while 循环首先计算一个条件。如果条件为 true,就会重复执行一组语句,直到条件变为 false

以下是 while 循环的一般形式: image.png

repeat - while 循环

while 循环的另一种变体,即 repeat - while 循环,会先执行一次循环体,再去判断循环条件。然后它会持续重复执行循环,直到条件为 false

注意

Swift 中的 repeat - while 循环类似于其他语言中的 do - while 循环。

以下是 repeat - while 循环的一般形式:

image.png

条件语句

根据特定条件执行不同的代码段通常很有用。比如,当出现错误时你可能想运行一段额外的代码,或者当某个值过高或过低时显示一条消息。要实现这一点,你需要让代码的某些部分具有条件性。

Swift 提供了两种向代码中添加条件分支的方式:if 语句和 switch 语句。通常,你会使用 if 语句来评估只有几种可能结果的简单条件。而 switch 语句更适合有多种可能排列组合的复杂条件,并且在模式匹配有助于选择合适的代码分支来执行的情况下很有用。

if

if 语句最简单的形式是只有一个 if 条件。只有当该条件为 true 时,它才会执行一组语句。

image.png

image.png

在这里,每个分支都会为 weatherAdvice 常量设置一个值,该常量会在 if 语句结束后被打印出来。

使用另一种语法,也就是所谓的 if 表达式,你可以更简洁地编写这段代码:

image.png

在这个 if 表达式版本中,每个分支都包含一个单一的值。如果某个分支的条件为 true,那么该分支的值就会在 weatherAdvice 的赋值操作中作为整个 if 表达式的值。每个 if 分支都有对应的 else if 分支或 else 分支,这确保了总有一个分支会匹配成功,并且无论哪些条件为 trueif 表达式总能产生一个值。

由于赋值操作的语法在 if 表达式外部就已开始,因此无需在每个分支内重复 weatherAdvice =。相反,if 表达式的每个分支都会产生 weatherAdvice 三种可能值中的一个,然后赋值操作会使用这个值。

if 表达式的所有分支都需要包含相同类型的值。因为 Swift 会分别检查每个分支的类型,像 nil 这种可用于多种类型的值会阻碍 Swift 自动确定 if 表达式的类型。此时,你需要显式指定类型,例如:

image.png

在上述代码中,if 表达式的一个分支具有字符串值,而另一个分支具有 nil 值。nil 值可作为任何可选类型的值使用,因此你必须像“类型注解”中所描述的那样,显式声明 freezeWarning 是一个可选字符串。

提供此类型信息的另一种方式是为 nil 显式指定类型,而不是为 freezeWarning 显式指定类型:

image.png

if 表达式可以通过抛出错误,或者调用像 fatalError(_:file:line:) 这种不会有返回值的函数,来应对意外的失败情况。例如:

image.png

在这个例子中,if 表达式会检查预报温度是否高于 100°C(水的沸点)。如果温度达到这么高,if 表达式会抛出一个 .boiling 错误,而不是返回一个文本摘要。尽管这个 if 表达式可能会抛出错误,但在它前面不需要写 try。有关处理错误的信息,请参阅“错误处理”。

除了像上述示例那样在赋值语句的右侧使用 if 表达式,你还可以将其用作函数或闭包返回的值。

switch 语句

switch 语句会考量一个值,并将其与多个可能匹配的模式进行比较。然后,根据第一个成功匹配的模式,执行相应的代码块。switch 语句为响应多种可能状态提供了一种替代 if 语句的方式。

switch 语句最简单的形式是将一个值与一个或多个相同类型的值进行比较。

image.png

每个 switch 语句都由多个可能的 case(分支)组成,每个分支都以 case 关键字开头。除了与特定值进行比较之外,Swift 还提供了多种方式让每个 case 指定更复杂的匹配模式。本章后续会介绍这些选项。

if 语句体一样,每个 case 都是一个独立的代码执行分支。switch 语句会确定应该选择哪个分支。这个过程被称为对所考量的值进行“分支选择”。

每个 switch 语句都必须是详尽的。也就是说,所考量类型的每个可能的值都必须能被某个 switch 分支匹配到。如果为每个可能的值都提供一个 case 不合适,你可以定义一个默认分支来涵盖那些未被明确处理的值。这个默认分支用 default 关键字表示,并且必须总是放在最后。

这个示例使用 switch 语句来考量一个名为 someCharacter 的单个小写字符:

image.png

image.png 在这个例子中,switch 表达式里的每个 case 都包含了 message 的值,当该 caseanotherCharacter 匹配时就会使用这个值。由于 switch 语句总是要求详尽匹配,所以总会有一个值用于赋值。

if 表达式一样,你可以抛出一个错误,或者调用像 fatalError(_:file:line:) 这种不会有返回值的函数,而不是为某个特定的 case 提供一个值。你可以像上述示例那样,在赋值语句的右侧使用 switch 表达式,也可以将其作为函数或闭包返回的值。

无隐式贯穿

与 C 和 Objective - C 中的 switch 语句不同,Swift 中的 switch 语句默认不会从每个 case 的末尾贯穿到下一个 case。相反,一旦第一个匹配的 switch 分支执行完毕,整个 switch 语句就会结束执行,无需显式的 break 语句。这使得 Swift 的 switch 语句比 C 语言中的更安全、更易用,也避免了误执行多个 switch 分支的情况。

注意

虽然在 Swift 中 break 不是必需的,但你可以使用 break 语句来匹配并忽略某个特定的 case,或者在某个匹配的 case 执行完毕之前跳出该 case。详细信息请参阅“switch 语句中的 break”。

每个 case 的语句体必须至少包含一条可执行语句。以下代码是无效的,因为第一个 case 为空:

image.png

与 C 语言中的 switch 语句不同,这个 switch 语句不会同时匹配 "a""A"。相反,它会报出一个编译时错误,提示 case "a": 不包含任何可执行语句。这种处理方式避免了意外地从一个 case 贯穿到另一个 case,使代码更安全,意图也更清晰。

若要使用单个 case 同时匹配 "a""A",可以将这两个值组合成一个复合 case,用逗号分隔这些值。

image.png

为了提高可读性,复合 case 也可以写成多行形式。有关复合 case 的更多信息,请参阅“复合 case”。

注意

如果要在某个特定的 switch case 结束时显式地贯穿到下一个 case,请使用 fallthrough 关键字,详情请参阅“贯穿”。

区间匹配

可以检查 switch case 中的值是否包含在某个区间内。以下示例使用数字区间为任意大小的数字提供自然语言描述的数量表达:

image.png

在上述示例中,approximateCountswitch 语句中进行判断。每个 case 都会将该值与一个数字或区间进行比较。由于 approximateCount 的值介于 12 和 100 之间,naturalCount 被赋值为 “dozens of”,随后程序执行流程会跳出 switch 语句。

元组

你可以使用元组在同一个 switch 语句中测试多个值。元组的每个元素都可以与不同的值或值的区间进行比较。或者,使用下划线字符(_),也称为通配符模式,来匹配任何可能的值。

下面的示例使用一个表示为简单 (Int, Int) 类型元组的 (x, y) 坐标点,并按照示例后面的图表对其进行分类。

image.png

image.png

switch 语句判断该点是在原点 (0, 0) 、在红色的 x 轴上、在绿色的 y 轴上、在以原点为中心的蓝色 4x4 方格内,还是在方格之外。

与 C 语言不同,Swift 允许多个 switch 分支考虑相同的值。实际上,点 (0, 0) 可能匹配此示例中的所有四个分支。但是,如果存在多个可能的匹配项,始终会使用第一个匹配的分支。点 (0, 0) 会首先匹配 case (0, 0),因此所有其他匹配分支将被忽略。

值绑定

switch 分支可以将其匹配的值命名为临时常量或变量,以便在分支体内使用。这种行为称为值绑定,因为这些值在分支体内绑定到临时常量或变量。

下面的示例使用一个表示为 (Int, Int) 类型元组的 (x, y) 点,并按照随后的图表对其进行分类:

image.png

image.png

where 子句

switch 分支可以使用 where 子句来检查更多条件。

下面的示例对以下图表中的 (x, y) 点进行分类:

image.png

switch 语句判断该点是在绿色对角线上(此时x == y),在紫色对角线上(此时x == -y),还是不在这两条对角线上。

这三个 switch 分支声明了占位常量xy,它们临时获取yetAnotherPoint中的两个元组值。这些常量用作where子句的一部分,以创建动态筛选条件。只有当where子句的条件对该值求值为true时,switch分支才会匹配当前的point值。

和上一个示例一样,最后一个分支匹配所有可能剩余的值,所以不需要default分支来使switch语句详尽无遗。

复合分支

多个拥有相同语句体的switch分支可以合并,方法是在case后编写多个模式,各模式之间用逗号分隔。只要其中任何一个模式匹配,就认为该分支匹配。如果模式列表很长,可以将模式写成多行。例如:

image.png

这个switch语句的第一个分支匹配英语中的所有五个小写元音。同样,第二个分支匹配所有小写英语辅音。最后,默认分支匹配任何其他字符。

复合分支也可以包含值绑定。复合分支的所有模式都必须包含相同的一组值绑定,并且每个绑定都必须从复合分支的所有模式中获取相同类型的值。这确保了无论复合分支的哪一部分匹配,分支主体中的代码始终可以访问绑定的值,并且该值始终具有相同的类型。

image.png

上述示例有两种模式:(let distance, 0) 匹配 x 轴上的点,而 (0, let distance) 匹配 y 轴上的点。这两种模式都包含对 distance 的绑定,并且在这两种模式中 distance 都是整数 —— 这意味着 case 主体中的代码始终可以访问 distance 的值。

控制转移语句

控制转移语句通过将控制权从一段代码转移到另一段代码,改变代码的执行顺序。Swift 有五种控制转移语句:

  • continue
  • break
  • fallthrough
  • return
  • throw

continuebreakfallthrough 语句将在下面介绍。return 语句在 “函数” 部分介绍,throw 语句在 “使用抛出函数传播错误” 部分介绍。

continue

continue 语句告诉循环停止当前正在做的事情,并从循环的下一次迭代开始重新执行。它表示 “我已完成当前的循环迭代”,但不会完全离开循环。

下面的示例从一个小写字符串中移除所有元音字母和空格,以创建一个神秘的谜题短语:

image.png

上述代码只要匹配到元音字母或空格,就会调用continue关键字,这会使循环的当前迭代立即结束,并直接跳转到下一次迭代的开始。

break

break语句会立即终止整个控制流语句的执行。当你希望比正常情况更早地终止switch或循环语句的执行时,可在switch或循环语句内部使用break语句。

循环语句中的break

在循环语句内部使用时,break会立即结束循环的执行,并将控制权转移到循环结束大括号(})之后的代码。循环当前迭代中剩余的代码不会被执行,并且不会再启动循环的后续迭代。

switch语句中的break

switch语句内部使用时,break会使switch语句立即结束执行,并将控制权转移到switch语句结束大括号(})之后的代码。

这种行为可用于在switch语句中匹配并忽略一个或多个case。由于Swift的switch语句要求详尽且不允许有空的case,所以有时为了明确表达意图,有必要刻意匹配并忽略某个case。你可以通过将break语句作为要忽略的case的整个主体来实现这一点。当switch语句匹配到该case时,case内部的break语句会立即结束switch语句的执行。

注意

只包含注释的switch case会报告为编译时错误。注释不是语句,不会使switch case被忽略。始终使用break语句来忽略switch case

以下示例对Character值进行switch判断,并确定它是否代表四种语言之一中的数字符号。为简洁起见,单个switch case涵盖了多个值。

image.png

贯穿(fallthrough)

在Swift中,switch语句不会从一个case的末尾自然贯穿到下一个case。也就是说,一旦第一个匹配的case执行完毕,整个switch语句就完成了执行。相比之下,C语言要求你在每个switch case的末尾插入一个显式的break语句,以防止贯穿行为。Swift避免了默认的贯穿行为,这使得switch语句比C语言中的同类语句更加简洁和可预测,从而避免了意外执行多个switch case的情况。

如果你需要C语言风格的贯穿行为,可以在每个case的基础上,使用fallthrough关键字来选择启用这种行为。下面的示例使用fallthrough来创建一个数字的文本描述。

image.png

这个示例声明了一个名为description的新String变量,并为其赋予初始值。然后,函数使用switch语句来判断integerToDescribe的值。如果integerToDescribe的值是列表中的质数之一,函数会在description的末尾追加文本,以表明该数字是质数。接着,它使用fallthrough关键字 “贯穿” 到default分支。default分支会在description的末尾添加一些额外的文本,至此switch语句执行完毕。

除非integerToDescribe的值在已知质数列表中,否则它根本不会与第一个switch case匹配。由于没有其他特定的caseintegerToDescribe会与default分支匹配。

switch语句执行完毕后,使用print(_:separator:terminator:)函数打印该数字的描述。在这个示例中,数字5被正确识别为质数。

注意

fallthrough关键字不会检查它导致执行转入的switch case的条件。fallthrough关键字只是使代码执行直接转移到下一个case(或default分支)块内的语句,就像C语言标准switch语句的行为一样。即使不满足下一个 case判断条件也能进入 case 语句中

image.png

带标签语句

在Swift中,你可以将循环和条件语句嵌套在其他循环和条件语句内部,以创建复杂的控制流结构。然而,循环和条件语句都可以使用break语句提前结束其执行。因此,有时明确指出你希望break语句终止哪个循环或条件语句是很有用的。同样,如果你有多个嵌套循环,明确指出continue语句应影响哪个循环也很有用。

为了实现这些目的,你可以用语句标签标记循环语句或条件语句。对于条件语句,你可以将语句标签与break语句配合使用,以结束带标签语句的执行。对于循环语句,你可以将语句标签与breakcontinue语句配合使用,以结束或继续带标签语句的执行。

带标签语句通过将标签与语句的起始关键字放在同一行,后面紧跟一个冒号来表示。以下是while循环的这种语法示例,不过所有循环和switch语句的原理都是一样的:

image.png

image.png 这个版本的游戏使用while循环和switch语句来实现游戏逻辑。while循环带有一个名为gameLoop的语句标签,用以表明它是“蛇梯棋”游戏的主游戏循环。

while循环的条件是while square!= finalSquare,这意味着玩家必须正好落在第25格上。

image.png

在每次循环开始时掷骰子。循环并不会立即移动玩家位置,而是使用switch语句来考量移动结果,并判断该移动是否被允许:

  • 如果掷出的骰子点数会使玩家移动到最终方格,游戏结束。break gameLoop语句将控制权转移到while循环外部的第一行代码,从而结束游戏。
  • 如果掷出的骰子点数会使玩家移动超过最终方格,该移动无效,玩家需要重新掷骰子。continue gameLoop语句结束当前while循环迭代,并开始下一次循环迭代。
  • 在其他所有情况下,掷出的骰子点数对应的移动是有效的。玩家向前移动diceRoll个方格,然后游戏逻辑检查是否遇到蛇梯。接着循环结束,控制权回到while条件判断处,以决定是否需要进行下一回合。

注意

如果上述break语句没有使用gameLoop标签,它将跳出switch语句,而不是while语句。使用gameLoop标签能明确表明应该终止哪个控制语句。

在调用continue gameLoop跳转到循环的下一次迭代时,严格来说并不一定非要使用gameLoop标签。因为游戏中只有一个循环,所以continue语句会影响哪个循环并无歧义。不过,在continue语句中使用gameLoop标签并无坏处。这样做与在break语句中使用该标签保持一致,有助于使游戏逻辑更易于阅读和理解。

提前退出

guard语句和if语句类似,会根据表达式的布尔值来执行语句。使用guard语句是为了要求某个条件必须为真,这样guard语句之后的代码才能执行。与if语句不同,guard语句始终有一个else子句 —— 如果条件不为真,就会执行else子句中的代码。

image.png 如果guard语句的条件满足,代码执行会在guard语句的结束花括号之后继续。在条件中通过可选绑定赋值的任何变量或常量,在guard语句所在的代码块的其余部分都可用。

如果条件不满足,则会执行else分支内的代码。该分支必须转移控制权,以退出guard语句所在的代码块。它可以通过诸如returnbreakcontinuethrow之类的控制转移语句来实现,或者可以调用一个不返回的函数或方法,例如fatalError(_:file:line:)

与使用if语句进行相同的检查相比,使用guard语句来处理必要条件可提高代码的可读性。它使你可以编写通常会执行的代码,而无需将其包装在else块中,并且可以将处理未满足条件的代码放在该条件旁边。

延迟执行操作

ifwhile等控制流结构不同,ifwhile用于控制代码的某部分是否执行或执行多少次,而defer用于控制一段代码何时执行。你可以使用defer块编写稍后执行的代码,当程序到达当前作用域的末尾时,就会执行这些代码。例如:

image.png

在上述示例中,defer块内的代码会在退出if语句主体之前执行。首先,if语句中的代码运行,将score增加5。然后,在退出if语句的作用域之前,运行延迟执行的代码,即打印score

无论程序如何退出该作用域,defer内的代码始终会运行。这包括从函数提前返回、跳出for循环或抛出错误等情况。这种特性使得defer对于需要确保成对操作执行的场景很有用,比如手动分配和释放内存、打开和关闭底层文件描述符,以及在数据库中开始和结束事务,因为你可以在代码中将这两个操作写在一起。例如,以下代码通过在一段代码内对score先加100再减100,给score一个临时奖励:

image.png

如果在同一作用域内编写多个 defer 块,你最先指定的 defer 块会最后运行。 image.png

如果程序停止运行(例如,由于运行时错误或崩溃),延迟执行的代码将不会执行。但是,在抛出错误后,延迟执行的代码会执行;有关在错误处理中使用defer的信息,请参阅“指定清理操作”。

检查API可用性

Swift 内置了对API可用性检查的支持,这能确保你不会意外使用在特定部署目标上不可用的API。

编译器会利用SDK中的可用性信息,来验证代码中使用的所有API在项目指定的部署目标上是否可用。如果你尝试使用不可用的API,Swift会在编译时报告错误。

你可以在ifguard语句中使用可用性条件,根据所需使用的API在运行时是否可用,有条件地执行一段代码。编译器在验证该代码块中的API是否可用时,会使用来自可用性条件的信息。

image.png

上述可用性条件指定,在iOS系统中,if语句的主体仅在iOS 10及更高版本中执行;在macOS系统中,仅在macOS 10.12及更高版本中执行。最后一个参数*是必需的,它指定在任何其他平台上,if语句的主体将在项目目标所指定的最低部署目标上执行。

一般来说,可用性条件采用平台名称和版本号的列表形式。你可以使用诸如iOS、macOS、watchOS、tvOS和visionOS等平台名称 —— 完整列表请参阅“声明属性”。除了指定像iOS 8或macOS 10.10这样的主版本号,你还可以指定像iOS 11.2.6和macOS 10.13.3这样的次版本号。 image.png

当你在guard语句中使用可用性条件时,它会完善该代码块中其余代码所使用的可用性信息。

image.png 在上述示例中,ColorPreference结构体要求在macOS 10.12或更高版本下使用。chooseBestColor()函数以一个可用性guard语句开头。如果平台版本过低,无法使用ColorPreference,函数会回退到始终可用的行为。在guard语句之后,你可以使用要求macOS 10.12或更高版本的API。

除了#available,Swift还支持使用不可用性条件进行相反的检查。例如,以下两个检查的作用相同:

image.png 当检查仅包含备用代码时,使用#unavailable形式有助于提高代码的可读性。

探寻 iOS Delegate:从基础到应用的优雅之旅

作者 万链飞空
2025年2月12日 10:34

引言

iOS 开发中,Objective - C 语言提供了许多强大的特性来帮助开发者更高效地构建应用程序,其中 Category(类别,也被称为分类)就是一个非常实用且灵活的特性。它允许开发者在不修改原有类代码的情况下,为已有的类添加新的方法,极大地增强了代码的可维护性和扩展性。本文将深入探讨 iOS Category 的各个方面,包括其简介、底层原理、作用、应用场景以及优缺点。

简介

CategoryObjective - C 语言的一种机制,用于为现有的类添加新的方法,而无需创建子类或修改原始类的代码。其语法结构相对简单,主要由接口部分和实现部分组成

使用方式

接口部分

@interface ExistingClass (CategoryName)
- (void)newMethod;
@end

实现部分

@implementation ExistingClass (CategoryName)
- (void)newMethod {
    // 方法实现代码
}
@end

在上述代码中,ExistingClass 是已有的类名,CategoryName 是为这个类别取的名称,newMethod 是我们为该类添加的新方法

category_t 结构体

struct _category_t {
    const char *name;                            // 类名
    classref_t cls;
    struct method_list_t *instanceMethods;       // 对象方法列表
    struct method_list_t *classMethods;          // 类方法列表
    struct protocol_list_t *protocols;           // 协议列表
    struct property_list_t *instanceProperties;  // 属性列表
    struct property_list_t *_classProperties;    // 类属性
};
  • 根据category_t 结构体可以看出,Category 可以为类添加对象方法、类方法、协议、属性
  • category_t 结构体中不包含 _ivar_list_t 类型,也就是不包含成员变量结构体,说明Category 中不能添加成员变量
  • 分类本身并不是一个真正的类,因为它并没有自己的 isa
  • 分类结构体中存在属性列表,所以可以声明属性,但是分类只会生成该属性对应的get 和 set的声明,没有去实现该方法

Category 加载过程

Category 的加载是通过 Runtime 在运行时动态完成的,具体过程如下:

编译阶段

  • 编译器会将 Category 中的方法、属性、协议等信息编译到 category_t 结构体中
  • 每个 Category 会生成一个对应的 category_t 结构体实例

运行时加载

  • 在程序启动时,Runtime 会调用 _objc_init 函数初始化 Objective-C 运行时环境
  • 随后,Runtime 会调用 load_images 函数加载镜像文件(包含类、Category 等信息)
  • 在加载过程中,Runtime 会遍历所有的 Category,并将其方法、属性、协议等信息合并到对应的类中

多个 Category 的加载顺序

  • 如果有多个 Category 对同一个类进行扩展,它们的加载顺序取决于编译顺序
  • 最后编译Category 会优先被加载,因此它的方法会插入到方法列表的前面,从而覆盖之前加载的 Category 的方法

方法合并

  • 对于实例方法和类方法,Runtime 会将 Category 中的方法列表添加到类的方法列表
  • 如果 Category 中的方法与原类的方法同名,Category 的方法会覆盖原类的方法(实际上是将Category 的方法插入到方法列表的前面,因此调用时会优先找到 Category 的方法)
  • 对于协议和属性,Runtime 会将其合并到类的协议列表和属性列表中

方法覆盖的本质:方法列表的插入顺序

  • Category 中的方法与原类的方法同名时,Runtime 会将 Category 的方法插入到方法列表的前面
  • 在方法查找时,Runtime 会从方法列表的头部开始查找,因此会优先找到 Category 的方法,从而实现“覆盖”

应用场景

扩展现有类的功能

  1. 系统类扩展: 对于系统提供的类,如 NSStringUIImageUIViewController等,我们可以使用分类为它们添加新的方法
  • 对于 NSString 类,添加一个计算字符串字数(不包含空格)的方法:
@interface NSString (WordCount)
- (NSUInteger)wordCount;
@end

@implementation NSString (WordCount)
- (NSUInteger)wordCount {
    NSString *trimmedString = [self stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
    NSArray *words = [trimmedString componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
    NSUInteger count = 0;
    for (NSString *word in words) {
        if (word.length > 0) {
            count++;
        }
    }
    return count;
}
@end
  • 对于 UIImage 类,我们可以添加一个生成指定颜色纯色图片的方法:
@interface UIImage (ColorImage)
+ (UIImage *)imageWithColor:(UIColor *)color size:(CGSize)size;
@end

@implementation UIImage (ColorImage)
+ (UIImage *)imageWithColor:(UIColor *)color size:(CGSize)size {
    CGRect rect = CGRectMake(0, 0, size.width, size.height);
    UIGraphicsBeginImageContext(rect.size);
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetFillColorWithColor(context, [color CGColor]);
    CGContextFillRect(context, rect);
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}
@end
  1. 自定义类扩展: 当项目中的自定义类需要新增功能时,使用分类可以避免在原类中添加大量代码,保持原类的简洁性。比如,有一个 Person 类,我们可以为其添加一个 Person + Social 分类,用于处理与社交相关的功能,如分享信息等。

代码组织与模块化

在大型项目中,一个类可能会有很多方法,这些方法可能涉及不同的功能模块。使用分类可以将这些方法按照功能进行分组,使代码结构更加清晰,便于维护和管理。

例如,对于一个 AppDelegate 子类,我们可以创建以下几个分类

AppDelegate+Root: 用于处理 App 根视图控制器的设置和管理。比如在应用启动时设置初始的根视图控制器,或者在某些特定条件下切换根视图控制器

AppDelegate+IMSDK: IMSDK 一般指即时通讯软件开发工具包,这个分类用于集成和管理即时通讯功能。包括初始化 IMSDK、处理用户登录登出、消息接收和发送等操作

AppDelegate+Network: 此分类主要处理网络相关的配置和管理,比如初始化网络请求库、设置网络监控等。例如使用 AFNetworking 进行网络请求的初始化

AppDelegate+OtherConfig: 这个分类用于存放一些其他杂项的配置,可能是一些不适合归类到前面几个分类中的设置,例如应用的推送配置、第三方统计 SDK 的初始化等。

减少继承的使用

  • 继承是实现代码复用和扩展的一种方式,但过多的继承会导致类的层次结构变得复杂,增加代码的耦合度。在某些情况下,使用分类可以替代继承来实现功能扩展,减少类的继承层次。
  • 例如,有多个不同类型的视图控制器都需要实现一个统一的分享功能。我们可以创建一个UIViewController+Sharing 分类,在其中实现分享方法,这样这些视图控制器就可以直接使用该分类中的分享功能,而无需通过继承一个包含分享功能的基类。

为第三方库的类添加功能

  • 当使用第三方库时,我们可能需要为库中的类添加一些自定义的方法。由于无法直接修改第三方库的源代码,使用分类是一个很好的解决方案。
  • 例如,在使用 AFNetworking 进行网络请求时,我们可以为 AFHTTPSessionManager 类添加一个自定义的请求方法,以满足特定的业务需求:
@interface AFHTTPSessionManager (CustomRequest)
- (void)customGETRequestWithURL:(NSString *)URL parameters:(NSDictionary *)parameters success:(void (^)(NSURLSessionDataTask *task, id responseObject))success failure:(void (^)(NSURLSessionDataTask *task, NSError *error))failure;
@end

@implementation AFHTTPSessionManager (CustomRequest)
- (void)customGETRequestWithURL:(NSString *)URL parameters:(NSDictionary *)parameters success:(void (^)(NSURLSessionDataTask *task, id responseObject))success failure:(void (^)(NSURLSessionDataTask *task, NSError *error))failure {
    // 可以在这里添加一些自定义的请求头、参数处理等逻辑
    [self GET:URL parameters:parameters progress:nil success:success failure:failure];
}
@end

协议方法的实现

  • 当一个类需要实现某个协议的多个方法,且这些方法的功能关联性不强时,可以使用分类将不同的协议方法分组实现,提高代码的可读性。
  • 例如,一个 ViewController 类需要实现 UITableViewDataSourceUITableViewDelegate 协议,我们可以分别创建 ViewController+TableViewDataSourceViewController+TableViewDelegate 两个分类来实现协议方法:
@interface ViewController (TableViewDataSource) <UITableViewDataSource>
@end

@implementation ViewController (TableViewDataSource)
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 10;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];
    cell.textLabel.text = [NSString stringWithFormat:@"Row %ld", (long)indexPath.row];
    return cell;
}
@end

Nodejs是如何导入模块和文件的?

作者 Riesenzahn
2025年2月12日 06:34

Node.js 提供了多种方式来导入模块和文件,以下是关于如何导入模块的详细解释和示例。

1. 使用 require()

Node.js 使用 CommonJS 模块规范,最常见的方式是使用 require() 函数导入模块。require() 函数接受模块的路径或模块的名称,并返回该模块的导出内容。

示例:

// 导入内置模块
const fs = require('fs');

// 导入自定义模块
const myModule = require('./myModule');

// 导入 npm 包
const express = require('express');

2. 模块的导出

要使模块能够被其他文件导入,需要使用 module.exportsexports 导出模块的内容。

示例:

// myModule.js
const greeting = 'Hello, World!';

function greet() {
  console.log(greeting);
}

// 导出函数和变量
module.exports = {
  greet,
  greeting,
};

3. 导入 JSON 文件

Node.js 也可以直接导入 JSON 文件。使用 require() 导入 JSON 文件时,Node.js 会自动将其解析为一个 JavaScript 对象。

示例:

const config = require('./config.json');
console.log(config);

4. ES6 模块导入(experimental)

从 Node.js 12 开始,Node.js 开始支持 ES6 模块(.mjs 文件或在 package.json 中设置 "type": "module")。使用 import 语句导入模块。

示例:

// 导入 ES6 模块
import { greet, greeting } from './myModule.mjs';

greet();

5. 动态导入

使用 import() 函数可以在运行时动态导入模块。这是一个返回 Promise 的异步操作。

示例:

(async () => {
  const module = await import('./myModule.js');
  module.greet();
})();

6. 导入整个模块

如果需要导入一个模块的所有导出,可以使用 * as 语法。

示例:

import * as myModule from './myModule.js';

myModule.greet();

7. 处理路径

在使用 require() 时,路径可以是相对路径或绝对路径。相对路径通常以 ./../ 开头。

示例:

const myModule = require('./lib/myModule');  // 相对路径
const path = require('path');                 // 内置模块

8. Node.js 的模块查找机制

Node.js 会按照以下顺序查找模块:

  1. 从缓存中查找已加载的模块。
  2. 如果未找到,检查内置模块。
  3. 如果还是未找到,查找相对路径模块。
  4. 如果还是未找到,查找全局模块。

9. 结论

Node.js 提供了灵活的模块导入机制,支持 CommonJS 和 ES6 模块规范。通过 require()import,开发者可以方便地构建模块化的应用程序。在实际开发中,选择合适的模块导入方式可以提高代码的可读性和可维护性。

DOM0、DOM2、DOM3事件处理方式的区别是什么?

2025年2月12日 06:33

DOM 事件处理方式的区别

一、DOM0 事件处理

DOM0 事件处理是最早的事件处理方式,它是通过直接将事件处理程序赋值给 DOM 元素的属性来实现的。其主要特点如下:

  1. 简单直接:通过直接设置元素的事件处理程序,例如 onclickonmouseover 等。
  2. 不支持多个处理程序:一个事件只能绑定一个处理程序,后绑定的会覆盖先绑定的。
  3. 无法解除绑定:只能通过将事件处理程序设置为 null 或者 undefined 来移除事件处理。

示例代码:

const button = document.getElementById('myButton');
button.onclick = function() {
    alert('Button clicked!');
};

缺点:由于其绑定方式的局限性,导致在复杂应用中难以管理事件处理程序。

二、DOM2 事件处理

DOM2 事件处理是对 DOM0 的改进,提供了更为灵活和强大的事件处理机制。主要特点包括:

  1. 支持多个处理程序:可以为同一事件添加多个处理程序,所有处理程序会依次执行。
  2. 事件捕获和冒泡:引入了事件捕获和冒泡机制,允许开发者控制事件的流转。
  3. 可以解除绑定:通过 removeEventListener 方法可以轻松移除事件处理。

示例代码:

const button = document.getElementById('myButton');
function handleClick() {
    alert('Button clicked!');
}
button.addEventListener('click', handleClick);

// 移除事件处理
button.removeEventListener('click', handleClick);

优点:灵活性高,适合复杂的应用场景。

三、DOM3 事件处理

DOM3 事件处理是在 DOM2 的基础上进一步扩展,增加了对事件对象的支持,主要特点如下:

  1. 事件对象:每个事件处理程序都会接收到一个事件对象,包含事件的详细信息,比如事件类型、目标元素、鼠标位置等。
  2. 标准化:DOM3 事件处理遵循标准,跨浏览器兼容性较好。
  3. 支持自定义事件:可以创建和触发自定义事件,增强了事件处理的灵活性。

示例代码:

const button = document.getElementById('myButton');
button.addEventListener('click', function(event) {
    console.log(`Button clicked at position: (${event.clientX}, ${event.clientY})`);
});

// 创建和触发自定义事件
const customEvent = new Event('myCustomEvent');
button.dispatchEvent(customEvent);

优点:提供了更丰富的事件处理功能,适合现代复杂 Web 应用。

总结

  • DOM0 事件处理:简单直接,但局限性大,不支持多个处理程序和事件解除。
  • DOM2 事件处理:引入了事件捕获、冒泡机制,支持多个处理程序和解除绑定,灵活性高。
  • DOM3 事件处理:在 DOM2 基础上增加了事件对象,标准化支持自定义事件,适合现代应用开发。

通过对比,可以看到 DOM0、DOM2 和 DOM3 事件处理方式的演变,体现了事件处理机制的逐步完善,开发者应根据具体需求选择合适的事件处理方式。

swift拾遗(字典)

2025年2月11日 19:15

字典

字典是一种无序的集合,用于存储同一类型的键和同一类型的值之间的关联关系。每个值都与一个唯一的键相关联,这个键在字典中充当该值的标识符。与数组中的元素不同,字典中的元素没有特定的顺序。当你需要根据标识符查找值时,就可以使用字典,这与在现实世界中使用字典查找特定单词的定义非常相似。

注意

Swift 的 Dictionary 类型与 Foundation 框架中的 NSDictionary 类是桥接的。

有关将 Dictionary 与 Foundation 和 Cocoa 结合使用的更多信息,请参阅“DictionaryNSDictionary 之间的桥接”。

字典类型的简写语法

Swift 字典的完整类型写法是 Dictionary<Key, Value>,其中 Key 是可以用作字典键的值的类型,Value 是字典为这些键所存储的值的类型。

注意

字典的 Key 类型必须遵循 Hashable 协议,就像集合的值类型一样。

你也可以使用简写形式 [Key: Value] 来表示字典的类型。虽然这两种形式在功能上是相同的,但简写形式更受青睐,并且在本指南中提及字典类型时都会使用这种简写形式。

创建空字典

和数组一样,你可以使用初始化语法来创建特定类型的空 Dictionary

image.png 这个示例创建了一个类型为 [Int: String] 的空字典,用于存储整数值的易读名称。该字典的键是 Int 类型,值是 String 类型。

如果上下文已经提供了类型信息,你可以使用空字典字面量来创建一个空字典,空字典字面量写为 [:](方括号内加一个冒号):

image.png

使用字典字面量创建字典

你还可以使用字典字面量来初始化字典,其语法与前面介绍的数组字面量类似。字典字面量是一种将一个或多个键值对写成字典集合的简洁方式。

键值对是键和值的组合。在字典字面量中,每个键值对里的键和值用冒号分隔。这些键值对以列表形式书写,用逗号分隔,并置于一对方括号内:

image.png

airports 字典使用一个包含两个键值对的字典字面量进行初始化。第一对的键是 "YYZ",值是 "Toronto Pearson";第二对的键是 "DUB",值是 "Dublin"

这个字典字面量包含两个 String: String 键值对。这种键值类型与 airports 变量声明的类型相匹配(即一个只包含 String 类型键和 String 类型值的字典),因此可以使用该字典字面量来初始化 airports 字典,并赋予其两个初始条目。

和数组一样,如果你使用一个键和值类型一致的字典字面量来初始化字典,就不必写出字典的类型。airports 的初始化可以采用更简洁的形式来编写:

image.png 由于字典字面量中所有的键彼此类型相同,并且所有的值也彼此类型相同,因此 Swift 可以推断出 [String: String]airports 字典应使用的正确类型。

访问和修改字典

你可以通过字典的方法和属性,或者使用下标语法来访问和修改字典。

和数组一样,你可以通过查看字典的只读 count 属性来了解字典中元素的数量:

image.png

image.png

image.png 你还可以使用下标语法来更改与特定键关联的值:

image.png

除了使用下标语法,你还可以使用字典的 updateValue(_:forKey:) 方法来设置或更新特定键对应的值。和上述使用下标语法的示例一样,updateValue(_:forKey:) 方法在键不存在时会为该键设置一个值,若键已存在则会更新其对应的值。不过,与下标语法不同的是,updateValue(_:forKey:) 方法在更新操作完成后会返回旧值,这让你能够检查是否进行了更新操作。

updateValue(_:forKey:) 方法返回一个字典值类型的可选值。例如,对于一个存储 String 类型值的字典,该方法会返回一个 String? 类型(即“可选 String”)的值。这个可选值在更新前若该键有旧值则包含该旧值,若该键之前不存在对应的值则为 nil

image.png

你还可以使用下标语法从字典中获取特定键对应的值。由于请求的键可能不存在对应的值,所以字典的下标会返回一个字典值类型的可选值。如果字典中包含所请求键对应的值,下标会返回一个包含该键现有值的可选值;否则,下标将返回 nil

image.png

你可以使用下标语法,通过为某个键赋值为 nil 来从字典中移除对应的键值对:

image.png

或者,你也可以使用 removeValue(forKey:) 方法从字典中移除键值对。如果该键值对存在,此方法会将其移除并返回被移除的值;若该键对应的值不存在,则返回 nil

image.png

image.png 有关for - in循环的更多信息,请参阅“for - in循环”。

你还可以通过访问字典的keysvalues属性,获取字典键或值的可迭代集合:

image.png

如果需要在某个接受Array实例的API中使用字典的键或值,可以利用keysvalues属性初始化一个新数组:

image.png

Swift的字典类型没有定义顺序。要按特定顺序遍历字典的键或值,可对其keysvalues属性使用sorted()方法。

swift拾遗(set)

2025年2月11日 17:15

集合类型的哈希值

要将某个类型的元素存储在集合中,该类型必须是可哈希的,也就是说,该类型必须提供一种为自身计算哈希值的方法。哈希值是一个 Int 类型的值,对于所有相等的对象,其哈希值相同,即如果 a == b,那么 a 的哈希值等于 b 的哈希值。

Swift 的所有基本类型(如 StringIntDoubleBool)默认都是可哈希的,因此可以用作集合的值类型或字典的键类型。没有关联值的枚举成员(如“枚举”部分所述)默认也是可哈希的。

注意

你可以让自定义类型遵循 Swift 标准库中的 Hashable 协议,从而将其用作集合的值类型或字典的键类型。有关实现所需的 hash(into:) 方法的信息,请参阅“Hashable”。有关遵循协议的信息,请参阅“协议”。

集合类型语法

Swift 集合的类型写作 Set<Element>,其中 Element 是该集合允许存储的元素类型。与数组不同,集合没有等效的简写形式。

创建并初始化空集合

你可以使用初始化器语法来创建特定类型的空集合:

var letters = Set<Character>()
print("letters is of type Set<Character> with \(letters.count) items.")
// Prints "letters is of type Set<Character> with 0 items."

或者,如果上下文已经提供了类型信息,比如在函数参数中,或者有已经指定类型的变量或常量,你可以使用空数组字面量来创建一个空集合:

letters.insert("a")
// letters now contains 1 value of type Character
letters = []
// letters is now an empty set, but is still of type Set<Character>

使用数组字面量创建集合

你还可以使用数组字面量来初始化一个集合,这是一种将一个或多个值写成集合形式的便捷方式。

下面的示例创建了一个名为 favoriteGenres 的集合,用于存储 String 类型的值:

var favoriteGenres: Set<String> = ["Rock", "Classical", "Hip hop"]
// favoriteGenres has been initialized with three initial items

favoriteGenres 变量被声明为“一个存储 String 类型值的集合”,写作 Set<String>。由于这个特定的集合指定了值的类型为 String,所以它只允许存储 String 类型的值。在这里,favoriteGenres 集合通过一个数组字面量进行初始化,该数组字面量包含三个 String 类型的值("Rock""Classical""Hip hop")。

注意

favoriteGenres 集合被声明为变量(使用 var 关键字)而非常量(使用 let 关键字),因为在下面的示例中会对其中的元素进行添加和移除操作。

仅通过数组字面量无法推断出集合类型,因此必须显式声明 Set 类型。不过,由于 Swift 具备类型推断功能,如果你使用只包含单一类型值的数组字面量来初始化集合,就不必写出集合元素的类型。favoriteGenres 的初始化可以采用更简洁的形式来编写:

var favoriteGenres: Set = ["Rock", "Classical", "Hip hop"]

由于数组字面量中的所有值都是同一类型,Swift 可以推断出 Set<String>favoriteGenres 变量应使用的正确类型。

访问和修改集合

你可以通过集合的方法和属性来访问和修改它。

要了解集合中元素的数量,可以查看其只读的 count 属性:

print("I have \(favoriteGenres.count) favorite music genres.")
// Prints "I have 3 favorite music genres."

可以使用布尔类型的 isEmpty 属性作为快捷方式,来检查 count 属性的值是否等于 0:

if favoriteGenres.isEmpty {
    print("As far as music goes, I'm not picky.")
} else {
    print("I have particular music preferences.")
}
// Prints "I have particular music preferences."

插入

favoriteGenres.insert("Jazz")
// favoriteGenres now contains 4 items

你可以通过调用集合的 remove(_:) 方法从集合中移除一个元素。如果该元素是集合的成员,此方法会将其移除并返回被移除的值;如果集合中不包含该元素,则返回 nil。另外,也可以使用集合的 removeAll() 方法移除集合中的所有元素。

if let removedGenre = favoriteGenres.remove("Rock") {
    print("\(removedGenre)? I'm over it.")
} else {
    print("I never much cared for that.")
}
// Prints "Rock? I'm over it."

要检查一个集合是否包含某个特定元素,可以使用 contains(_:) 方法。

if favoriteGenres.contains("Funk") {
    print("I get up on the good foot.")
} else {
    print("It's too funky in here.")
}
// Prints "It's too funky in here."

遍历集合

你可以使用 for-in 循环来遍历集合中的值。

image.png

有关 for-in 循环的更多信息,请参阅“for-in 循环”。

Swift 的 Set 类型没有定义特定的顺序。若要按特定顺序遍历集合中的值,可使用 sorted() 方法,该方法会以数组形式返回集合中的元素,数组中的元素按小于运算符(<)排序。

image.png

执行集合操作

你可以高效地执行基本的集合操作,例如将两个集合合并在一起、确定两个集合有哪些共同的值,或者判断两个集合包含的元素是完全相同、部分相同还是完全不同。

基本集合操作

下图展示了两个集合 —— ab,不同集合操作的结果用阴影区域表示。

image.png

执行集合操作

  • 使用 intersection(_:) 方法创建一个新集合,新集合仅包含两个原集合中共有的元素。
  • 使用 symmetricDifference(_:) 方法创建一个新集合,新集合包含仅存在于其中一个原集合中的元素(即不同时存在于两个集合中的元素)。
  • 使用 union(_:) 方法创建一个新集合,新集合包含两个原集合中的所有元素。
  • 使用 subtracting(_:) 方法创建一个新集合,新集合包含不在指定集合中的元素。

image.png

集合成员关系和相等性

下图展示了三个集合 —— abc,其中重叠区域表示集合间共享的元素。集合 a 是集合 b 的超集,因为 a 包含了 b 中的所有元素。相反,集合 b 是集合 a 的子集,因为 b 中的所有元素也都包含在 a 中。集合 b 和集合 c 彼此不相交,因为它们没有共同的元素。

image.png

集合成员关系和相等性判断方法

  • 使用“相等”运算符(==)来判断两个集合是否包含完全相同的元素。
  • 使用 isSubset(of:) 方法来判断一个集合的所有元素是否都包含在指定集合中。---儿子是否包含在父亲中
  • 使用 isSuperset(of:) 方法来判断一个集合是否包含指定集合中的所有元素。 ---父亲是否包含儿子
  • 使用 isStrictSubset(of:)isStrictSuperset(of:) 方法来判断一个集合是否是指定集合的真子集或真超集(即子集或超集,但不相等)。
  • 使用 isDisjoint(with:) 方法来判断两个集合是否没有共同元素。

image.png

反馈与交流 | 肘子的 Swift 周报 #070

作者 东坡肘子
2025年2月11日 08:04

issue70.jpg

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

反馈与交流

近日,我收到苹果发来的一封邮件,告知我去年四月反馈的一个问题——@State 在多窗口应用中初始值的异常表现——现已修复。令人欣喜的是,此次修复不仅适用于最新系统,还向下兼容至 iOS 17.5、macOS 14.5、tvOS 17.5、watchOS 10.5 以及 visionOS 1.3。与此同时,WWDC 2024 部分新功能对旧版系统的适配,也似乎预示着 SwiftUI 团队正在打破“新功能与问题修复仅限最新系统”的传统做法。

虽然从去年四月提交反馈到今年年初修复,这个周期不短,但当我收到修复通知时,依然难掩内心的喜悦。这份愉悦不仅源自问题的彻底解决,更来自于作为开发者,为生态建设贡献力量而获得的认可感。

更为巧合的是,几天前我又收到了一位 SwiftUI 开发组工程师的私信。他不仅确认了我提交的 另一个关于 onChange 响应异常的问题,还直言不讳地分享了 SwiftUI API 在适配苹果众多系统平台时所面临的一致性挑战。相比于反馈助理那礼貌却略显公式化的回复,这种直接、双向的交流更能引起普通开发者的共鸣。

在开发者关系的维护上,尽管苹果仍有很大的提升空间,但某些迹象表明他们正在积极改变。以我经常浏览的 苹果官方开发者论坛 中的 SwiftUI、Concurrency 与 DB 三个版块为例,DTS 工程师在回复的频率、及时性和深度上均有显著改善。我建议开发者在论坛讨论后,将反馈助理中的反馈编号一并分享,这无疑有助于加快问题的解决进程。

Quinn 创建了一个 帖子,指导开发者如何通过反馈助理清晰、高效地反馈问题。

无论社区讨论多么热烈,开发者始终期盼能从苹果工程师处获得更准确、更深入的技术资讯。期待苹果能继续举办类似 WWDC Digital Lounge 或 Ask Apple 这样的多对多互动活动,将与开发者的关系从单向反馈提升至真正的双向交流。只有这样,才能营造出一个更健康、更活跃、更具吸引力的生态社区环境。

前一期内容全部周报列表

近期推荐

Apple Vision Pro 一年使用体会 (Apple Vision Pro: 1 Year Later)

转眼间,Apple Vision Pro 已经发布一周年了。Basic Apple Guy 在本文中回顾了过去一年的使用体验,并探讨了 AVP 在硬件、软件和用户接受度方面的表现。作者认为,AVP 提供了一种 沉浸式、平静且不慌不忙的空间计算体验,这既是它的优势,也让其在快节奏社会中难以成为主流。尽管 AVP 具备出色的 UI 设计、硬件工艺和媒体观看体验,但缺乏杀手级原生应用、佩戴舒适度欠佳、电池续航短等问题,使其难以融入日常生活。尽管如此,作者仍希望通过调整使用习惯,让 AVP 发挥更大价值,并对其未来发展抱有期待。在文章的最后,他还分享了一些提升佩戴舒适度和使用体验的配件推荐。

SwiftUI ControlGroup 详解 (Grouping Controls with ControlGroup)

在 SwiftUI 中,组件的呈现方式会随上下文不同而变化,而 ControlGroup 通过 将语义相关的控件组合在一起,大幅提升界面清晰度、可发现性和一致性,从而优化应用的用户体验。Antonella Giugliano 在本文中详细介绍了 ControlGroup 的多种用法,并演示了如何通过实现 ControlGroupStyle 协议来自定义外观,例如修改背景颜色、添加边框等,使界面更加美观且交互更统一。此外,ControlGroup 在无障碍体验方面也发挥着重要作用,通过添加文本标签,使依赖辅助技术的用户更容易理解控件的功能。

🪜 iOS 的声音引擎 (Sound Engineering on iOS: AudioToolbox, AVAudioSession, and UIFeedbackGenerator)

声音、音乐和触觉反馈一直是提升 iOS 用户体验的重要组成部分。在本文中,Uwais Alqadri 详细介绍了iOS 音频与触觉反馈技术,并通过代码示例讲解如何使用 AudioToolbox、AVAudioSession 和 UIFeedbackGenerator 处理系统音效、音频播放与录制、触觉反馈等功能。文章涵盖系统声音 ID、Taptic Engine 触觉反馈、音频会话管理等关键内容,帮助开发者更好地优化 iOS 应用的音频交互体验。

用 Swift Charts 绘制地图 (Drawing Maps with Swift Charts)

在本文中,Artem Novichkov 结合 MapKit 和 Swift Charts,实现了基于哈萨克斯坦各地区人口数据的地图可视化。作者使用 MKGeoJSONDecoder 解析 GeoJSON 数据,并通过扩展 MKPolygon 提取坐标和计算区域中心点。随后,利用 Swift Charts 的 AreaPlotLinePlotPointMark 绘制人口分布地图,直观呈现不同地区的人口差异。Novichkov 通过灵活组合 Apple 官方框架,大幅提升数据处理与可视化效率,展示了 Swift Charts 在地理数据可视化方面的潜力

掌握 TaskGroups (Mastering TaskGroups in Swift)

任务组(TaskGroups)是一种管理动态子任务、批量执行异步任务并灵活控制并发的方式,使 Swift 并发编程更高效。Majid Jabrayilov 在本文中不仅介绍了 TaskGroups 的基本用法,还重点探讨了不合理使用 TaskGroups 可能导致的高内存消耗。为了优化任务管理,他提出了一种 分批添加任务 的策略:限制并发任务数量,在已有任务完成后再动态添加新任务,从而减少内存占用并提升执行效率。

参数化测试 (Parameterized Tests in Swift: Reducing Boilerplate Code)

Swift Testing 的参数化测试(Parameterized Tests)允许开发者 使用一组输入参数重复运行同一个测试函数,避免为每个输入单独编写测试方法,从而大幅减少冗余代码。同时,Xcode 的 Test Navigator 还能直观地展示每个参数的测试结果,方便调试和快速定位问题。Antoine van der Lee 在本文中不仅介绍了参数化测试的基本用法,还探讨了如何处理多参数组合,例如:为不同 Feature 设定不同的免费试用次数,并使用 zip 方法将 Feature 与对应的 tries 数量匹配,以确保测试逻辑更清晰、代码更精炼。

Let's Vision 2025 抽奖 🎁

LET’S VISION 是中国最大、最具国际化的 Apple 生态盛会,被誉为 Apple 生态中的“超级盛典”。本届大会将在 2025 年 3 月 1 日 —— 3 月 2 日上海鲜花港 举办,主题为:人工智能 + 空间计算 = 无限♾️ 。活动将带领你走在 Apple 生态的最前沿,展示最创新、最尖端的产品与技术。无论你是开发者、创业者,还是行业精英,这将是你不可错过的年度盛会!

请访问 Let's Vision 大会官网 了解更多活动详情和嘉宾名单。学生可以享受半价购票优惠。

我作为嘉宾将分享与 SwiftUI 布局原理有关的内容。

2 月 18 日,我会从下方参与者中抽取一人送出一张大会门票(Basic Pass ¥699 )。参与方法为:

期待与你在上海相见。

我还会在 X 上抽取一张,可在 此处 参与。

往期内容

THANK YOU

如果你觉得这份周报或者我的文章对你有所帮助,欢迎 点赞 并将其 转发 给更多的朋友。

weekly.fatbobman.com 订阅本周报的电子邮件版本。访问我的博客 肘子的 Swift 记事本 查看更多的文章。加入 Discord 社区,与 2000+ 中文开发者深入交流 Swift、SwiftUI 开发体验。

swift 拾遗(三)

2025年2月10日 17:40

image.png

代码功能概述 代码通过扩展为 Player 类型添加了一个 updateScore 方法,该方法用于更新玩家的得分记录,同时会检查新得分是否超过玩家的历史最高得分,如果超过则更新最高得分并输出提示信息。最后调用这个方法,将玩家的得分更新为 50。 代码详细解释

  1. 扩展 Player 类型
    swift
extension Player {
    mutating func updateScore(_ newScore: Int) {
        history.append(newScore)
        if highScore < newScore {
            print("\(newScore)! A new high score for \(name)! 🎉")
            highScore = newScore
        }
    }
}

extension Player:在 Swift 中,extension 关键字用于为已有的类型(类、结构体、枚举等)添加新的功能,而无需修改原始类型的定义。这里为 Player 类型添加了一个新的方法 updateScore。 mutating func updateScore(_ newScore: Int): mutating 关键字用于标记这个方法会修改 Player 实例的属性。因为 Player 可能是一个结构体(结构体是值类型,默认情况下方法不能修改其属性,除非使用 mutating 关键字)。 updateScore 方法接受一个 Int 类型的参数 newScore,表示新的得分。

image.png

代码整体功能概述 这段代码主要完成了两个核心操作。首先,通过扩展让 Player 类型遵循 Codable 和 Equatable 协议;然后,使用 JSONEncoder 对 player 实例进行 JSON 编码,最后打印出 player 实例。 代码详细解释

  1. 扩展 Player 类型以遵循 Codable 和 Equatable 协议 swiftextension Player: Codable, Equatable {} extension Player:在 Swift 里,extension 关键字能够为已有的类型(像类、结构体、枚举等)增添新功能,或者让该类型遵循特定协议,同时无需对原始类型的定义进行修改。 Codable 协议:Codable 是一个组合协议,它包含了 Encodable 和 Decodable 协议。当一个类型遵循 Codable 协议时,意味着该类型的实例能够被编码(例如转化为 JSON 格式)和解码(从 JSON 格式转换回该类型的实例)。若 Player 类型的所有属性都遵循 Codable 协议,编译器会自动合成 Codable 所需的实现。 Equatable 协议:遵循 Equatable 协议的类型能够使用 == 和 != 运算符来比较两个实例是否相等。同样,如果 Player 类型的所有属性都遵循 Equatable 协议,编译器会自动合成 Equatable 所需的实现。
  2. 导入 Foundation 框架 swiftimport Foundation Foundation 框架是 Apple 提供的基础框架,其中包含了很多基础类和功能,像数据处理、文件操作、网络请求等。JSONEncoder 类就来自 Foundation 框架。
  3. 创建 JSONEncoder 实例并对 player 进行编码 swiftlet encoder = JSONEncoder() try encoder.encode(player) JSONEncoder:这是 Foundation 框架中的一个类,用于将遵循 Encodable 协议的类型实例编码为 JSON 数据。 try encoder.encode(player):调用 JSONEncoder 的 encode 方法对 player 实例进行编码。encode 方法会抛出错误,所以使用 try 关键字来处理可能出现的错误。如果编码成功,会返回一个 Data 类型的对象,代表编码后的 JSON 数据;若编码失败,则会抛出相应的错误。

Contacts 联系人

作者 ZRD1112
2025年2月10日 15:52

developer.apple.com/documentati…

developer.apple.com/documentati…

截屏2025-02-11 13.52.37.png


1. 获取权限

  • 需要在 Info.plist 中添加 NSContactsUsageDescription 键,并提供一个描述,说明为什么需要访问联系人。
  • 由于涉及到用户隐私,确保在请求权限时提供清晰的解释。

Q: 获取联系人有几种方法,有什么区别

A:

在 iOS 中,获取系统联系人的方法主要依赖于 Contacts 框架(CNContactStore)。根据不同的需求和场景,获取联系人的方法可以分为以下几种,每种方法有其特点和适用场景:


1. 使用 CNContactFetchRequest 枚举联系人

  • 特点
    • 适用于批量获取联系人。
    • 可以指定需要获取的联系人字段(如名字、电话、邮箱等)。
    • 通过回调逐条返回联系人,适合处理大量联系人。
  • 代码示例
    let store = CNContactStore()
    let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactPhoneNumbersKey] as [CNKeyDescriptor]
    let request = CNContactFetchRequest(keysToFetch: keysToFetch)
    
    do {
        try store.enumerateContacts(with: request) { contact, stop in
            print("Name: \(contact.givenName) \(contact.familyName)")
            for phoneNumber in contact.phoneNumbers {
                print("Phone: \(phoneNumber.value.stringValue)")
            }
        }
    } catch {
        print("Failed to fetch contacts: \(error)")
    }
    
  • 适用场景
    • 需要获取所有联系人。
    • 需要逐条处理联系人数据。

2. 使用 CNContactStoreunifiedContacts(matching:keysToFetch:) 方法

  • 特点
    • 根据条件(如名字、电话等)查询联系人。
    • 返回一个包含匹配联系人的数组。
    • 需要指定查询条件(NSPredicate)和需要获取的字段。
  • 代码示例
    let store = CNContactStore()
    let predicate = CNContact.predicateForContacts(matchingName: "John")
    let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactPhoneNumbersKey] as [CNKeyDescriptor]
    
    do {
        let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
        for contact in contacts {
            print("Name: \(contact.givenName) \(contact.familyName)")
            for phoneNumber in contact.phoneNumbers {
                print("Phone: \(phoneNumber.value.stringValue)")
            }
        }
    } catch {
        print("Failed to fetch contacts: \(error)")
    }
    
  • 适用场景
    • 根据特定条件(如名字、电话等)查询联系人。
    • 需要快速获取符合条件的联系人。

3. 使用 CNContactStoreunifiedContact(withIdentifier:keysToFetch:) 方法

  • 特点
    • 根据联系人的唯一标识符(identifier)获取单个联系人。
    • 需要提前知道联系人的 identifier
  • 代码示例
    let store = CNContactStore()
    let contactIdentifier = "12345" // 联系人的唯一标识符
    let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactPhoneNumbersKey] as [CNKeyDescriptor]
    
    do {
        let contact = try store.unifiedContact(withIdentifier: contactIdentifier, keysToFetch: keysToFetch)
        print("Name: \(contact.givenName) \(contact.familyName)")
        for phoneNumber in contact.phoneNumbers {
            print("Phone: \(phoneNumber.value.stringValue)")
        }
    } catch {
        print("Failed to fetch contact: \(error)")
    }
    
  • 适用场景
    • 已知联系人唯一标识符,需要获取特定联系人的详细信息。

4. 使用 CNContactStorecontainers(matching:) 方法

  • 特点
    • 获取联系人容器(CNContainer),每个容器包含一组联系人。
    • 适用于需要区分不同来源的联系人(如 iCloud、本地、Exchange 等)。
  • 代码示例
    let store = CNContactStore()
    do {
        let containers = try store.containers(matching: nil)
        for container in containers {
            print("Container: \(container.name)")
        }
    } catch {
        print("Failed to fetch containers: \(error)")
    }
    
  • 适用场景
    • 需要区分不同来源的联系人。
    • 需要获取特定容器中的联系人。

5. 使用 CNContactStoregroups(matching:) 方法

  • 特点
    • 获取联系人分组(CNGroup),每个分组包含一组联系人。
    • 适用于需要按分组获取联系人。
  • 代码示例
    let store = CNContactStore()
    do {
        let groups = try store.groups(matching: nil)
        for group in groups {
            print("Group: \(group.name)")
        }
    } catch {
        print("Failed to fetch groups: \(error)")
    }
    
  • 适用场景
    • 需要按分组获取联系人。
    • 需要管理联系人分组。

6. 使用 CNContactStoredefaultContainerIdentifier() 方法

  • 特点
    • 获取默认联系人容器的标识符。
    • 适用于需要操作默认容器中的联系人。
  • 代码示例
    let store = CNContactStore()
    let defaultContainerIdentifier = store.defaultContainerIdentifier()
    print("Default Container Identifier: \(defaultContainerIdentifier)")
    
  • 适用场景
    • 需要操作默认容器中的联系人。

方法对比

方法 特点 适用场景
enumerateContacts(with:) 批量获取联系人,逐条回调 获取所有联系人,逐条处理
unifiedContacts(matching:keysToFetch:) 根据条件查询联系人,返回匹配的数组 根据条件查询联系人
unifiedContact(withIdentifier:keysToFetch:) 根据唯一标识符获取单个联系人 获取特定联系人的详细信息
containers(matching:) 获取联系人容器,区分不同来源的联系人 区分不同来源的联系人
groups(matching:) 获取联系人分组,按分组获取联系人 按分组获取联系人
defaultContainerIdentifier() 获取默认联系人容器的标识符 操作默认容器中的联系人

总结

  • 如果需要获取所有联系人,推荐使用 enumerateContacts(with:)
  • 如果需要根据条件查询联系人,推荐使用 unifiedContacts(matching:keysToFetch:)
  • 如果需要获取特定联系人,推荐使用 unifiedContact(withIdentifier:keysToFetch:)
  • 如果需要区分不同来源或分组的联系人,可以使用 containers(matching:)groups(matching:)

根据具体需求选择合适的方法,可以提高代码的效率和可读性。

Swift开发-截屏指定区域

作者 山水域
2025年2月10日 15:26

#方法 1: 使用 UIGraphicsImageRenderer

func captureScreenArea(rect: CGRect) -> UIImage? {
    // 获取主窗口
    guard let window = UIApplication.shared.windows.first else { return nil }
    
    // 创建渲染器(自动处理 Retina 缩放)
    let renderer = UIGraphicsImageRenderer(bounds: rect)
    
    return renderer.image { context in
        // 将指定区域绘制到上下文
        window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
    }
}

// 使用示例
let captureRect = CGRect(x: 100, y: 200, width: 300, height: 200)
if let screenshot = captureScreenArea(rect: captureRect) {
    UIImageWriteToSavedPhotosAlbum(screenshot, nil, nil, nil) // 保存到相册
}

#方法 2: 使用 drawHierarchy


func captureViewArea(view: UIView, rect: CGRect) -> UIImage? {
    // 创建临时绘图上下文
    UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
    
    defer { UIGraphicsEndImageContext() } // 确保上下文关闭
    
    // 转换坐标系(视图坐标系 → 图像坐标系)
    let drawingRect = CGRect(
        x: -rect.origin.x,
        y: -rect.origin.y,
        width: view.bounds.width,
        height: view.bounds.height
    )
    
    // 渲染指定区域
    let success = view.drawHierarchy(in: drawingRect, afterScreenUpdates: false)
    
    guard success, let image = UIGraphicsGetImageFromCurrentImageContext() else {
        return nil
    }
    
    // 裁剪到目标区域
    guard let cgImage = image.cgImage?.cropping(to: CGRect(
        origin: .zero,
        size: rect.size
    )) else { return nil }
    
    return UIImage(cgImage: cgImage)
}

// 使用示例(截取某个子视图的区域)
let targetView = UIView(frame: CGRect(x: 50, y: 100, width: 200, height: 150))
if let screenshot = captureViewArea(view: targetView, rect: CGRect(x: 20, y: 30, width: 100, height: 80)) {
    // 使用截图...
}

#方法 3: 使用 CALayer 渲染(底层方法)

func captureLayerArea(layer: CALayer, rect: CGRect) -> UIImage? {
    // 创建位图上下文
    UIGraphicsBeginImageContextWithOptions(rect.size, false, UIScreen.main.scale)
    
    defer { UIGraphicsEndImageContext() }
    
    guard let context = UIGraphicsGetCurrentContext() else { return nil }
    
    // 调整上下文坐标系
    context.translateBy(x: -rect.origin.x, y: -rect.origin.y)
    
    // 渲染图层内容
    layer.render(in: context)
    
    return UIGraphicsGetImageFromCurrentImageContext()
}

// 使用示例(截取特定图层的区域)
let targetLayer = CALayer()
targetLayer.frame = CGRect(x: 0, y: 0, width: 400, height: 300)
if let screenshot = captureLayerArea(layer: targetLayer, rect: CGRect(x: 50, y: 50, width: 200, height: 200)) {
    // 处理截图...
}

方法 优点 缺点 适用场景
UIGraphicsImageRenderer 自动处理缩放,代码简洁 支持 iOS 10+ iOS 10+
drawHierarchy 兼容旧系统,支持实时渲染 需要手动处理坐标系 需要兼容 iOS 7+ 的项目
CALayer 渲染 底层控制,性能最优 无法捕获 OpenGL/Vulkan 内容 游戏或高性能渲染

#扩展功能实现

1. 带安全区域的截图

func safeAreaScreenshot() -> UIImage? {
    guard let window = UIApplication.shared.windows.first else { return nil }
    
    let safeArea = window.safeAreaInsets
    let captureRect = CGRect(
        x: safeArea.left,
        y: safeArea.top,
        width: window.bounds.width - safeArea.left - safeArea.right,
        height: window.bounds.height - safeArea.top - safeArea.bottom
    )
    
    return captureScreenArea(rect: captureRect)
}

2. 延时截图(解决渲染未完成问题)

func delayedCapture(rect: CGRect, delay: TimeInterval, completion: @escaping (UIImage?) -> Void) {
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
        let screenshot = self.captureScreenArea(rect: rect)
        completion(screenshot)
    }
}

// 使用示例
delayedCapture(rect: CGRect(x: 0, y: 0, width: 200, height: 200), delay: 0.3) { image in
    guard let image = image else { return }
    // 处理截图...
}

3. 拼接多个区域


func combineScreenshots(rects: [CGRect]) -> UIImage? {
    guard !rects.isEmpty else { return nil }
    
    // 计算总区域
    let totalRect = rects.reduce(CGRect.null) { $0.union($1) }
    
    let renderer = UIGraphicsImageRenderer(bounds: totalRect)
    return renderer.image { context in
        for rect in rects {
            if let image = captureScreenArea(rect: rect) {
                image.draw(in: rect)
            }
        }
    }
}

Metal 进阶:计算通道

作者 一牛
2025年2月10日 14:47

引言

大家好,我是一牛,很高兴和大家又见面了。我们在上一篇文章中介绍了离屏渲染。通过创建两个渲染通道,一个负责离屏渲染,另一个负责将离屏渲染的结果渲染到窗口。今天我分享的是Metal 中的计算通道。在图形编程中,计算通道(Compute Pass) 指的是GPU 计算管线中的一次执行过程,它专门用于计算任务(如物理模拟、图像处理、AI 推理等),而不是传统的光栅化渲染。在本例中,我将使用计算通道实现图片处理 - 灰度化图片并渲染到窗口。

计算通道

计算函数

// Grayscale compute kernel
constant half3 kRec709Luma = half3(0.2126, 0.7152, 0.0722);
kernel void
grayscaleKernel(texture2d<half, access::read>  inTexture  [[texture(0)]],
                texture2d<half, access::write> outTexture [[texture(1)]],
                uint2                          gid        [[thread_position_in_grid]])
{   
    half4 inColor  = inTexture.read(gid);
    half  gray     = dot(inColor.rgb, kRec709Luma);
    outTexture.write(half4(gray, gray, gray, 1.0), gid);
}

和顶点着色器、片段着色器类似,计算函数是由关键字kernel 修饰的,所以它也被称为内核函数。这个函数是将输入的inTexture转换成灰度图片,将输出写入到outTexture。这里需要注意的是,inputTexture是只读,而outputTexture是可写的。thread_position_in_grid 在本例只指代的就是图片的像素坐标。

计算管线

在Metal 中创建计算管线非常容易,绑定计算函数即可。

guard let kernelFunction = library?.makeFunction(name: "grayscaleKernel") else {
    fatalError("fail to load kernel function - grayscaleKernel")
}
do {
    computePipelineState = try device.makeComputePipelineState(function: kernelFunction)
} catch {
    fatalError("fail to create compute pipelineState")
}

编码计算命令

let computeEncoder = commanderBuffer?.makeComputeCommandEncoder()
computeEncoder?.setComputePipelineState(computePipelineState)
computeEncoder?.setTexture(inputTexture, index: 0)
computeEncoder?.setTexture(outputTexture, index: 1)
let threadsPerGrid = MTLSize(width: inputTexture.width, height: inputTexture.height, depth: 1)
computeEncoder?.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
computeEncoder?.endEncoding()

var threadsPerThreadgroup: MTLSize {
    let w = computePipelineState?.threadExecutionWidth ?? 0
    let h = (computePipelineState?.maxTotalThreadsPerThreadgroup ?? 0) / w
    return MTLSize(width: w, height: h, depth: 1)
}

这里threadsPerGrid和图片的尺寸一致,threadsPerThreadgroup是一个线程组。如图,图片被分割成了多个线程组,而线程组大小并不总是一致。

media-2922064~dark@2x.png

这里还需要注意的是,由于outputTexture对应计算函数的outTexture, 它需要可写。在本例中,我们还需要将它渲染到窗口,所以它还需要可读,所以我们在创建纹理的时候需要申明成可读可写。

func createOutputTextureDescriptor(inputTexture: MTLTexture) -> MTLTextureDescriptor {
    let descriptor = MTLTextureDescriptor()
    descriptor.textureType = .type2D
    descriptor.width = inputTexture.width
    descriptor.height = inputTexture.height
    descriptor.pixelFormat = inputTexture.pixelFormat
    // shaderWrite -> compute pass, shaderRead -> render pass
    descriptor.usage = [.shaderWrite, .shaderRead]
    return descriptor
}

总结

通过 计算通道,我们可以在 Metal 中高效地处理各种计算任务,如图像滤波、深度学习推理、物理模拟等。理解和掌握计算通道的使用,不仅能够帮助我们优化图形渲染流程,还能拓展应用程序的计算能力,尤其在图像处理和 AI 加速方面具有广泛应用。

希望大家能通过本文了解 Metal 中的计算通道,并掌握其在图像处理中的应用。如果你有任何问题或想进一步讨论,欢迎留言或分享你的看法!

本例源码

Error Domain=NSPOSIXErrorDomain Code=65 "No route to host" 报错的一个可能问题

作者 婉卿容若
2025年2月10日 14:28

问题及处理

在使用 socket 的过程中,你有可能会遇到 Error Domain=NSPOSIXErrorDomain Code=65 "No route to host" 报错 在确定 ip 或者 url 没有问题的情况后,可以尝试创新创建 socket 来处理

我的开发 局域网通信功能 以及 使用 SimplePing 的过程中,都遇到过此问题。 以前开发局域网通信时一直没找到原因,最新 app 使用 SimplePing 的过程中,遇到一个 bug, 在手机从 5g 切换到 wifi 后,会出现一下函数在调用 sendto(::::) 函数时报错,之后所有的 pingdata 都会报错,发不出去,查看错误后就是 Code=65 "No route to host",检查了参数,只有可能时 self.socket实例在网络波动时系统做了处理,导致消息发送一直失败,所以在sendPingWithData失败后, 重新创建 self.socket 后,消息发送恢复正常。

在使用 CocoaAsyncSocket 也遇到过相同问题

sendPingWithData 函数:

- (**void**)sendPingWithData:(NSData *)data {

    **int**                     err;

    NSData *                payload;

    NSData *                packet;

    ssize_t                 bytesSent;

    **id**<SimplePingDelegate>  strongDelegate;

    

    // data may be nil

    NSParameterAssert(**self**.hostAddress != **nil**);     // gotta wait for -simplePing:didStartWithAddress:

    

    // Construct the ping packet.

    

    payload = data;

    **if** (payload == **nil**) {

        payload = [[NSString stringWithFormat:@"%28zd bottles of beer on the wall", (ssize_t) 99 - (size_t) (**self**.nextSequenceNumber % 100) ] dataUsingEncoding:NSASCIIStringEncoding];

        assert(payload != **nil**);

        

        // Our dummy payload is sized so that the resulting ICMP packet, including the ICMPHeader, is

        // 64-bytes, which makes it easier to recognise our packets on the wire.

        

        assert([payload length] == 56);

    }

    

    **switch** (**self**.hostAddressFamily) {

        **case** AF_INET: {

            packet = [**self** pingPacketWithType:ICMPv4TypeEchoRequest payload:payload requiresChecksum:**YES**];

        } **break**;

        **case** AF_INET6: {

            packet = [**self** pingPacketWithType:ICMPv6TypeEchoRequest payload:payload requiresChecksum:**NO**];

        } **break**;

        **default**: {

            assert(**NO**);

        } **break**;

    }

    assert(packet != **nil**);

    

    // Send the packet.

    

    **if** (**self**.hostAddress.length == 0) {

        bytesSent = -1;

        err = EBADF;

    }

    **else** **if** (**self**.socket == **NULL**) {

        bytesSent = -1;

        err = EBADF;

    } **else** {

        bytesSent = sendto(

                           CFSocketGetNative(**self**.socket),

                           packet.bytes,

                           packet.length,

                           0,

                           **self**.hostAddress.bytes,

                           (socklen_t) **self**.hostAddress.length

                           );

        err = 0;

        **if** (bytesSent < 0) {

            err = errno;

        }

    }

    

    // Handle the results of the send.

    

    strongDelegate = **self**.delegate;

    **if** ( (bytesSent > 0) && (((NSUInteger) bytesSent) == packet.length) ) {

        

        // Complete success.  Tell the client.

        

        **if** ( (strongDelegate != **nil**) && [strongDelegate respondsToSelector: **@selector**(simplePing:didSendPacket:sequenceNumber:)] ) {

            [strongDelegate simplePing:**self** didSendPacket:packet sequenceNumber:**self**.nextSequenceNumber];

        }

    } **else** {

        NSError *   error;

        

        // Some sort of failure.  Tell the client.

        

        **if** (err == 0) {

            err = ENOBUFS;          // This is not a hugely descriptor error, alas.

        }

        error = [NSError errorWithDomain:NSPOSIXErrorDomain code:err userInfo:**nil**];

        **if** ( (strongDelegate != **nil**) && [strongDelegate respondsToSelector: **@selector**(simplePing:didFailToSendPacket:sequenceNumber:error:)] ) {

            [strongDelegate simplePing:**self** didFailToSendPacket:packet sequenceNumber:**self**.nextSequenceNumber error:error];

        }

    }

    

    **self**.nextSequenceNumber += 1;

    **if** (**self**.nextSequenceNumber == 0) {

        **self**.nextSequenceNumberHasWrapped = **YES**;

    }

}

其他

关于切换网络,socket 很容易有一些问题出现,基本都是要重启 socket 在使用 SocketRocket时,我遇到过从 wifi 切换蜂窝时,报错 Domain=SRWebSocketErrorDomain Code=2133 "Invalid Sec-WebSocket-Accept response" UserInfo={NSLocalizedDescription=Invalid Sec-WebSocket-Accept response} 相关的描述可以查看此回复

一些想法

关于切换网络,系统可能是会对 socket 做一些处理的,我不清楚我的想法是否正确,但是以上我遇到了问题应该都是系统 CFSocketRef 对网络变化的高敏反应造成的

索引

SimplePing 的代码从此处可以看到 github.com/chrishulber…

❌
❌