阅读视图

发现新文章,点击刷新页面。

僵尸对象与野指针的区别和联系

僵尸对象(Zombie Object)与野指针(Dangling Pointer)的区别和联系


定义

  • 僵尸对象(Zombie Object) 指的是对象已经被释放,但程序仍尝试访问该对象的内存区域。在 Objective-C 中,开启 Zombie 模式后,释放的对象会变成“僵尸”,用于捕获对已释放对象的访问,便于调试。
  • 野指针(Dangling Pointer) 指指针变量指向的内存已经被释放或无效,但指针本身没有被置空,继续使用该指针会导致未定义行为。

区别

方面 僵尸对象(Zombie Object) 野指针(Dangling Pointer)
本质 已释放对象被替换成特殊的“僵尸”对象,捕获访问 指针指向已释放或无效内存,未被置空
出现环境 通常在 Objective-C 的 Zombie 模式下出现 任何语言中都可能出现
表现 访问僵尸对象时会触发崩溃,便于调试 访问野指针导致未定义行为,可能崩溃或数据错误
调试工具 Instruments Zombies 工具 静态分析工具、内存检测工具如 AddressSanitizer
防范措施 不访问已释放对象,开启 Zombie 模式辅助调试 释放后将指针置 nil/nullptr,避免悬挂指针

联系

  • 僵尸对象是野指针问题的一种表现形式,是通过特殊机制(Zombie 模式)帮助发现野指针访问。
  • 两者都源于访问已释放的内存,都会导致程序崩溃或异常。
  • 解决方法都强调释放后指针清理和避免访问已释放内存。

示例代码

野指针示例(C 语言)

#include <stdio.h>
#include <stdlib.h>

void dangling_pointer_example() {
    int *ptr = (int *)malloc(sizeof(int));
    *ptr = 42;
    free(ptr);
    // ptr 仍然指向已释放内存,成为野指针
    printf("Value: %d\n", *ptr); // 未定义行为,可能崩溃或打印垃圾值
}

僵尸对象示例(Objective-C)

@interface MyObject : NSObject
@end

@implementation MyObject
- (void)dealloc {
    NSLog(@"MyObject dealloc");
}
@end

void zombie_example() {
    MyObject *obj = [[MyObject alloc] init];
    [obj release]; // 释放对象
    // 访问已释放对象,若开启 Zombie 模式,此处会捕获异常
    [obj description]; // EXC_BAD_ACCESS 或 Zombie 捕获错误
}

总结

  • 野指针 是指向无效内存的指针,是一种常见的内存错误。
  • 僵尸对象 是 Objective-C 中为调试野指针访问而产生的特殊对象。
  • 通过合理管理内存和指针,避免访问已释放对象,可以防止这类问题。

内存泄漏和僵尸对象的区别与联系

内存泄漏(Memory Leak)和僵尸对象(Zombie Object)的区别与联系


内存泄漏 vs 僵尸对象

方面 内存泄漏(Memory Leak) 僵尸对象(Zombie Object)
定义 程序中已不再使用的对象没有被释放,仍占用内存 对象已经被释放,但程序仍尝试访问该对象的内存区域
本质原因 对象的引用计数没有归零,导致内存无法回收 访问已释放对象的指针,导致野指针访问
表现形式 内存持续增长,最终可能导致内存耗尽 程序崩溃,通常是 EXC_BAD_ACCESS 或访问违规错误
发生时机 运行过程中未正确释放对象 释放对象后继续访问该对象
调试方式 使用 Instruments 的 Leaks 工具、静态分析等 开启 Zombie 模式,使用 Instruments 的 Zombies 工具
解决方法 及时释放对象,避免循环引用,管理好对象生命周期 不访问已释放对象,修正野指针,确保对象访问安全

联系

  • 内存泄漏和僵尸对象都与内存管理相关,都是因对象生命周期管理不当引起的问题。

  • 两者都是内存错误,但表现和后果不同:

    • 内存泄漏是“没释放”,导致内存浪费。
    • 僵尸对象是“访问已释放”,导致程序崩溃。
  • 有时内存泄漏会导致程序长期占用大量内存,影响性能;僵尸对象通常导致崩溃,影响稳定性。

  • 在调试过程中,二者常常结合使用不同工具定位问题。


简单比喻

  • 内存泄漏:就像你租了一个房间,但搬走后忘了退租,房间一直占着,别人用不了。
  • 僵尸对象:就像你退了房,但钥匙还在别人手里,别人试图进房间发现房间已经不存在了,导致混乱。

示例代码

  • 内存泄漏示例(NSTimer 循环引用)

    @interface MyClass ()
    @property (nonatomic, strong) NSTimer *timer;
    @end
    
    @implementation MyClass
    
    - (void)startTimer {
        // NSTimer 强引用 self,self 又强引用 timer,形成循环引用
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0                                                  target:self                                                selector:@selector(timerFired)                                                userInfo:nil                                                 repeats:YES];
    }
    
    - (void)timerFired {
        NSLog(@"Timer fired");
    }
    
    - (void)dealloc {
        [self.timer invalidate]; 
        NSLog(@"MyClass dealloc");
    }
    
    @end
    
  • 僵尸对象示例(访问已释放对象)

    interface MyObject : NSObject
    @end
    
    @implementation MyObject
    - (void)dealloc {
        NSLog(@"MyObject dealloc");
    }
    @end
    
    void testZombie() {
        MyObject *obj = [[MyObject alloc] init];
        [obj release]; // 手动释放对象(非 ARC 环境下)
        // 下面访问已释放的对象,导致僵尸对象崩溃
        [obj description]; // EXC_BAD_ACCESS 错误
    }
    

IM数据在iOSApp端存储、读取方案

iOS社交应用设计的IM数据存储、读取及会话管理方案,结合高性能、安全与可扩展性,分为六个核心模块:


📚 一、存储架构设计:三级分层策略

  1. 本地存储层(高频访问)

    • 数据库选型:采用 SQLite + FMDB,轻量且支持复杂查询,优于Core Data在批量写入场景的性能1,3

    • 表结构设计

      • messages 表:消息ID(主键)、会话ID、发送者ID、内容、类型(文本/图片等)、时间戳、状态(已发送/已读等)。
      • conversations 表:会话ID、最后消息ID、未读计数、会话类型(单聊/群聊)。
    • 加密机制:集成 SQLCipher 对数据库文件加密,密钥存储于Keychain并绑定生物识别1,7

  2. 云端存储层(持久化与漫游)

    • 冷热数据分离

      • 热数据(7天内):Redis存储离线消息,支持高并发读取(写扩散模型,每人独立收件箱)5
      • 冷数据(历史消息):HBase按会话ID分片存储(读扩散模型),节省存储空间5
    • 文件存储:图片/视频等大文件上传至对象存储(如AWS S3),仅保留URL在数据库3

  3. 内存缓存层(加速访问)

    • NSCache + LRU策略:缓存最近会话列表及前20条消息,淘汰久未访问数据1
    • 预加载机制:滑动消息列表时,异步加载下一屏内容,避免滚动卡顿7

二、数据读写优化:高性能实践

  1. 写入策略

    • 批量事务处理:单次写入多条消息时,使用FMDB事务包裹,减少I/O次数(例:插入100条消息仅1次磁盘写入)1
    • 事务的ACID特性
      SQLite默认每次执行SQL语句都会开启一个独立事务(隐式事务),导致频繁的磁盘I/O。而显式事务(BEGIN TRANSACTION)将多个操作合并为一个原子单元:
  • 原子性:所有操作要么全部提交(COMMIT),要么全部回滚(ROLLBACK)。
  • 性能提升:减少磁盘写入次数,将N次操作合并为1次I/O,降低系统开销
  • 通过包裹多个executeUpdate操作,将批量插入合并为单次事务提交
    • 异步队列:通过FMDatabaseQueue串行化数据库操作,防止多线程竞争1
  1. 读取优化

    • 索引设计:为会话ID时间戳创建复合索引,加速消息按会话分页查询1
    • 分页加载:每次拉取20条消息,通过WHERE timestamp < last_msg_time LIMIT 20避免内存溢出5
    • 懒加载资源:图片消息先加载缩略图(<50KB),点击后下载原图7

💬 三、会话管理:状态同步与更新

  1. 会话同步机制

    • 增量更新:客户端启动时,仅拉取本地最后更新时间戳后的新会话(减少网络传输)8
    • 未读计数聚合:服务端计算未读数,客户端本地更新时通过事务保证一致性5
  2. 消息状态流转

    graph LR
        A[消息发送] --> B{服务器接收成功?}
        B -->|是| C[更新状态为“已发送”]
        B -->|否| D[重试3次后标记“失败”]
        C --> E{接收方在线?}
        E -->|是| F[推送并标记“已送达”]
        E -->|否| G[存入Redis离线队列]
        F --> H[接收方阅读后标记“已读”]
    
    • 状态回执:接收方阅读后,通过WebSocket实时回传已读状态6

🔒 四、安全与隐私合规

  1. 数据传输安全

    • 端到端加密:敏感消息(如支付凭证)使用Signal协议加密,服务器仅中转密文7
    • SSL Pinning:防止中间人攻击,校验证书指纹7
  2. 本地数据防护

    • 敏感信息隔离:用户身份Token存储于Keychain,消息解密密钥通过生物认证后获取7
    • 沙盒限制:禁止IM数据共享至App Group,防止其他应用读取3

⚙️ 五、性能与扩展性增强

  1. 弱网适配

    • 消息QoS分级:文本消息优先传输,图片/视频在WiFi下自动下载7
    • 断点续传:大文件分片上传,记录分片MD5校验完整性1
  2. 万人群聊优化

    • 读写分离

      场景 策略 优势
      新消息写入 写扩散(每人收件箱) 读性能高,延迟<100ms
      历史消息拉取 读扩散(按会话存储) 存储成本降低70%5
    • 流量控制:群消息仅推送摘要(如“3条新消息”),点击后加载详情5


♻️ 六、备份与灾难恢复

  1. 用户级备份

    • iCloud同步:加密的SQLite数据库自动同步至iCloud,支持换机恢复4,11
    • 增量备份:仅上传新增/修改的消息,节省用户流量11
  2. 数据恢复流程

    • 本地恢复:从iCloud下载备份文件,通过FMDB导入11
    • 服务端漫游:用户重装App后,从HBase拉取最近6个月历史消息5

💎 方案优势总结

  1. 性能指标

    • 消息读取延迟:<50ms(本地缓存命中时)
    • 群消息吞吐量:支持万级并发5
  2. 成本控制:冷热存储分离降低云端费用70%5

  3. 安全合规:满足GDPR及《》要求7

推荐技术栈:FMDB(SQLite封装)+ WebSocket(实时通信)+ Redis/HBase(云端存储) + iCloud(备份)。此方案已在微信、环信等亿级应用中验证,可支撑社交场景下IM全链路需求1,5,7

swift 基础

1. struct 和 class 的区别

类型区别

struct: 值类型

  • 赋值或传递时是值拷贝(深拷贝),独立内存空间
  • 修改副本不会影响原实例

class:应用类型

  • 赋值或者传递时是引用指针的拷贝,共享同一内存空间
  • 修改任一引用会影响所有指向该实例的变量

内存管理

struct:编译器自动管理,栈上,比较高效 class:内存通过ARC管理,堆上分配。可能引发循环引用

继承与多态

struct:不支持继承,但可以通过protocol实现多态 class:支持单继承,可通过override重写方法/属性

可变性

struct:默认不可变,如需修改方法内的属性,需标记为mutaing

//在值类型的实例方法中修改实例属性值,需要在方法前加 mutaing

struct Point2 {
    var x=0.0, y=0.0
    mutating func moveBy(dx:Double, dy:Double) {
//        x += dx
//        y += dy
        //或者直接为 self 赋值
        self = Point2(x: x+dx, y: y+dy)
    }
}

class:始终可变,无需额外关键字

初始化

struct:自动生成成员初始化器 对于struct,如果没有自定义初始化方法,会有一个默认的,为所有属性赋值的初始化方法:

//对于 struct,如果属性没有默认值,初始化的时候,会有一个默认初始化方法,可以为所有属性赋值
struct Size {
    var width:Double
    var height:Double
}
var size = Size(width: 100.0, height: 200.0)

如果有自定义了初始化方法,那么不能再调用默认的初始化方法了

struct Size {
    var width:Double
    var height:Double
    init() { // 自定义了初始化方法
        width = 200.0
        height = 300.0
    }
}
// 如果为 value type 提供了自定义初始化方法,那么就不能调用默认的初始化方法了
// 不能使用 var size = Size(width: 100.0, height: 200.0)
var size = Size()

class:需手动定义初始化器,若继承父类需处理super.init()

使用场景

优先选择struct:

  1. 数据简单,无需继承
  2. 线程安全

优先选择class:

  1. 需共享或者修改同一实例
  2. 需继承或类型检查

map, filter, reduce 作用

  1. map:对集合中每个元素进行转换,返回一个新的集合,可用于元素转换,替换for循环
let numbers = [1, 2, 3, 4]
let newArr = numbers.map { n in
    return n * 2
}
print(newArr) // [2,4,6,8]

let newArr2 = numbers.map { $0 * 2 }
print(newArr2)
  1. filter: 筛选符合条件的元素,返回一个新集合。可用于过滤无效数据,搜索匹配,替代if+for循环
let strs = ["fasdf", "werw", "fvvf", "32e124faf", "mmkjbnj"]
let res1 = strs.filter { str in
    return str.count > 6
}
print(res1) // ["32e124faf", "mmkjbnj"]

let res2 = strs.filter { $0.count > 6 }
print(res2)
  1. reduce:将集合的所有元素合并成一个值,如求和,字符串拼接,替代for循环+累加变量
let result = array.reduce(initialValue) { partialResult, element -> T in
    // 累积计算
    return updatedPartialResult
}

let nums = [1,2,3,4]
// 0 是初始值
let sum = nums.reduce(0) { partialResult, n in
    return partialResult + n
}
print(sum)

let sum1 = nums.reduce(0, +)
print(sum1)

let strs = ["hello", "world", "shanghai"]
let sum2 = strs.reduce("") { $0 + $1 }
print(sum2)

string和NSString的区别和联系

string可通过定义时的let或var决定,NSString不可变 string值类型,NSString引用类型 string编码方式UTF-8/UTF-16,自动处理Unicode string具有赋值时拷贝(Copy-on-Write)的特性,NSString传递指针

互相转换

let str = "hello"
let nstr = str as NSString
let nstr2 = NSString(string: "world")

let nstr3: NSString = "wwww"
let swiftStr = nstr3 as String
let swiftstr2 = String(nstr3)

swift中associatedtype 的作用

When defining a protocol, it’s sometimes useful to declare one or more associated types as part of the protocol’s definition. An associated type gives a placeholder name to a type that’s used as part of the protocol. The actual type to use for that associated type isn’t specified until the protocol is adopted. Associated types are specified with the associatedtype keyword

associatedtype主要作用是让protocol支持泛型 如下,定义一个协议,有一个属性和两个方法

protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    func getItem(at index: Int) -> Item
}

Item是一个类型占位符,具体类型由实现协议的结构体或类来定义

下面的结构体实现了该协议,并且指定Item的类型为Int

struct IntContainer: Container {
    typealias Item = Int // 可以省略,swift会根据类型推断Item的具体类型
    private var items:[Int] = []
    mutating func append(_ item: Int) {
        items.append(item)
    }

    func getItem(at index: Int) -> Int {
        return items[index]
    }
}

又一个实现该协议的struct,这次指定Item为String

struct StringContainer: Container {
    private var items:[String] = []
    mutating func append(_ item: String) {
        items.append(item)
    }

    func getItem(at index: Int) -> String {
        return items[index]
    }
}

分别初始化两个结构体,可以看到可以有不同的类型

var intContainer = IntContainer()
intContainer.append(12)
intContainer.append(13)
print(intContainer.getItem(at: 0))

var stringContainer = StringContainer()
stringContainer.append("hello")
stringContainer.append("world")
print(stringContainer.getItem(at: 0) + " " + stringContainer.getItem(at: 1))

open和public的区别

特性 public open
模块外可见性 ✅ 其他模块可以访问 ✅ 其他模块可以访问
模块外可继承 ❌ 其他模块不能继承该类 ✅ 其他模块可以继承该类(open class
模块外可重写 ❌ 其他模块不能重写方法/属性 ✅ 其他模块可以重写方法/属性(open func
适用场景 暴露接口但不允许外部修改 允许外部继承或重写(如框架设计)

Optional是用什么实现的?

enum,有两个case: .some(value) .none

定义静态方法时,staitc 和 class 有什么区别?

都用于定义类型级别的方法或者属性,但是关键区别在于是否允许子类重写

特性 static class
适用范围 类、结构体、枚举、协议 仅类(class
是否允许重写 ❌ 不可被子类重写 ✅ 允许子类重写(需加 override
语义 强调静态不可变性 强调类的可继承性

协议中必须用static

何时用static?

  1. 定义工具方法或者常量
  2. 需要跨类型使用
  3. 禁止子类修改

何时用class?

  1. 设计可扩展的类层次结构
  2. 需要子类提供特定实现

如果想禁止重写class方法,可配合final使用:

class Parent {
    final class func cannotOverride() {} // 子类不能重写
}

weak和assign区别

weak:修饰对象类型,不会增加引用计数,对象释放后自动设置为nil,代理用weak,可防止循环引用。 assign:修饰基本数据类型,修饰对象时可能导致野指针。

weak修饰的属性自动置为nil 的原理

weak是Runtime维护了一个hash(哈希)表,用于存储指向某个对象的所有weak指针。weak表其实是一个hash(哈希)表,Key是所指对象的地址,Value是weak指针的地址(这个地址的值是所指对象指针的地址)数组。

weak_table: 全局管理弱引用的哈希表 weak var obj = someObj

key: 被弱引用的对象的地址,这里是指 &someObj 作用:通过地址快速定位到该对象的所有弱引用记录

value:weak_entry_t结构体 类型:weak_entry_t,存储一个对象的所有弱引用信息

struct weak_entry_t {
    DisguisedPtr<objc_object> referent;  // Key 的副本(被弱引用的对象)
    union {
        struct {
            weak_referrer_t *referrers;  // 动态数组:存储所有 weak 变量的地址
            uintptr_t num_refs;          // 数组长度
        };
        struct {
            weak_referrer_t  inline_referrers[4]; // 内联数组(优化少量 weak 引用)
        };
    };
};

referrers存储的是变量obj的地址,如:[&obj],如果有多个weak修饰,那么如:[&obj1, &obj2] 内存关系图:

image.png

weak引用插入weak_table_t的过程: 当执行 weak var obj = someObject 时:

  1. 计算 someObject 地址的哈希值:
    hash = hash_pointer(someObject)
  2. 通过 hash & mask 计算数组索引 index
  3. 检查 weak_entries[index]
    • 若为空,初始化一个新的 weak_entry_t,存入 referent = someObject 和 referrers = [&obj]
    • 若已存在且 referent == someObject,将 &obj 追加到 referrers 数组。
    • 若发生哈希冲突(referent != someObject),线性探测下一个位置(开放寻址法)

什么时候用copy修饰符

修饰NSString,NSArray,NSDictionary等不可变类型的时候 原因:使用copy修饰的时候,赋值的时候,实际保存的是值的一个不可变副本,不会被外界无意修改。 NSString,NSArray,NSDictionary有对应的可变类型,如果用strong修饰,得到的实际是可变对象,外界修改时会同步改变属性的值

不手动指定Autoreleasepool的前提下,一个AutoreleasePool对象在什么时候释放?

所有autorelease对象都由主线程的RunLoop创建的@autoreleasepool来管理。

每一个Autoreleasepool都是由一系列的AutoreleasePage组成的,数据结构为双向链表,AutoreleasePage是节点

AutoreleasePage

类似一个栈结构

image.png

初始化的时候,调用objc_autoreleasePoolPush,把一个POOL_SENTINELpush到自动释放池的栈顶,并且返回这个POOL_SENTINEL对象。 POP时,向自动释放池中的对象发送release消息,直到第一个POOL_SENTINEL

Autoreleasepool和RunLoop的关系

主线程的RunLoop中注册了两个observer

  • 一个监听kCFRunLoopEntry事件,调用push
  • 第二个observer,监听kCFRunLoopBeforeWaiting事件,调用pop,push。监听kCFRunLoopBeforeExit事件,调用pop。 总结就是runloop会自动创建和销毁Autoreleasepool。

RunLoop

juejin.cn/post/684490…

运行循环,在程序运行中做一些事情,如接收和处理消息,休眠等待 主要是靠内部的事件循环来实现

事件循环

  • 没有消息时休眠,避免占用资源
  • 有消息时唤醒线程
  • 用户态到内核态到转换

RunLoop mode

多种mode起到屏蔽效果,运行在mode1模式的时候,无法处理mode2中的事件。

RunLoop和线程的关系

  • 一一对应
  • 如果没有Runloop,线程执行完任务就会退出,Runloop会在第一次获取它时创建

子线程创建RunLoop过程

  1. 获取当前线程的RunLoop
  2. 添加source/port
  3. run

为什么block可以修改使用__block修饰的局部变量?

使用__block修饰的局部变量,会在底层被编译成一个结构体,结构体中引用的有该变量的地址,在block中会通过地址访问该结构体,然后通过结构体更改该变量的值。

如何解决NSTimer和self的循环引用?

  1. 中间代理
  2. iOS 10+ 提供了 scheduledTimerWithTimeInterval:repeats:block:,结合 weakSelf
__weak typeof(self) weakSelf = self;
_timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
    [weakSelf timerFired]; // 必须检查 weakSelf 是否存活
}];
  1. gcd timer

dispatch_barrier_async作用

主要起到线程同步的作用,只能作用在自定义的并发队列

  • 立即返回
    dispatch_barrier_async 在提交任务后会 立即返回,不会阻塞当前线程(异步提交)。

  • 屏障块的执行时机

    • 当屏障块到达 私有并发队列的队首 时,不会立即执行
    • 队列会 等待当前正在执行的所有并发任务完成,之后 屏障块单独执行
    • 屏障块执行期间,队列 暂停执行其他任务,直到屏障块完成。
  • 屏障块之后的任务
    在屏障块之后提交的任务,必须等待屏障块执行完毕才会开始执行

举例: 在barrier之前有3个并发任务A,B,C。barrier中加入了任务D,后续还有任务E,F。 那么任务执行如下:

  • A,B,C并发执行
  • D不会立即执行,要等到A,B,C都执行完毕后,D才开始执行。
  • D执行完毕后,E,F开始执行。

Objective-C的消息处理流程

www.jianshu.com/p/220841172…

iOS 事件响应链

juejin.cn/post/689451…

Objective-c中的nil,NULL,Nil,NSNULL

nil

OC中nil 是一个宏,定义如下:

#define nil ((id)0)
NSString *str = nil; //实际会被转为
NSString *str = ((id)0); // 即 str = 0

它表示 将 0 强制转换为 id 类型(Objective-C 对象的通用指针类型)。

Objective-C 中,对象指针的本质是 C 指针,而 0(或 NULL)是 C 语言中表示空指针的标准方式。这里提供两个验证方式:

1、nil 和 0 的等价性:

图片

2、指针的底层值:

图片

nilNULL 和 0 在指针层面是相同的,以下几行代码都不会报错

int *a = nil;
int *b = NULL;
NSString *c = NULL;
NSString *c0;
NSString *e = Nil;

为什么 Objective-C 要用 nil 而不是直接写 0

语义清晰,nil 明确表示“空对象指针”,而 0 可能被误解为整数值,与 C 语言的 NULL 区分,NULL 用于 C 指针(如 int*),nil 用于 Objective-C 对象。但是从上述例子中也可以看到NULL和nil可以混用

Nil

Nil 表示指向 Objective-C 类(Class)的空指针。它是一个宏,定义为 (Class)0。可以说跟nil是一模两样了

用于 类的空指针,通常较少使用:

Class someClass = Nil;
if (someClass == Nil) {    
    NSLog(@"Class is Nil");
}

与 nil 类似,但用于 类对象(Class)  而不是实例对象。

NULL

NULL 是 C 语言标准的空指针,表示指向任何类型的空指针。通常定义为 (void *)0

int *ptr = NULL;
if (ptr == NULL) {    
    NSLog(@"ptr is NULL");
}

适用于 C 语言层面的指针(如 int*char*)。

在 Objective-C 中,优先使用 nil 而不是 NULL

NSNull

在 NSArray 或 NSDictionary 中,nil 不能直接存储(因为 nil 表示列表结束),可以用 NSNull 占位

NSNull 是一个单例对象, 它只有一个单例方法:+[NSNull null],用于表示集合(如 NSArrayNSDictionary)中的空值

它不是指针,而是一个 真正的 Objective-C 对象

iOS 二维码扫描组件推荐:zcscan,一款轻量级可定制的扫码工具库!

🚀 iOS 二维码扫描组件推荐:zcscan,一款轻量级可定制的扫码工具库!

在日常 App 开发中,扫码早已成为一个非常常见的需求,比如登录、分享、支付、设备配对等等。而系统自带的 AVCapture 使用复杂、UI 样式千篇一律,不利于快速接入和个性化定制。

今天给大家推荐一款开源的 Swift 扫码组件库 👉 zcscan,支持二维码扫描 + 相册选择,并且提供了丰富的 UI 自定义能力。


🌟 项目亮点

  • ✅ 基于 Swift 构建,轻量且易集成
  • ✅ 支持自定义扫码界面样式(线条、按钮、图标等)
  • ✅ 支持选择相册图片进行二维码识别
  • ✅ 支持 present 和 push 两种方式展示扫码页
  • ✅ CocoaPods 和 SwiftPM 均支持
  • ✅ 默认样式已足够美观,开箱即用

📦 安装方式

1️⃣ CocoaPods

pod 'zcscan'

终端执行:

pod install

2️⃣ Swift Package Manager(SPM)

  • Xcode 菜单栏选择:File > Add Packages...
  • 输入地址:github.com/ZClee128/zc…
  • 也可以在 Package.swift 中添加:
.package(url: "https://github.com/ZClee128/zcscan.git", from: "1.0.0")

⚡️ 快速上手

✅ 导入模块

import zcscan

✅ 方式一:默认 present/push 扫码页面

let vc = ZCScanViewController.present(fromVC: self, albumClickBlock: nil, resultBlock: { link in
    print("扫描结果:\(link)")
})

✅ 方式二:push + 自定义相册选择行为

let vc = ZCScanViewController.push(fromVC: self, albumClickBlock: { selectPhoto in
    let picker = ZLPhotoPicker()
    picker.selectImageBlock = { results, _ in
        if let img = results.first?.image {
            selectPhoto(img)
        }
    }
    picker.cancelBlock = {
        // 用户取消选择
    }
    picker.showPhotoLibrary(sender: self)
}, resultBlock: { link in
    print("扫描结果:\(link)")
})

🎨 UI 高度可定制

zcscan 暴露了所有必要的 UI 属性,可以根据你的品牌样式灵活设置:

let config = ZCScanManager.shared.conifg
config.selectQrcodeBtnImage = UIImage(named: "qrcode_arrow")
config.scanninglineImage = UIImage(named: "scan_line")

📷 示例效果图

IMG_1651.PNG

IMG_1650.PNG

📚 项目地址 & Demo

GitHub 地址:github.com/ZClee128/zc…

示例工程:Example/ViewController.swift

欢迎大家 Star ⭐️ 一下!


🧑‍💻 作者信息

作者:ZClee

Email:876231865@qq.com


📄 License

zcscan 使用 MIT 开源协议,免费商用,放心食用。


如果你觉得这个库不错,欢迎点赞收藏 ⭐️,转发给更多有需要的朋友!

如你需要二维码扫码、照片识别、界面自定义,zcscan 都能助你一臂之力!


Appstore开始新一轮备案号审查,看看你的产品被下架了么?

背景

最近Appstore出现大面积产品下架和重新上架的情况,正常来讲下架的产品通常都是违规导致的3.2f,也有其他几种非常规现象。比如,开发者账号到期未续费或者申诉成功解救

但是最近这批下架重新上架的面积及其普遍,加上粉丝留言。基本可以锁定是苹果主动下架了中国大陆区

在Appstore后将看到如下情况:

下架截图.png

App是啥?

自从新赛季开启App备案之后,苹果陆续同步了关于中国大陆区的校验工作,甚至在Appstore后台新增了专属于中国大陆区的ICP备案号填写。

其实App备案本身和域名备案是一样的,主要是确保开发者主体真实有效,约束大多数合规开发者的君子协议

Appstore后台的备案号也经常有Bug,所以才会导致这种局面。

应该做什么

对于中国大陆区的开发者来说,无论是个人还是公司主体都应该积极备案。如果说产品名称不确定的情况下,可以在提审时候不销售中国大陆区,同时着手备案相关事宜。这样既能保证产品的一个正常迭代,又能不受政策影响

不该做什么

既然可以备案名称而且不需要提供额外的软著或者其他证明材料,这时候就会有人抖机灵,耍小聪明。

首先就是备案竞品名称,抢注热品牌词,其实这种做法毫无意义。对备案的开发者来说,白瞎了一个备案服务号100元,以及一周左右的备案时间

其次备案顺利到手,也无法完成Appstore上架的需求。同时真正的品牌词持有者依旧可以正常备案,丝毫不受影响

特别说明:不要妄图乱填备案号的方式欺骗审核人员,已经有同行因为此问题触发了3.2f的彩蛋。所以,还是要老老实实做人,本本分分开发。

遵守规则,方得长治久安,最后祝大家大吉大利,今晚过审!

相关推荐

# Pingpong和连连的平替,让AppStore收款无需新增持有人。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

Swift 单元测试突破口:如何优雅测试私有方法?

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

image.png

前言

在编写单元测试时,我们通常希望测试代码的业务逻辑。常用的测试编写格式是 GWT(Given-When-Then),其中在 "When" 步骤中调用我们想要测试的代码。然而,如果你曾写过测试用例,你可能遇到过无法测试私有方法的问题。

这是因为私有属性或方法的作用域仅限于其所在的类,因此我们无法在测试类中访问这些属性或方法。

例如,如果我们有一个类,其中包含一个私有方法,如下所示:

class SomeClass {
    private func somePrivateMethod() {
        // 一些逻辑代码
    }
}

尝试在单元测试中调用 somePrivateMethod,会发现无法访问,并会产生编译错误:“'somePrivateMethod' 由于 'private' 保护级别而无法访问”,这很容易理解。

class SomeClassTests: XCTestCase {
    func testSomePrivateMethod() {
        let testTarget = SomeClass()
        testTarget.somePrivateMethod() // 错误:无法访问私有方法
    }
}

那么我们该如何解决这个问题呢?如果 somePrivateMethod 包含一些业务逻辑,单元测试一定要能够覆盖,那么我们就必须找到其他可行的方法。

方法一:改变访问级别

一种方法是将这些方法的访问级别改为非私有,但这会将这些方法暴露给其他代码,显然这不是一个理想的方案。

public class SomeClass {
    public func somePrivateMethod() {
        // 一些逻辑代码
    }
}

方法二:使用 TestHooks

TestHooks 可以派上用场了。我们可以创建测试钩子并利用它们来访问私有方法和属性,以进行单元测试。TestHooks 只是我们的类持有的一个钩子集,通过这些钩子可以提供对私有方法和属性的访问。

创建 TestHooks 时需要注意的几点:

  1. TestHooks 是在我们希望访问其私有方法或属性的类的扩展中创建的(在我们的例子中是 SomeClass),因为只有这样钩子才能访问那些属性或方法。

  2. 我们想访问的每个属性或方法都需要一个钩子。

  3. 建议将钩子扩展放在 DEBUG 宏中,以避免误用。

实现 TestHook:

以下是 SomeClass 的 TestHook 实现示例:

#if DEBUG    // 在 debug 宏下添加以避免误用,并避免在发布环境中暴露私有方法
extension SomeClass {    // 在类的扩展中编写,我们希望访问其私有方法
    var testHooks: TestHooks {      // testHooks 的实例,通过它我们将在单元测试中访问私有方法
        TestHooks(target: self)      // 使用 self 初始化以访问 self 的私有方法
    }
    struct TestHooks {    // TestHooks 结构体,其中包含我们希望访问的所有属性和方法的钩子
        var target: SomeClass    // 需要访问其私有方法的目标
        func somePrivateMethod() {    // 暴露方法的钩子
            target.somePrivateMethod()    // 暴露该方法
        }
    }
}
#endif

这样一来,我们可以在单元测试文件中通过 testHooks 访问 somePrivateMethod

class SomeClassTests: XCTestCase {
    func testSomePrivateMethod() {
        let testTarget = SomeClass()
        testTarget.testHooks.somePrivateMethod() // 通过 testHooks 访问私有方法
    }
}

结尾

通过这种方式,我们可以在不改变代码结构的情况下,合理地测试私有方法。

TestHooks 提供了一种在测试环境中访问私有方法的途径,同时在发布环境中保持代码的封装性。这是一种在不破坏类封装原则的情况下进行单元测试的有效方法。

希望这能帮助到大家更好地进行 Swift 的单元测试,你对这种方式有什么看法呢?是否还有更好的方案分享,欢迎在评论区留言讨论。

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

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

Swift组件:使用第三方静态库

最近在做的项目,使用了组件化方案,组件都是用Swift编写。在做用户组件的时候,用到了微信登录SDK,该SDK是一个使用OC编写的静态库,文件只有.a和.h文件,通过CocoaPods引入到项目后,尝试导入,结果报错找不到该库。

为什么找不到静态库?

明明导入了SDK,为什么找不到?

很简单,因为OC代码没有暴露给Swift。

在常规Swift项目中,创建OC文件时Xcode会自动帮我们创建桥接文件Xx-Bridging-Header.h,供Swift使用OC代码,同时也会创建Xx-Swift.h文件,供OC使用Swift代码,以达到混合开发的目的。

但是,组件是不允许使用桥接文件的。

怎么办?

我们来看下Cocoapods的做法。 比如在Swift项目中导入OC库MJRefresh。 安装到项目后,会看到在该库的Support Files文件夹中多了MJRefresh-umbrella.h和MJRefresh.modulemap文件。

MJRefresh-umbrella.h文件导入了所有OC头文件:

#import "MJRefreshAutoFooter.h"
#import "MJRefreshBackFooter.h"
#import "MJRefreshComponent.h"
...

MJRefresh.modulemap文件声明了Swift可以导入的库名MJRefresh

framework module MJRefresh {
  umbrella header "MJRefresh-umbrella.h"
  export *
  module * { export * }
}

这一套下来,Swift就知道可以导入哪个OC库,里边有哪些OC类可以用。

但是,为什么导入微信提供的开源库之后,没有自动生成上面说的这些文件?

因为微信开源库提供的文件不是源码,而是打包好的静态库和头文件。CocoaPods不会自动创建这些文件。

怎样才能让CocoaPods生成桥接文件?

把SDK包装成一个本地组件!

参考项目:gitee.com/style_tende…

我们先看下微信开源库有哪些文件:

libWechatOpenSDK.a
WechatAuthSDK.h
WXApi.h
WXApiObject.h

创建文件夹和配置文件,目录如下:

WechatOpenSDK

  • WechatOpenSDK
    • libWechatOpenSDK.a
    • WechatAuthSDK.h
    • WXApi.h
    • WXApiObject.h
  • WechatOpenSDK.podspec

配置文件的内容如下:

Pod::Spec.new do |s|
  s.name         = 'WechatOpenSDK'
  s.version      = '2.0.4'
  s.summary      = '本地集成的微信 OpenSDK'
  s.homepage     = 'https://open.weixin.qq.com'
  s.license      = { :type => 'Commercial' }
  s.author       = { 'WeChat' => 'wechat@tencent.com' }
  s.source       = { :path => '.' }

  s.ios.deployment_target = '9.0'
  s.swift_version = '5.0'
  s.requires_arc = true
  s.static_framework = true
  
  s.source_files         = 'WechatOpenSDK/*'
  s.vendored_libraries   = 'WechatOpenSDK/*.a'
  
  # SDK依赖
  s.libraries = 'c++'}
  
end

注意:s.source_files需把.a文件也包括在内,否则CocoaPods不会生成桥接文件。

怎么使用本地组件?

1、在组件库的配置文件中依赖该组件:

s.dependency 'WechatOpenSDK'

2、在Podfile文件中指定该组件的路径:

pod 'WechatOpenSDK', :path => '../WechatOpenSDK'

3、在Swift文件中导入头文件

import WechatOpenSDK

其他

问题一:组件为什么不能使用桥接文件?

  1. Bridging-Header 只对主 Target 有效

Bridging-Header 是通过主 App 的 Build Settings 中 Objective-C Bridging Header 设置路径生效的。 这个设置不会自动传递给子模块(比如 Pod 或 Framework)。

  1. Swift 模块之间是隔离的

Swift Framework 被设计为 模块化的独立单元,不能通过 Bridging-Header 暴露 OC 给 Swift。 如果允许使用 Bridging-Header,模块之间的隔离性就被破坏了,会出现冲突、依赖混乱等问题。

问题二:为什么 CocoaPods 对静态库没有自动生成 umbrella header (umbrella.h) 和 module.modulemap 文件?

Umbrella Header 和 modulemap 是为动态 Framework(modular framework)服务的,而不是为传统的静态库(.a)服务的。

  1. 静态库(Static Library)本身不支持模块化特性

静态库(.a 文件)是预编译的二进制文件,不具备模块边界、命名空间,本质上只能通过头文件路径手动 #import 来暴露接口。所以CocoaPods 默认不会为 .a 文件创建模块相关的 umbrella header 或 modulemap。

  1. CocoaPods 默认是非模块化集成方式

CocoaPods 默认行为(不启用 use_frameworks! 或 modular_headers):不开启模块化编译,不生成 module.modulemap,不要求每个 Pod 是一个独立模块。这样做的原因是为了兼容所有 Objective-C 传统项目(非 Swift 项目)。

iOS 使用 Objective-C 实现基于 Wi-Fi 的 Socket 通信(TCP)

在iOS开发中,除了蓝牙通信(BLE),Wi-Fi网络下的Socket 通信同样常用于设备互联,比如智能家居、安防系统、工业控制等应用场景。

本篇文章将以Objective-C为语言,完整讲解我在iOS开发工程中如何使用 GCDAsyncSocket 第三方库,在 iOS设备上通过Wi-Fi与局域网设备建立 TCP Socket 通信连接,并实现数据的收发。

使用的框架

导入第三方库 GCDAsyncSocket

我们使用 CocoaAsyncSocket 项目中的 GCDAsyncSocket 作为TCP Socket的封装库。

安装方式:

使用 CocoaPods 安装:

pod 'CocoaAsyncSocket'

或手动集成:

可以自行从GitHub下载,详细教程可以从GitHub官网查看说明。

通信原理简介

  • 基于 TCP 协议的通信是可靠连接
  • iOS 作为客户端,主动连接服务器(通常是局域网中的嵌入式设备)
  • 通过 IP 和端口完成连接,发送/接收数据使用 NSData

创建 SocketManager 类(Objective-C)

我们将通信逻辑封装在 SocketManager 单例类中,方便统一管理。

SocketManager.h


#import <Foundation/Foundation.h>
#import "GCDAsyncSocket.h"

NS_ASSUME_NONNULL_BEGIN

@interface SocketManager : NSObject <GCDAsyncSocketDelegate>

@property (nonatomic, strong) GCDAsyncSocket *socket;

/// 单例
+ (instancetype)sharedManager;

/// 连接服务器
- (void)connectToHost:(NSString *)host port:(uint16_t)port;

/// 发送数据
- (void)sendData:(NSData *)data;

/// 断开连接
- (void)disconnect;

@end

NS_ASSUME_NONNULL_END

SocketManager.m

#import "SocketManager.h"

@implementation SocketManager

+ (instancetype)sharedManager {
    static SocketManager *manager = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        manager = [[SocketManager alloc] init];
    });
    return manager;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        self.socket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_main_queue()];
    }
    return self;
}

#pragma mark - 连接服务器

- (void)connectToHost:(NSString *)host port:(uint16_t)port {
    if ([self.socket isConnected]) {
        [self.socket disconnect];
    }
    NSError *error = nil;
    BOOL result = [self.socket connectToHost:host onPort:port error:&error];
    if (!result || error) {
        NSLog(@"连接失败:%@", error.localizedDescription);
    } else {
        NSLog(@"正在连接 %@:%d", host, port);
    }
}

#pragma mark - 发送数据

- (void)sendData:(NSData *)data {
    if ([self.socket isConnected]) {
        [self.socket writeData:data withTimeout:-1 tag:0];
    } else {
        NSLog(@"Socket未连接,无法发送");
    }
}

#pragma mark - 断开连接

- (void)disconnect {
    [self.socket disconnect];
}

#pragma mark - GCDAsyncSocketDelegate

/// 连接成功回调
- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port {
    NSLog(@"连接成功:%@:%d", host, port);
    [sock readDataWithTimeout:-1 tag:0]; // 准备接收数据
}

/// 接收到数据
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
    NSString *receivedStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    NSLog(@"收到数据:%@", receivedStr);
    [sock readDataWithTimeout:-1 tag:0]; // 继续读取
}

/// 数据发送成功
- (void)socket:(GCDAsyncSocket *)sock didWriteDataWithTag:(long)tag {
    NSLog(@"数据发送成功");
}

/// 断开连接
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(nullable NSError *)err {
    NSLog(@"连接断开:%@", err ? err.localizedDescription : @"正常断开");
}

@end

示例调用代码


NSString *ip = @"192.168.1.100"; // 设备IP
uint16_t port = 8080; // 端口

[[SocketManager sharedManager] connectToHost:ip port:port];

// 构建发送命令
NSString *cmd = @"A1B2C3D4";
NSData *cmdData = [ParseDataTool transToDataWithString:cmd];
[[SocketManager sharedManager] sendData:cmdData];

我在工作中常见问题与建议

1. 连接不上设备?

  • 检查设备是否和 iOS 在同一局域网
  • 检查端口是否开放(用 PC 尝试连接测试)
  • 如果是虚拟机或开发板,请关闭防火墙或开启Socket服务

2. 收不到数据?

  • 检查服务器是否主动发送数据
  • 请确保调用了 readDataWithTimeout:tag: 读取回包

3. 断开频繁?

  • TCP需要持续心跳或定时交互,避免NAT路由断流
  • 可设置定时器每隔 30 秒发送一次PING包

实用拓展建议

  • 建议使用我之前的ParseDataTool 工具类来处理十六进制命令、异或校验等底层数据
  • 若需处理大数据或文件传输,请考虑分片 + 回包确认机制
  • 若需加密通信,可结合TLS或AES加密库使用

若有说错的地方,恳请大家指正。相互学习,一起进步~~

与 AI 共舞:我的 Claude Code 一月谈

转眼间,我使用 Claude Code 已经整整一个月了。这段时间里,它迅速成为了开发者们的新宠,关于 Claude Code 的讨论充斥着我的社交媒体时间线。恰好有网友在 Discord 上让我聊聊 Claude Code,借此机会,我想梳理一下这段时间的使用感受,以及过去两年中 AI 为我的开发工作带来的便利与思考。

掌握生死时速:苹果应用加急审核全攻略!

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

前言

image.png

在应用开发的过程中,总会遇到一些突发情况,比如应用版本在上线后出现重大 bug 或崩溃,严重影响了大部分用户的使用体验。这种情况下,及时响应和修复问题至关重要,不仅可以减少用户的不良体验,还能防止应用评分的下降。

然而,即便你已经找到了问题的根源,并准备好了解决方案,想要快速发布更新版本,也并非完全由你决定。

因为首先苹果是不允许热更新的,每个版本发布都必须经过苹果的审核,其次审核是否通过完全取决于苹果,因此加快审核流程就显得尤为重要。

但很多 iOS 开发者可能不知道,其实可以通过“加急审核”来告知 App Review 团队有紧急更新需要审核,从而加快审核速度。

本文将详细介绍如何通过 Apple 开发者门户请求加急审核。

如何请求加急审核

要请求加急审核,你需要前往 developer.apple.com 并登录到你的开发者账号。登录的账号必须具备管理需要请求加急审核的应用的权限。

image.png

  1. 滚动到主页底部,点击“联系我们”链接。

image.png 2. 从列表中选择“应用审核”类别。

Image

  1. 选择“请求加急应用审核”选项。

Image

  1. 点击“联系应用审核团队”。

Image

  1. 填写表单,告知审核团队你希望加快处理的应用信息。

Image

需要在表单中提供的信息包括:

  • 你希望向应用审核团队提出的请求类型:在我们这种情况下,选择“请求加急审核”选项。

  • 请求加急审核的人员姓名。

  • 请求加急审核的人员邮箱。

  • 拥有该应用的组织名称。

  • 应用名称。

  • 需要加急审核的版本平台。

完成以上步骤后,点击最后的“Send”按钮,将请求提交给应用审核团队。

正常情况下,几个小时后会收到苹果审核的结果。

注意事项

虽然加急审核是开发者工具箱中的一个强大工具,是在紧急情况下将应用快速交到用户手中的最佳方式,但它应仅在特殊情况下使用。正如 Apple 在审核表单中提到的:

如果你面临紧急情况,比如修复关键 bug 或发布应用以配合某个事件,可以通过填写此表单请求加急审核。

同时需要注意,太多次的加急审核请求可能会导致 Apple 对你的反感,然后可能会导致对你之后的加急请求不予理会,因此要谨慎使用。

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

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

Swift 6.2 中的 `@concurrent`

核心概念

@concurrent 是 Swift 6.2 引入的新特性,用于明确标记需要卸载到全局执行器(后台线程)的函数。它与 nonisolated(nonsending) 共同构成 Swift 并发模型的改进,旨在解决以下问题:

  1. 行为统一
    消除异步/同步函数在隔离行为上的不一致性
  2. 显式意图
    明确标识需要并发执行的代码
  3. 简化复杂度
    减少不必要的并发隔离域

关键机制解析

1. nonisolated(nonsending)(统一行为)

// 始终在调用者的执行器上运行
nonisolated(nonsending) func decode<T: Decodable>(_ data: Data) async throws -> T
版本 行为差异
Swift 6.1 异步函数 → 全局执行器
Swift 6.1 同步函数 → 调用者执行器
Swift 6.2 统一在调用者执行器运行

2. @concurrent(显式卸载)

// 明确卸载到全局执行器
@concurrent func decode<T: Decodable>(_ data: Data) async throws -> T
特性 说明
自动标记 nonisolated 无需额外声明
创建新隔离域 要求状态实现 Sendable
使用限制 不能与显式隔离声明(如 @MainActor)共存

何时使用 @concurrent

适用场景

class Networking {
    // 主线程安全的网络请求
    func loadData(from url: URL) async throws -> Data { ... }
    
    // 耗时解码 → 适合 @concurrent
    @concurrent func decode<T: Decodable>(_ data: Data) async throws -> T {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    }
    
    func getFeed() async throws -> Feed {
        let data = try await loadData(from: Feed.endpoint)
        // 避免阻塞调用者线程
        let feed: Feed = try await decode(data)
        return feed
    }
}

使用原则

  1. 精准定位
    仅标记实际需要并发的函数(如 CPU 密集型任务)
  2. 避免过度使用
    减少不必要的隔离域和 Sendable 约束
  3. 性能优化
    解决特定性能瓶颈(如大数据量解码)

总结对比表

特性 nonisolated (旧) nonisolated(nonsending) @concurrent
执行位置 异步→全局/同步→调用者 始终在调用者执行器 全局执行器
隔离域 可能创建新隔离域 不创建新隔离域 创建新隔离域
状态要求 潜在需要 Sendable 无特殊要求 必须 Sendable
使用场景 兼容旧版 默认推荐 显式并发需求
代码可读性 意图模糊 行为明确 意图明确

在Swift中运行Silero VAD

最近又开始学习Swift了,前段时间在AI的帮助下做了一个可以和大模型聊天的软件,当时VAD的功能很头痛,搜了下有一个付费的Cobra VAD,另外就只有靠音频能量判断了,这种方式不准。

最近做的东西又有VAD需求了,研究了很久后可以在Swift里跑Silero VAD了,直接把代码丢出来。

由于我不知道如何把ONNX模型转成Core ML的,官方ONNX Runtime只有Pods的包,我用的是另一个Swift Packags版本的ONNX Runtime,用Pods的包要把import OnnxRuntimeBindings换一下。

//
//  SileroVAD.swift
//  Real-time Captions
//
//  Created by yu on 2025/6/30.
//

import AVFoundation
import Foundation
import OnnxRuntimeBindings

/// 说话起止事件回调
protocol SileroVADDelegate: AnyObject {
    /// 检测到"开始说话"
    /// - Parameter probability: 触发时那一帧的 VAD 概率
    func vadDidStartSpeech(probability: Float)

    /// 检测到"结束说话"
    /// - Parameter probability: 触发时那一帧的 VAD 概率
    func vadDidEndSpeech(probability: Float)
}

final class SileroVAD {
    // MARK: - 可调参数

    public struct Config {
        /// 进入说话的高阈值
        public var threshold: Float = 0.5
        /// 退出说话的低阈值(自动与 threshold 保持 0.15 差值)
        public var negThreshold: Float { max(threshold - 0.15, 0.01) }
        /// 连续多长时间高于 threshold 才算"开始说话"(秒)
        public var startSecs: Float = 0.20
        /// 连续多长时间低于 negThreshold 才算"结束说话"(秒)
        public var stopSecs: Float = 0.80
        /// 采样率,仅支持 8 kHz / 16 kHz
        public var sampleRate: Int = 16000

        public init() {}
    }

    // MARK: - 内部状态

    private enum VADState {
        case silence // 静音状态
        case speechCandidate // 可能开始说话
        case speech // 正在说话
        case silenceCandidate // 可能结束说话
    }

    private enum VADError: Error {
        case modelLoadFailed(String)
        case invalidAudioFormat(String)
        case inferenceError(String)
        case tensorCreationFailed(String)
    }

    // MARK: - 核心属性

    private let session: ORTSession
    private var state: ORTValue
    private let config: Config
    public weak var delegate: SileroVADDelegate?

    // 状态机相关
    private var vadState: VADState = .silence
    private var speechFrameCount = 0
    private var silenceFrameCount = 0
    private var lastProbability: Float = 0.0

    // 阈值(基于配置计算的帧数)
    private let speechFrameThreshold: Int
    private let silenceFrameThreshold: Int

    // 音频缓冲
    private var sampleBuffer: [Float] = []
    private let bufferSize = 512

    // MARK: - 公有方法

    public init(config: Config = Config(), delegate: SileroVADDelegate? = nil) {
        self.config = config
        self.delegate = delegate

        // 计算帧数阈值(基于配置动态计算窗口时长)
        let windowDurationSecs = Float(bufferSize) / Float(config.sampleRate)
        speechFrameThreshold = Int(config.startSecs / windowDurationSecs)
        silenceFrameThreshold = Int(config.stopSecs / windowDurationSecs)

        guard let modelPath = Bundle.main.path(forResource: "silero_vad", ofType: "onnx") else {
            fatalError("SileroVAD: Model file not found in bundle")
        }

        do {
            let env = try ORTEnv(loggingLevel: .warning)
            let sessionOptions = try ORTSessionOptions()

            // 性能优化配置
            try sessionOptions.setGraphOptimizationLevel(.all)
            try sessionOptions.setIntraOpNumThreads(Int32(ProcessInfo.processInfo.processorCount))

            // 尝试启用Core ML硬件加速
            do {
                let coreMLOptions = ORTCoreMLExecutionProviderOptions()
                try sessionOptions.appendCoreMLExecutionProvider(with: coreMLOptions)
                print("SileroVAD: Using Core ML Execution Provider (Neural Engine/NPU)")
            } catch {
                print("SileroVAD: Using optimized CPU execution with \(ProcessInfo.processInfo.processorCount) cores")
            }

            session = try ORTSession(env: env, modelPath: modelPath, sessionOptions: sessionOptions)

        } catch {
            fatalError("SileroVAD: Failed to create ONNX session: \(error)")
        }

        // 初始化RNN状态 (shape: 2, 1, 128)
        let stateData = Array(repeating: Float(0.0), count: 2 * 1 * 128)
        do {
            state = try ORTValue(tensorData: NSMutableData(data: Data(bytes: stateData, count: stateData.count * 4)),
                                 elementType: .float,
                                 shape: [2, 1, 128])
        } catch {
            fatalError("SileroVAD: Failed to create initial state tensor: \(error)")
        }
    }

    /// 输入音频样本,自动处理状态检测
    public func feed(_ samples: [Float]) {
        sampleBuffer.append(contentsOf: samples)

        // 当有足够样本时自动检测
        while sampleBuffer.count >= bufferSize {
            if let probability = performDetection() {
                updateVADState(probability: probability)
            }
        }
    }

    /// 重置内部状态机 & RNN 隐状态
    public func reset() {
        // 重置状态机
        vadState = .silence
        speechFrameCount = 0
        silenceFrameCount = 0
        lastProbability = 0.0

        // 清空缓冲区
        sampleBuffer.removeAll()

        // 重置RNN状态
        let stateData = Array(repeating: Float(0.0), count: 2 * 1 * 128)
        do {
            state = try ORTValue(tensorData: NSMutableData(data: Data(bytes: stateData, count: stateData.count * 4)),
                                 elementType: .float,
                                 shape: [2, 1, 128])
        } catch {
            print("SileroVAD: Failed to reset state tensor: \(error)")
        }
    }

    // MARK: - 私有方法

    private func performDetection() -> Float? {
        guard sampleBuffer.count >= bufferSize else {
            return nil
        }

        // 取出一个窗口的样本
        let vadInput = Array(sampleBuffer.prefix(bufferSize))
        sampleBuffer.removeFirst(bufferSize)

        do {
            let probability = try runInference(audioData: vadInput)
            lastProbability = probability
            return probability
        } catch {
            print("SileroVAD: Detection error: \(error)")
            return nil
        }
    }

    private func runInference(audioData: [Float]) throws -> Float {
        guard audioData.count == 512 else {
            throw VADError.invalidAudioFormat("Audio data must be exactly 512 samples")
        }

        // 创建输入张量
        let inputTensor = try ORTValue(
            tensorData: NSMutableData(data: Data(bytes: audioData, count: audioData.count * 4)),
            elementType: .float,
            shape: [1, 512]
        )

        // 创建采样率张量
        var srData = Int64(config.sampleRate)
        let srTensor = try ORTValue(
            tensorData: NSMutableData(data: Data(bytes: &srData, count: 8)),
            elementType: .int64,
            shape: [1]
        )

        // 准备输入
        let inputs: [String: ORTValue] = [
            "input": inputTensor,
            "state": state,
            "sr": srTensor,
        ]

        // 执行推理
        let allOutputNames = try session.outputNames()
        let outputs = try session.run(withInputs: inputs, outputNames: Set(allOutputNames), runOptions: nil)

        // 提取结果
        guard let outputTensor = outputs["output"] else {
            throw VADError.inferenceError("Missing 'output' tensor")
        }

        guard let newStateTensor = outputs["stateN"] else {
            throw VADError.inferenceError("Missing 'stateN' tensor")
        }

        // 更新状态
        state = newStateTensor

        // 提取概率值
        let tensorData = try outputTensor.tensorData() as Data
        let probability = tensorData.withUnsafeBytes { bytes in
            bytes.load(as: Float.self)
        }

        return probability
    }

    private func updateVADState(probability: Float) {
        let isHighProbability = probability >= config.threshold
        let isLowProbability = probability <= config.negThreshold

        switch vadState {
        case .silence:
            if isHighProbability {
                vadState = .speechCandidate
                speechFrameCount = 1
                silenceFrameCount = 0
            }

        case .speechCandidate:
            if isHighProbability {
                speechFrameCount += 1
                if speechFrameCount >= speechFrameThreshold {
                    vadState = .speech
                    delegate?.vadDidStartSpeech(probability: probability)
                }
            } else {
                vadState = .silence
                speechFrameCount = 0
            }

        case .speech:
            if isLowProbability {
                vadState = .silenceCandidate
                silenceFrameCount = 1
                speechFrameCount = 0
            } else if isHighProbability {
                // 继续说话,重置静音计数
                silenceFrameCount = 0
            }

        case .silenceCandidate:
            if isLowProbability {
                silenceFrameCount += 1
                if silenceFrameCount >= silenceFrameThreshold {
                    vadState = .silence
                    delegate?.vadDidEndSpeech(probability: probability)
                }
            } else if isHighProbability {
                vadState = .speech
                silenceFrameCount = 0
            }
        }
    }
}

要下载模型silero_vad.onnx丢进项目。

当然这个代码也是Claude帮我写的。

Swift 的多平台策略,需要我们大家一起来建设 | 肘子的 Swift 周报 #091

issue91.webp

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

Swift 的多平台策略,需要我们大家一起来建设

继 2025 年 2 月 Swift 社区论坛发布关于启动 Android Community Workgroup 的消息数月后,Swift.org 于上周正式宣布成立官方 Android 工作组。这标志着由官方主导的 Swift 安卓平台支持正式启动,未来 Swift 开发者有望获得更完善的安卓适配工具链与开发体验。

不过,在欣喜之余,我们也应正视一个现实:对于绝大多数 Swift 开发者来说,长期以来的开发工作深度依赖苹果生态,日常所用 API 多与系统框架强耦合。尽管 Swift 社区和苹果已着手推进 Foundation 的纯 Swift 化改造,并陆续提供更多跨平台基础库,但这距离满足实际跨平台开发的需求仍有相当差距。

不久前,Swift Package Index 在原有对苹果平台和 Linux 的兼容性标识基础上,新增了对 Android 与 Wasm 平台的支持,侧面反映出社区对多平台适配的重视。我也借此机会让自己的两个库完成了对 Linux 的兼容。不过在适配过程中也深刻体会到,目前还缺乏一个便捷、统一的跨平台开发环境。虽然这两个库的适配较为简单,仅通过 GitHub Actions 就完成了编译测试和修复,但若将来需要支持更多平台,社区能否构建一个便利、安全的适配机制将变得至关重要。

近年来,Swift 在多平台战略上的推进明显提速,但若想真正成为跨平台开发者的主流选择,仅靠官方与苹果的努力还远远不够。我们每一位 Swift 开发者的参与同样不可或缺。Swift 越强大,Swift 开发者越受益。Swift 的多平台生态,需要我们共同建设!

前一期内容全部周报列表

原创

NotificationCenter.Message:Swift 6.2 并发安全通知的全新体验

NotificationCenter 作为 iOS 开发中的经典组件,为开发者提供了灵活的广播——订阅机制。然而,随着 Swift 并发模型的不断演进,传统基于字符串标识和 userInfo 字典的通知方式暴露出了诸多问题。为了彻底解决这些痛点,Swift 6.2 在 Foundation 中引入了全新的并发安全通知协议:NotificationCenter.MainActorMessageNotificationCenter.AsyncMessage。它们充分利用 Swift 的类型系统和并发隔离特性,让消息的发布与订阅在编译期就能得到验证,从根本上杜绝了“线程冲突”和“数据类型错误”等常见问题。

近期推荐

Xcode Coding Intelligence 逆向解析简报 (Reverse-Engineering Xcode's Coding Intelligence Prompt)

在 Xcode 26 中,苹果正式推出了备受期待的 AI 编码助手 —— Coding Intelligence。相较于市面上已有的 AI 编程工具,苹果在系统提示词(system prompt)的设计上是否有自己的哲学?Peter Friese 借助 Proxyman 对其进行了深入逆向分析。通过这些解析出的提示词内容,我们不仅可以了解 Coding Intelligence 的工作机制,也能窥见苹果对现代开发实践的倾向性,比如:强烈推荐使用 Swift Concurrency(async/await、actor)而非 Combine,测试建议使用 Swift Testing 框架与宏。这些设计细节,是苹果开发范式的重要指标。


SwiftUI 设计系统中的语义颜色设计 (SwiftUI Design System Considerations: Semantic Colors)

在构建 SwiftUI 设计系统 API 时,如何优雅地处理 语义颜色(Semantic Colors) 始终是一个令人头疼的问题。Magnus Jensen 在本文中系统梳理了常见方案的优缺点,并提出了一种基于宏(macro)的解决路径,力求实现 可读性强、类型安全、上下文感知 的色彩系统。如果你正打算为自己的 SwiftUI 项目设计一套结构清晰、可维护的风格体系,这篇文章值得一读。


iOS 内存效率指南系列 (Memory Efficiency in iOS)

随着项目复杂度的提升,开发者终将面对内存相关的问题:内存泄漏、系统警告,甚至因资源占用过高被系统强制终止。在这种情况下,如何诊断问题、控制内存占用,是对开发者经验与体系理解的深度考验。Anton Gubarenko 在两篇文章(内存优化篇)中,系统梳理了 iOS 应用内存使用的评估方式、诊断工具以及优化手段,构建出一套完整、实用的内存管理知识体系。


What is @concurrent in Swift 6.2?

从 Swift 最近的几个版本更新和 Xcode 26 的表现可以看出,Swift 团队正有意识地优化并发编程的开发体验。通过启用新的默认行为,开发者无需在一开始就理解所有细节,便能写出更安全的并发代码。@concurrent 的引入,正是这一策略下的产物之一。在 Donny Wals 的这篇文章中,他详细介绍了 @concurrent 的背景与用途。简单来说,@concurrent 是 Swift 6.2 引入的显式并发标记,主要用于在启用 NonIsolatedNonSendingByDefault 特性时,明确指定函数运行在全局执行器上,从而在需要时将工作负载转移到后台线程,避免阻塞调用者所在的 actor(如主线程)。

或许有人会质疑 Swift 是否又在“用新关键字补旧洞”,但从语言设计趋势来看,随着并发模型逐步完善,许多旧关键字的使用将逐渐被默认机制吸收、简化甚至隐藏。


Swift 与 Java 互操作 (Swift 6.2 Java interoperability in Practice)

Swift 与 Java 的互操作并非新鲜事物,但过往的解决方案往往过程复杂且容易出错。Swift 6.2 引入的 swift-java 包具有划时代意义——这是首次提供官方支持、与工具链深度集成、开发体验接近一等公民的互操作方案,标志着 Swift 和 Java 之间真正意义上的“无缝互通”正式到来。Artur Gruchała 通过一个完整的示例项目,详细演示了如何从 Swift 端调用 Java 方法、构建双语言协作的 CLI 应用,并深入分析了实际开发中容易踩坑的关键细节——特别是 classpath 配置等看似简单却至关重要的环节。


Kodeco 教程:迁移到 Swift 6 (Migrating to Swift 6 Tutorial)

Swift 6 引入了更严格的并发规则与更加结构化的编程范式。在迁移过程中,理解隔离域、Sendable 类型、默认行为,以及 @concurrent 的使用变得尤为重要。Audrey Tam 通过一个完整的 SwiftUI 示例项目(附项目源码),系统演示了从 Swift 5 迁移至 Swift 6.2 的全过程,涵盖 Xcode 设置、并发语义调整与数据隔离等核心环节,是一篇很具实用价值的迁移教程。


Modern Concurrency - Swift 6.2 Suite of Examples

如何在 async/await 中实现类似 Combine 的 throttle 操作?如何持续追踪 @Observable 属性的变化?如何构建支持多消费者的异步流?Lucas van Dongen 在这个开源项目中给出了系统性的实践示例。他汇集了 Swift 6.2 并发模型下的多种模式,演示了如何在实际项目中逐步替代 Combine,迁移到更现代、类型安全的并发范式。


是否升级应用的最低支持版本?(Considerations for New iOS Versions)

WWDC 25 中 Liquid Glass 的登场令人惊艳,但要同时支持两种视觉风格,对开发资源是一大考验。这也让很多开发者开始思考是否应放弃对旧系统的支持。David Smith 建议从两个角度判断:现有用户影响新用户流失。以他的 Widgetsmith 应用为例,当前仍有约 9% 的新增用户来自旧系统,一旦抬高最低支持版本将直接失去这部分潜在用户。他认为,只有当旧系统用户占比降至个位数时,再做版本升级才更合理——简化技术负担,不应以牺牲业务增长为代价

活动

AdventureX 25 游客指南

AdventureX 25 将于 2025 年 7 月 23 日至 27 日在杭州市湖畔创研中心与未来科技城学术交流中心举行。本指南包含活动行程介绍、参与方式、群聊福利、出行与住宿建议及注意事项等内容。不论你是来逛展、互动,还是寻找志同道合的伙伴,这份指南都将帮助你轻松规划行程~

往期内容

THANK YOU

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

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

Re: 0x01. 从零开始的光线追踪实现-光线、相机及背景

目标

书接上文,之前已经实现一个铺满整个窗口的红色填充,这趟来实现光线、相机及背景。

本节最终效果

image.png

计算物体在窗口坐标的位置

其实这个光追的思维模式很简单,就是从相机处开始发射一束射线,射线撞到哪些“物体”,就计算跟该“物体”相交的颜色。如图所示,从相机处发射射线,以左上角开始逐像素扫一遍,计算对应像素的颜色

fig-04-camera-view-space.svg

我们再看看 viewport 的坐标,假设一个窗口大小是 480272480 * 272(没错,PSP 的分辨率😁)的宽高,那么 xx 的区间就是 [0,479)[0, 479)yy 的区间就是 [0,271)[0, 271)

fig-03-viewport-coords.svg

现在我们要来处理一个标准化的像素坐标,处理像素在屏幕中的 2D 位置

struct Vertex {
  float4 position [[position]];
};

fragment float4 fragmentFn(Vertex in [[stage_in]]) {
  auto uv = in.position.xy / float2(float(480 - 1), float(272 - 1));
  // ...
}

上面这一步的作用是把像素级的屏幕坐标转成区间 [0,1][0, 1] 的归一化坐标。
假设现在有一个物体,它的坐标是 (240,135)(240, 135),通过上面的计算式子可以得出
uv=(240/479,135/271)(0.5,0.5)uv = (240 / 479, 135 / 271) ≈ (0.5, 0.5),说明它在屏幕的中间

接着我们假定相机的位置是原点 (0,0,0)(0, 0, 0),相机距离 viewport 11。我们计算出宽高的比例再套进这个计算 (2 * uv - float2(1)),等于讲把 [0,1][0, 1] 映射成 [1,1][-1, 1] 的范围,其实就是

原始 uv 变换后
(0, 0) (-1, -1) 左下角
(1, 0) (1, -1) 右下角
(0.5, 0.5) (0, 0) 居中
(1, 1) (1, 1) 右上角

再把 (2 * uv - float2(1))float2(aspect_ratio, -1) 相乘等于讲横向乘以 aspect_ratio 用来做等比例变换

至于纵向乘以 -1,那是因为在 Metal 中,yy 轴是向下为正,乘一下 -1 就可以把 yy 轴翻转变成向上为正,接下来计算方向就简单多了,因为 zz 轴面向相机,其实就是相机距离取反,上面假定相机距离为 1,所以取反再跟 uvuv 放一块就是方向,同时我们又假定相机的位置是原点 (0,0,0)(0, 0, 0),那么求光线就很容易了

struct Ray {
  float3 origin;
  float3 direction;
};

fragment float4 fragmentFn(Vertex in [[stage_in]]) {
  // ...
  const auto focus_distance = 1.0;
  // ...
  const auto direction = float3(uv, -focus_distance);
  Ray ray = { origin, direction };
}

现在既然有了光线,再就是要计算一下光线的颜色,因为目前场景中没有物体,所以就默认计算背景色,我们先把光线从 [1,1][-1, 1] 映射回 [0,1][0, 1],然后再线性插值计算渐变天空颜色,所以先要让光线经过归一化操作到 [1,1][-1, 1]

// [-1, 1]
normalize(ray.direction)

然后再给该向量加 11

// [-1, 1] + 1 = [0, 2]
normalize(ray.direction) + 1

然后把 [0,2][0, 2] 乘以 0.50.5 就转成 [0,1][0, 1] 了,之后再代入线性插值公式计算结果,具体渐变色值可以根据自己的需求调整,我这里直接使用 Ray Tracing in One Weekend 的色值 float3(0.5, 0.7, 1)

blendedValue=(1a)startValue+aendValueblendedValue = (1 − a) \cdot startValue + a \cdot endValue

float3 sky_color(Ray ray) {
  const auto a = 0.5 * (normalize(ray.direction).y + 1);
  return (1 - a) * float3(1) + a * float3(0.5, 0.7, 1);
}

最后总结一下代码

struct Ray {
  float3 origin;
  float3 direction;
};

float3 sky_color(Ray ray) {
  const auto a = 0.5 * (normalize(ray.direction).y + 1);
  return (1 - a) * float3(1) + a * float3(0.5, 0.7, 1);
}

fragment float4 fragmentFn(Vertex in [[stage_in]]) {
  const auto origin = float3(0);
  const auto focus_distance = 1.0;
  const auto aspect_ratio = 480 / 272;
  auto uv = in.position.xy / float2(float(480 - 1), float(272 - 1));
  uv = (2 * uv - float2(1)) * float2(aspect_ratio, -1);
  const auto direction = float3(uv, -focus_distance);
  Ray ray = { origin, direction };
  return float4(sky_color(ray), 1);
}

Swift底层原理学习笔记

笔记主要记录Swift和OC底层原理差异的地方,OC的底层原理之前的笔记有详细记录。

课程是逻辑教育的,视频基本只看了总结部分,然后结合网上已有笔记进行的重点梳理。

Swift 进阶一:类,对象,属性

类、对象

  • Swift对象的内存结构HeapObject,有两个属性:一个是Metadata,一个是Refcount,默认占用16字节大小,就是对象中没有任何东西也是16字节。
  • OC中实例对象的本质是结构体,是以objc_object为模板继承的,其中有一个isa指针,占8字节。
  • Swift比OC中多了一个refCounted引用计数大小,也就是多了8字节。

  • getClassObject函数是根据kind获取object类型
  • 如果kind(理解为isa指针)是Class类型,则将当前的Metadata强转成ClassMetadata,而ClassMetadataTargetClassMetadata根据类型的别名,其中TargetClassMetadata结构: image.png
  • TargetClassMetadata继承自TargetAnyClassMetadata image.png
  • 结构与OC中的objc_class的结构一样,有isa,有父类,有cacheDataData类似于objc_class中的bits
  • 根据上面分析我们可以得到结论:当metadatakindClass时,有如下的继承关系: image.png

  • 总结,swift的类内存结构可以理解为:
struct Metadata {
    void *kind;               // 类型标识(如类、结构体、枚举)
    void *superClass;         // 父类的 Metadata 指针
    void *cacheData;          // 方法缓存(类似 OC 的 cache_t)
    void *data;               // 指向额外数据的指针
    
    // 实例布局信息
    uint32_t flags;                   // 类型标志位
    uint32_t instanceAddressOffset;   // 实例变量的起始偏移量
    uint32_t instanceSize;            // 实例对象占用的内存大小
    uint16_t instanceAlignMask;       // 实例的对齐掩码
    uint16_t reserved;                // 保留字段
    
    // 类布局信息
    uint32_t classSize;               // 类对象占用的内存大小
    uint32_t classAddressOffset;      // 类变量的起始偏移量
    void *description;                // 类型描述信息
    
    
    // 类对象与元类对象的关键区别
    uint32_t flags;       // 包含类型标志位(如是否为元类)
    void *vtable;         // 类对象的 vtable 指向实例方法表
    void *classVtable;    // 元类对象的 vtable 指向类方法表
    
    // 方法签名表(所有方法),存放在类对象中
    MethodDescriptor* methodDescriptors;
    uint32_t methodCount;
}

// 例子:
class MyClass { 
    func instanceMethod() {} // 实例方法 
    static func classMethod() {} // 类方法 
} 
// 实例方法调用(通过类对象的 vtable) 
let obj = MyClass() obj.instanceMethod() // 类对象 → Metadata → vtable → 方法实现 

// 类方法调用(通过元类对象的 classVtable) 
MyClass.classMethod() // 类对象 → 元类对象 → Metadata → classVtable → 方法实现


属性

    1. 存储属性:有常量存储属性和变量存储属性两种,都占用内存
    1. 计算属性:不占用内存,本质为函数。
    1. 属性观察者
      1. 属性观察可以添加在类的存储属性继承的存储属性继承的计算属性
      1. 父类在调用init中改变属性值不会触发属性观察,子类调用父类的init触发属性观察
      1. 统一属性在父类和子类都添加观察,在触发观察时:
      • willSet方法,先子类后父类
      • didSet方法,先父类后子类
    1. 延迟属性(lazy) :延迟属性必须有初始(可以为nil),只有在访问后内存中才有值,延迟属性对内存有影响,不能保证线程安全
    1. 类型属性:类型属性必须有初始值,内存只分配一次,通过swift_once函数创建,类似dispatch_once,是线程安全的。

    • 可以用于单例:
    class XXX {
      static let share: XXX = XXX()
      private init(){}
    }
    

Swift 进阶二:值类型、引用类型、结构体

结构体,值类型

struct WSPerson { 
    var age: Int = 18 
} 
    
struct WSTeacher { 
    var age: Int 
}

image.png

  • 结构体会自动创建为所有参数赋值的构造函数。
  • 结构体开辟的内存在栈区
  • 结构体的赋值是深拷贝,并且有写时复制的机制。

结构体的属性修改问题

  • 结构体对象self类型为let,即不可以被修改。
  • 结构体中函数修改属性, 需要在函数前添加mutating关键字,本质是给函数的默认参数self添加了inout关键字,将selflet常量改成了var变量。
  • mutating方法修改结构体属性时,采用的是 "in-place" 的方式,也就是直接在当前实例的内存空间里修改属性值,并没有重新创建一个新的实例来替换原来的实例。这一特性和赋值操作有着本质的区别。

结构体的函数调用

  • 值类型对象的函数的调用方式是静态调用,即直接地址调用,调用函数指针,这个函数指针在编译、链接完成后就已经确定了,存放在代码段,而结构体内部并不存放方法。因此可以直接通过地址直接调用 image.png 这个符号哪里来的?

  • 是从Mach-O文件中的符号表Symbol Tables,但是符号表中并不存储字符串,字符串存储在String Table(字符串表,存放了所有的变量名和函数名,以字符串形式存储),然后根据符号表中的偏移值到字符串中查找对应的字符,然后进行命名重整:工程名+类名+函数名

方法重载问题

  • Objective-C里,方法重载是不被支持的,不过Swift却支持,这主要是由它们不同的函数签名机制和语言设计理念造成的。
  1. 函数签名机制
  • Objective - C:它的函数签名只依据方法名,和参数类型没有关系。 比如下面这两个方法,在OC看来是一样的,所以无法共存:
- (void)doSomethingWithInt:(int)value; 
- (void)doSomethingWithInt:(NSString *)value; 
  • Swift:它的函数签名是由方法名和参数类型共同组成的。 下面这样的重载在Swift中是被允许的:
func doSomething(value: Int) 
func doSomething(value: String) 

2. 消息传递机制

  • OC:采用的是运行时消息传递机制,方法调用是通过字符串(SEL)来实现的。 像[obj doSomethingWithInt:1]这样的调用,在运行时会被解析为SEL @selector(doSomethingWithInt:),要是有多个同名方法,就会引发冲突。
  • Swift:使用的是静态 dispatch 机制,在编译时就会确定具体要调用哪个方法。
  1. 补充说明
  • Swift 的重载:除了参数类型不同可以重载外,参数数量不同或者参数标签不同也能实现重载。
  • OC 的替代方案:在OC中,如果要实现类似功能,通常会采用命名约定,例如doSomethingWithInt:doSomethingWithString:

总结来说,Swift支持方法重载是其类型系统和编译时检查机制的自然结果,而OC不支持则是受限于其动态特性和历史设计。

Swift 进阶三:内存分区、方法调度、指针

方法调度

  • Swift 类的方法(非final、非static、非@objc修饰的)会被存放在一个名为 vtable 的表中。
  • 只有类能够使用 vtable,结构体和枚举由于不支持继承,所以没有 vtable
内存布局示例:
[实例对象内存]
  ├ isa 指针 ───→ [类对象]
                ├ Metadata 指针 ───→ [Metadata]
                │                  └ vtable 指针 ───→ [vtable 内存区域]
                │                                      ├ 0: init()
                │                                      ├ 1: method1()
                │                                      └ 2: method2()
                └ 其他类数据...
  • 方法调用时的流程,当调用一个类的实例方法时,Swift 运行时会:
    1. 通过实例的 isa 指针找到类对象。
    2. 从类对象中获取 Metadata 指针。
    3. 从 Metadata 中读取 vtable 指针。
    4. 根据方法在 vtable 中的索引,调用对应的函数实现。
  • vtable 仅存储可重写的方法,而类的所有方法(包括不可重写的)仍通过元数据(Metadata)管理。
  • 元类对象(Metaclass Object)的 Metadata 主要存储类方法(static/class 方法)的实现信息。
  • 协议方法的签名和实现由 Witness Table 管理,与类对象 / 元类对象的 Metadata 是分离的。
方法调用总结
  • struct值类型,它的函数调度是直接调用,即静态调度
    • 值类型在函数中如果要修改实例变量的值,则函数前面需要添加Mutating修饰
  • class引用类型,它的函数调度是通过vtable函数,即动态调度
  • extension中的函数是直接调用,即静态调度
  • final修饰的函数是直接调用,即静态调度
  • @objc修饰的函数是methodList函数表调度,如果方法需要在OC中使用,则类需要继承NSObject
  • dynamic修饰的函数调度方式是methodList函数表调度,它是动态可以修改的,可以进行method-swizzling
    • @objc+dynami修饰的函数是通过objc_msgSend来调用的
  • 如果函数中的参数想要被更改,则需要在参数的类型前面增加inout关键字,调用时需要传入参数的地址

Swift 进阶四:弱引用、闭包、元类型

Swift 内存管理

  • swift实例对象的内存中,存在一个Metadata,一个Refcount。后者记录引用计数。
  • Refcount最终可以获得64位整型数组bits,其结构: image.png
// 简化的 Refcount 结构(实际实现可能更复杂)
struct Refcount {
    // 64 位中的高 32 位:强引用计数
    uint32_t strongRefCount: 32;
    
    // 64 位中的低 32 位:
    uint32_t hasWeakRefs: 1;      // 是否有弱引用
    uint32_t hasUnownedRefs: 1;   // 是否有 unowned 引用
    uint32_t isDeiniting: 1;      // 是否正在析构
    uint32_t sideTableMask: 1;    // 是否使用 Side Table
    uint32_t weakRefCount: 28;    // 弱引用计数
};
  • 当引用计数超出直接存储范围时,通过 sideTableMask 标志切换到全局 Side Table 存储。
  • Swift在创建实例对象时的默认引用计数是1,而OCalloc创建对象时是没有引用计数的。

弱引用

  • 为对象增加弱引用时,实际是调用refCounts.formWeakReference,即去操作sideTable表,添加对象的弱引用关系,这里和OC处理是一致的。

swift中的runtime

  • 对于纯swift类来说,没有动态特性dynamic(因为swift是静态语言),方法和属性不加任何修饰符的情况下,已经不具备runtime特性,此时的方法调度,依旧是函数表调度即V_Table调度。
  • 对于纯swift类,方法和属性添加@objc标识的情况下,可以通过runtime API获取到,但是在OC中是无法进行调度的,原因是因为swift.h文件中没有swift类的声明。
  • 对于继承自NSObject类来说,如果想要动态的获取当前属性+方法,必须在其声明前添加@objc关键字,如果想要使用方法交换,还必须在属性+方法前添加dynamic关键字,否则当前属性+方法只是暴露给OC使用,而不具备任何动态特性。

补充

  • Any:任意类型,包括function类型、optional类型
  • AnyObject:任意类的instance、类的类型、仅类遵守的协议,可以看作是Any的子类
  • AnyClass:任意实例类型,类型是AnyObject.Type
  • T.self:如果T是实例对象,则表示它本身,如果是类,则表示metadata.T.self的类型是T.Type

深度解析!Apple App Site Association 文件背后的秘密和配置攻略

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

前言

image.png

在 iOS 生态系统中,Apple App Site Association(AASA)文件扮演着至关重要的角色。它通过在你的 iOS 应用和网络域之间建立安全且经过验证的链接,实现了诸如通用链接(Universal Links)、共享网络凭证、Handoff 和 App Clips 等功能。

不知道你有没有注意过,当你在手机浏览器上访问知乎、小红书或 YouTube 时,有些链接会让你继续留在浏览器中,而另一些则会直接跳转到对应的应用中?这背后的驱动力正是 AASA 文件。

AASA 文件的创建

Apple App Site Association 文件是一个配置文件,它定义了 URL 的处理方式,并指定它们是打开在浏览器中还是直接链接到应用程序内的内容。

以下是一个基本示例:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "ABCDE12345.com.example.app",
        "paths": [
          "/",
          "/about-us/",
          "/products/*",
          "/services/detail/?id=*",
          "/news/article/*",
          "/promo/*",
          "NOT /private/",
          "NOT /settings/*",
          "*.html"
        ]
      }
    ]
  },
  "webcredentials": {
    "apps": ["ABCDE12345.com.example.app"]
  },
  "appclips": {
    "apps": ["ABCDE12345.com.example.app"]
  }
}

访问下边这些路径的请求都会将用户路由到应用程序中:

"/",
"/about-us/",
"/products/*",
"/services/detail/?id=*",
"/news/article/*",
...

而对于那些以 NOT 前缀的路径,系统将不会重新路由用户,允许体验继续在浏览器中进行:

"NOT /private/",
"NOT /settings/*",
...

部署 AASA 文件

当你准备好部署 AASA 文件时,需要将其托管在网站的根目录或 .well-known 目录下:

  • /apple-app-site-association

  • /.well-known/apple-app-site-association

注意:AASA 文件不应有文件扩展名。为了让内容分发网络(CDN)成功缓存此文件,它必须托管在可供所有 IP 地址和范围访问的域上,通过 HTTPS 提供文件,不重定向,并且不被访问策略阻止。

你还应该确保服务器以 Content-Type: application/json 的形式提供文件,并且文件大小不超过 128KB。

当你首次托管 AASA 文件时,苹果的 CDN 将在 24 小时内获取它,这意味着它们将在文件发布的第一天内从你的服务器请求并缓存一份副本。

根据苹果文档的说法,这个 24 小时的窗口仅与首次将文件放入苹果的 CDN 有关。随后的更新会在不同的时间间隔发生。

其实我们可以通过下边的链接看下其他公司的 AASA 文件是如何写的:

测试与验证

随着应用程序的迭代,你将需要修订 AASA 文件,以便为新的用户流程整合通用链接(Universal Link)。

AASA 文件中的错误很常见,因此验证此文件的行为和语法成为流程中不可或缺的一部分。一旦被苹果的 CDN 确认,此文件中的任何错误都可能影响所有用户的通用链接行为——无论是新用户还是老用户。

幸运的是,在更改到达苹果之前,有几种方法可以验证 AASA 文件的行为。

绕过 CDN

关联域支持一种备用模式,允许开发者通过绕过 CDN 直接从服务器获取文件,以验证修改后的 AASA 文件的行为。

要在 Xcode 项目中激活此模式:

  • 前往 Signing & Capabilities -> Associated Domains

  • 在域条目的末尾添加 ?mode=developer(例如:applinks:yourdomain.com?mode=developer

现在,当你构建并运行应用程序时,它将直接从你的服务器检索更新的 AASA 文件。

在开发期间只能使用备用模式,你必须在将应用程序提交到 App Store 之前从关联域中删除后面这段字符串。

验证 AASA 文件配置

你可以使用以下工具来验证 AASA 文件的语法和配置:

  • Universal Link & Apple App Site Association Testing Tool[1]

这是一个免费的工具,用于验证和测试 Apple App Site Association(AASA)文件。确保你的通用链接配置正确,通过简单的链接创建、实时测试和团队协作功能简化 AASA 文件的故障排除。

官方查询方式

你可以通过访问以下网址检查苹果的 CDN 是否已获取文件的最新版本:

  • https://app-site-association.cdn-apple.com/a/v1/{YOUR_DOMAIN_HERE}

如果 CDN 确实具有文件的最新版本,那么任何新应用安装也将获得此最新版本。但是对于老用户,他们的设备每周只会检查一次更新的副本。

重新安装应用程序将从 CDN 获取最新版本。

实际上,AASA 文件的任何更改的推出周期是 CDN 刷新其缓存所需的时间与现有用户每周检查的时间相结合。

缓存更新时间

为了更好地理解缓存版本更新前剩余的时间,我们可以查看来自 CDN 的响应头中的 Cache-Control 字段。

Cache-Control 头由网络服务器使用,以决定浏览器和中间缓存(如 CDN)应该如何以及在多长时间内缓存文件的提供版本。

在 Facebook 和 Yelp 上,我们可以通过以下链接查看:

  • https://app-site-association.cdn-apple.com/a/v1/facebook.com

  • https://app-site-association.cdn-apple.com/a/v1/yelp.com

然后我们可以通过从 max-age 中减去 age 来确定缓存何时更新:

image.png

image.png

  • max-age:6 小时,缓存刷新时间为 17,760 秒 / 约 5 小时。

  • max-age:1 小时,缓存刷新时间为 3,112 秒 / 约 50 分钟。

在实际情况中,最短的 max-age 是 3,600 秒,即 1 小时,最长的是 21,600 秒,即 6 小时。

值得注意的是,苹果的 CDN 会覆盖原始网站指定的 Cache-Control 设置。例如,直接从 Yelp 访问 AASA 文件的 max-age 为 1200,但从苹果的 CDN 检索时,其 max-age 为 3600。

通过更长时间的缓存文件,苹果能够最大限度地减少对原始服务器的请求频率,从而减少网络和原始服务器的流量和负载。

总结

AASA 文件是 iOS 生态系统中一个非常重要的组件,它通过在 iOS 应用和网络域之间建立安全且经过验证的链接,实现了诸如通用链接(Universal Links)、共享网络凭证、Handoff 和 App Clips 等功能。

通过了解 AASA 文件的创建、部署、测试与验证,开发者可以更好地优化其应用的链接配置,提升用户体验,并确保应用的稳定性和可靠性。

参考资料

[1]

Universal Link & Apple App Site Association Testing Tool: getuniversal.link/?ref=digita…

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

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

Swift 的多平台策略,需要我们大家一起来建设 - 肘子的 Swift 周报 #91

继 2025 年 2 月 Swift 社区论坛发布关于启动 Android Community Workgroup 的消息数月后,Swift.org 于上周正式宣布成立官方 Android 工作组。这标志着由官方主导的 Swift 安卓平台支持正式启动,未来 Swift 开发者有望获得更完善的安卓适配工具链与开发体验。

❌