普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月27日iOS

iOS底层原理:KVC分析

2025年10月27日 17:22

KVC是什么

KVC全称**Key-Value Coding,俗称键值编码。它是一种通过字符串描述符而不是通过调用访问方法或者直接使用实例变量的非直接的访问对象属性的机制。在iOS中,NSObject、NSArray、NSDictionary**等类使用这种机制并采用分类的形式为自身拓展了KVC的能力。常用的Api如下:

- (void)setValue:(nullable id)value forKey:(NSString *)key;
- (nullable id)valueForKey:(NSString *)key;

点方法进去:

1.png

2.png

3.png

我们可以看到,它是在 Foundation 框架下,关于**NSObject、NSArray、NSDictionary等的一个NSKeyValueCoding**的分类。其实这些内容在苹果的官方文档中解释的很清楚,小伙伴们可以自行查阅。Key-Value Coding Programming Guide

KVC设值和取值

KVC的使用我们都不陌生,那么KVC在内部操作时是以怎样的顺序来寻找key的呢?接下来我们就来探索一下。

取值 valueForKey:

先看下苹果官方文档的Setter步骤:

4.png

5.png

国际惯例,翻译一下:

  1. 在实例中搜索找到的第一个名称为get<Key><key>is<Key>、或的访问器方法_<key>,按该顺序。如果找到,则调用它并使用结果继续执行步骤 5。否则继续下一步。

  2. 如果没有找到简单的访问器方法,则在实例中搜索名称与模式countOf<Key>objectIn<Key>AtIndex:(对应于NSArray类定义的原始方法)和<key>AtIndexes:(对应于NSArray方法objectsAtIndexes:)的方法。 如果找到这些中的第一个和其他两个中的至少一个,则创建一个响应所有NSArray方法的集合代理对象并返回该对象。否则,继续执行步骤 3。 代理对象随后将任何NSArray接收到的一些组合的消息countOf<Key>objectIn<Key>AtIndex:<key>AtIndexes:消息给键-值编码创建它兼容的对象。如果原始对象还实现了一个可选的方法,其名称类似于get<Key>:range:,则代理对象也会在适当的时候使用它。实际上,与键值编码兼容的对象一起工作的代理对象允许底层属性表现得好像它是NSArray,即使它不是。

  3. 如果没有找到简单的访问方法或阵列访问方法组,寻找一个三重的方法命名countOf<Key>enumeratorOf<Key>和memberOf<Key>:(对应于由所定义的原始的方法NSSet类)。 如果找到所有三个方法,则创建一个响应所有NSSet方法的集合代理对象并返回该对象。否则,继续执行步骤 4。 此代理对象随后将任何NSSet接收到的一些组合信息countOf<Key>enumeratorOf<Key>memberOf<Key>:消息以创建它的对象。实际上,与键值编码兼容的对象一起工作的代理对象允许底层属性表现得好像它是NSSet,即使它不是。

  4. 如果发现收集的访问方法没有简单的存取方法或者组,如果接收器的类方法accessInstanceVariablesDirectly返回YES,搜索名为实例变量_<key>_is<Key><key>,或者is<Key>,按照这个顺序。如果找到,直接获取实例变量的值并进行步骤5,否则进行步骤6。

  5. 如果检索到的属性值是一个对象指针,只需返回结果即可。 如果该值是 支持的标量类型NSNumber,则将其存储在一个NSNumber实例中并返回该实例。 如果结果是NSNumber不支持的标量类型,则转换为NSValue对象并返回。

  6. 如果所有其他方法都失败,请调用valueForUndefinedKey:,默认情况下,这会引发异常,但 的子类NSObject可能会提供特定于键的行为。

这里推荐一个翻译工具DeepL,个人觉得还是挺准挺好用的。好了,废话不多说,我们顺着这个步骤来验证一下。

首先按照文档中的顺序**get<Key>** -> <key> -> is<Key> -> _<key> 进行调试验证即可,如图:

6.png

7.png

如果以上方法都没有,且**accessInstanceVariablesDirectly为YES时,则按照_<key>** -> _is<Key> -> <key> -> **is<Key>**取值,如图:

8.png

9.png

10.png

11.png

以上取值顺序依次注释代码进行验证即可,如果都没有的话,就会调用**valueForUndefinedKey:**这个方法,最终抛出异常。

设值 setValue:forKey:

先看下苹果官方文档的**Setter**步骤:

12.png

翻译如下:

  1. 按顺序寻找名为set<Key>:_set<Key>的第一个访问器。如果找到了,就用输入值(或根据需要解开的值)来调用它,然后完成。

  2. 如果没有找到简单的访问器,并且如果类方法accessInstanceVariablesDirectly返回YES,那么寻找一个名称为_<key>_is<Key><key>is<Key>的实例变量,依次进行。如果找到了,直接用输入值(或解开的值)设置变量,然后完成。

  3. 一旦发现没有访问器或实例变量,就调用setValue:forUndefinedKey:。这默认会引发一个异常,但NSObject的子类可以提供特定于键的行为。

首先来验证:set<Key>: ->_set<Key>:

13.png

14.png

15.png

从上面getter取值顺序我们可以看到有**get<Key>** -> <key> -> is<Key> -> _<key>四个方法,而此处的setter只有set<Key>:_set<Key>:两个方法。显然,对setter的第一步骤有所怀疑,那就探索一下,实现setIs<Key>_setIs<Key>,如图:

16.png

17.png

经过代码验证,这里确实会走**setIs<Key>:,而_setIs<Key>:**没有调用。所以苹果文档这里的描述并不全面。

当**set<Key>:** -> _set<Key>: ->setIs<Key>都没有实现的时候, 并且当accessInstanceVariablesDirectly设置为YES时 获取实例变量的顺序为顺序查找名称为_<key> -> _is<Key> -> <key> -> is<Key>,如下图:

18.png

19.png

20.png

21.png

根据上面的流程,梳理一张**setValue:forKey:**的流程图:

22.png

KVC自定义实现

清楚了KVC的设值和取值流程,我们就可以自定义一下它的实现过程,代码如下:

- (void)lg_setValue:(nullable id)value forKey:(NSString *)key{
   
    // KVC 自定义
    // 1: 判断什么 key
    if (key == nil || key.length == 0) {
        return;
    }
    
    // 2: setter set<Key>: or _set<Key>,
    // key 要大写
    NSString *Key = key.capitalizedString;
    // 拼接方法
    NSString *setKey = [NSString stringWithFormat:@"set%@:",Key];
    NSString *_setKey = [NSString stringWithFormat:@"_set%@:",Key];
    NSString *setIsKey = [NSString stringWithFormat:@"setIs%@:",Key];
    
    if ([self lg_performSelectorWithMethodName:setKey value:value]) {
        NSLog(@"*********%@**********",setKey);
        return;
    }else if ([self lg_performSelectorWithMethodName:_setKey value:value]) {
        NSLog(@"*********%@**********",_setKey);
        return;
    }else if ([self lg_performSelectorWithMethodName:setIsKey value:value]) {
        NSLog(@"*********%@**********",setIsKey);
        return;
    }
    
    // 3: 判断是否响应 accessInstanceVariablesDirectly 返回YES NO 奔溃
    // 3:判断是否能够直接赋值实例变量
    if (![self.class accessInstanceVariablesDirectly] ) {
        @throw [NSException exceptionWithName:@"LGUnknownKeyException" reason:[NSString stringWithFormat:@"****[%@ valueForUndefinedKey:]: this class is not key value coding-compliant for the key name.****",self] userInfo:nil];
    }
    
    // 4: 间接变量
    // 获取 ivar -> 遍历 containsObjct -
    // 4.1 定义一个收集实例变量的可变数组
    NSMutableArray *mArray = [self getIvarListName];
    // _<key> _is<Key> <key> is<Key>
    NSString *_key = [NSString stringWithFormat:@"_%@",key];
    NSString *_isKey = [NSString stringWithFormat:@"_is%@",Key];
    NSString *isKey = [NSString stringWithFormat:@"is%@",Key];
    if ([mArray containsObject:_key]) {
        // 4.2 获取相应的 ivar
       Ivar ivar = class_getInstanceVariable([self class], _key.UTF8String);
        // 4.3 对相应的 ivar 设置值
       object_setIvar(self , ivar, value);
       return;
    }else if ([mArray containsObject:_isKey]) {
       Ivar ivar = class_getInstanceVariable([self class], _isKey.UTF8String);
       object_setIvar(self , ivar, value);
       return;
    }else if ([mArray containsObject:key]) {
       Ivar ivar = class_getInstanceVariable([self class], key.UTF8String);
       object_setIvar(self , ivar, value);
       return;
    }else if ([mArray containsObject:isKey]) {
       Ivar ivar = class_getInstanceVariable([self class], isKey.UTF8String);
       object_setIvar(self , ivar, value);
       return;
    }
    
    // 5:如果找不到相关实例
    @throw [NSException exceptionWithName:@"LGUnknownKeyException" reason:[NSString stringWithFormat:@"****[%@ %@]: this class is not key value coding-compliant for the key name.****",self,NSStringFromSelector(_cmd)] userInfo:nil];
    
}


- (nullable id)lg_valueForKey:(NSString *)key{
    
    // 1:刷选key 判断非空
    if (key == nil  || key.length == 0) {
        return nil;
    }

    // 2:找到相关方法 get<Key> <key> countOf<Key>  objectIn<Key>AtIndex
    // key 要大写
    NSString *Key = key.capitalizedString;
    // 拼接方法
    NSString *getKey = [NSString stringWithFormat:@"get%@",Key];
    NSString *countOfKey = [NSString stringWithFormat:@"countOf%@",Key];
    NSString *objectInKeyAtIndex = [NSString stringWithFormat:@"objectIn%@AtIndex:",Key];
        
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    if ([self respondsToSelector:NSSelectorFromString(getKey)]) {
        return [self performSelector:NSSelectorFromString(getKey)];
    }else if ([self respondsToSelector:NSSelectorFromString(key)]){
        return [self performSelector:NSSelectorFromString(key)];
    }else if ([self respondsToSelector:NSSelectorFromString(countOfKey)]){
        if ([self respondsToSelector:NSSelectorFromString(objectInKeyAtIndex)]) {
            int num = (int)[self performSelector:NSSelectorFromString(countOfKey)];
            NSMutableArray *mArray = [NSMutableArray arrayWithCapacity:1];
            for (int i = 0; i<num-1; i++) {
                num = (int)[self performSelector:NSSelectorFromString(countOfKey)];
            }
            for (int j = 0; j<num; j++) {
                id objc = [self performSelector:NSSelectorFromString(objectInKeyAtIndex) withObject:@(num)];
                [mArray addObject:objc];
            }
            return mArray;
        }
    }
#pragma clang diagnostic pop
    
    // 3:判断是否能够直接赋值实例变量
    if (![self.class accessInstanceVariablesDirectly] ) {
        @throw [NSException exceptionWithName:@"LGUnknownKeyException" reason:[NSString stringWithFormat:@"****[%@ valueForUndefinedKey:]: this class is not key value coding-compliant for the key name.****",self] userInfo:nil];
    }
    
    // 4.找相关实例变量进行赋值
    // 4.1 定义一个收集实例变量的可变数组
    NSMutableArray *mArray = [self getIvarListName];
    // _<key> _is<Key> <key> is<Key>
    // _name -> _isName -> name -> isName
    NSString *_key = [NSString stringWithFormat:@"_%@",key];
    NSString *_isKey = [NSString stringWithFormat:@"_is%@",Key];
    NSString *isKey = [NSString stringWithFormat:@"is%@",Key];
    if ([mArray containsObject:_key]) {
        Ivar ivar = class_getInstanceVariable([self class], _key.UTF8String);
        return object_getIvar(self, ivar);;
    }else if ([mArray containsObject:_isKey]) {
        Ivar ivar = class_getInstanceVariable([self class], _isKey.UTF8String);
        return object_getIvar(self, ivar);;
    }else if ([mArray containsObject:key]) {
        Ivar ivar = class_getInstanceVariable([self class], key.UTF8String);
        return object_getIvar(self, ivar);;
    }else if ([mArray containsObject:isKey]) {
        Ivar ivar = class_getInstanceVariable([self class], isKey.UTF8String);
        return object_getIvar(self, ivar);;
    }

    return @"";
}


#pragma mark - 相关方法
- (BOOL)lg_performSelectorWithMethodName:(NSString *)methodName value:(id)value{
 
    if ([self respondsToSelector:NSSelectorFromString(methodName)]) {
        
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [self performSelector:NSSelectorFromString(methodName) withObject:value];
#pragma clang diagnostic pop
        return YES;
    }
    return NO;
}

- (id)performSelectorWithMethodName:(NSString *)methodName{
    if ([self respondsToSelector:NSSelectorFromString(methodName)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        return [self performSelector:NSSelectorFromString(methodName) ];
#pragma clang diagnostic pop
    }
    return nil;
}

- (NSMutableArray *)getIvarListName{
    
    NSMutableArray *mArray = [NSMutableArray arrayWithCapacity:1];
    unsigned int count = 0;
    Ivar *ivars = class_copyIvarList([self class], &count);
    for (int i = 0; i<count; i++) {
        Ivar ivar = ivars[i];
        const char *ivarNameChar = ivar_getName(ivar);
        NSString *ivarName = [NSString stringWithUTF8String:ivarNameChar];
        NSLog(@"ivarName == %@",ivarName);
        [mArray addObject:ivarName];
    }
    free(ivars);
    return mArray;
}

iOS底层原理:Method Swizzling原理和注意事项

2025年10月27日 17:21

Method Swizzling 是什么?

Method Swizzling的含义是方法交换,其核心内容是使用**runtime api**在运行时将一个方法的实现替换成另一个方法的实现。我们利用它可以替换系统或者我们自定义类的方法实现,进而达到我们的特殊目的,这就是我们常说的iOS黑魔法。

本文Demo地址:Github-JQMethodSwizzling

Method Swizzling 原理

OC方法在底层是通过**方法编号SEL函数实现IMP一一对应进行关联的。打个比方,OC类好比一本书,SEL就像是书中的目录,IMP**相当于每条目录所对应的页码。关系如图所示:

1.png

方法交换的代码如下:

    Method oriMethod = class_getInstanceMethod(cls, oriSEL);
    Method swiMethod = class_getInstanceMethod(cls, swiSEL);
    method_exchangeImplementations(oriMethod, swiMethod);

交换后的关系如图所示:

2.png

从上述方法交换可以看出:

  • 交换的是两者的方法实现,也就是说调用**oriSEL方法时,最终走的方法实现是swiIMP;调用swiSEL方法时,最终走的方法实现是oriIMP**。
  • 由此可见,在进行方法交换操作时,如果交换代码调用了两次或多次(2的倍数),就会导致方法实现又交换了回去,相当于交换了个寂寞,所以交换代码建议放在单例下进行来保证方法交换的有效性。

方法交换在使用中的递归调用分析

首先,我们来创建一个**JQStudent类,类中有两个实例方法,jq_studentInstanceMethod studentInstanceMethod;然后,在load方法中对两个方法进行交换;最后,jq_studentInstanceMethod 的实现中再次调用jq_studentInstanceMethod **方法。

代码实现如下图:

3.png

我们看到,这里会在**jq_studentInstanceMethod **方法中再次调用该方法,会不会引起递归调用呢?

运行结果如下图:

4.png

从运行结果看,并没有引起递归。这是因为进行方法交换后,在执行**[st studentInstanceMethod]时,实际上找到的是jq_studentInstanceMethod 的方法实现,而jq_studentInstanceMethod 方法实现中又执行[self jq_studentInstanceMethod],同样是因为方法交换,此时jq_studentInstanceMethod的方法实现也已经指向了studentInstanceMethod,所以并不会引起递归调用。相反,如果我们在jq_studentInstanceMethod 方法中调用了[self studentInstanceMethod]**才是会引起递归调用的,小伙伴们一定要注意!!! 流程如下图:

5.png

在实际的开发中,我们常采用这种方式对业务流程中的一些关键方法进行方法交换(俗称hook),从而达到不影响业务流程的情况下完成一些信息的收集工作,而这种方式则被称为**AOPAspect Oriented Programming,面向切面编程)。AOP是一种编程的思想,区别于OOPObject Oriented Programming,面向对象编程)。其实OOPAOP都是一种编程的思想,只不过OOP编程思想更加倾向于对业务模块的封装,划分出更加清晰的逻辑单元。而AOP**则是面向切面进行提取封装,提取各个模块中的公共部分,提高模块的复用率,降低业务之间的耦合性。

方法交换的坑点和分析

坑点一:交换父类的方法

我们还在刚才的Demo中来演示,现在有一个**JQStudent类了,再创建一个JQPerson类,让JQStudent继承JQPerson,在JQPerson类添加一个实例方法personInstanceMethod,在JQStudent类的load方法中将jq_studentInstanceMethod 方法和父类中的personInstanceMethod**方法进行交换。

实现代码如下图:

6.png

7.png

运行结果如下图:

8.png

从上面的结果可以看到:

  • 子类**JQStudent对象调用父类JQPerson的方法personInstanceMethod**,消息发送会通过方法查找从而找到父类方法并调用。
  • 但是此时父类**JQPerson中的方法personInstanceMethod对应的方法实现已经被交换成了子类JQStudentjq_studentInstanceMethod,因此会执行子类的jq_studentInstanceMethod**方法实现。
  • 同理,此时子类中调用**jq_studentInstanceMethod方法,会执行父类的personInstanceMethod**方法实现。

这样看起来好像没有什么问题啊!紧接着,我们再使用父类**JQPerson对象调用一下personInstanceMethod**方法,如下图:

9.png

啪、啪、啪,报错了!!!我们来分析下什么原因,

  • 首先,父类调换用**personInstanceMethod方法会执行子类中的jq_studentInstanceMethod**方法实现。
  • 然后又调用了**jq_studentInstanceMethod方法,但是,此时的调用者是JQPerson对象,父类JQPerson中并没有jq_studentInstanceMethod**方法实现。所以因方法找不到而报错。

出了问题,我们来解决以下,将交换方式换成下面这种:

10.png

11.png

再看运行结果:

12.png

此时,我们的运行不报错了,而且**JQStudent对象调用父类的personInstanceMethod方法,确实走了方法交换后的流程,JQPerson对象也正常的调用了personInstanceMethod**方法,互不影响。为什么呢? 原因是:

  1. 在方法交换前,先尝试给本类添加一下**oriSEL方法,方法实现为swiMethod**;
  2. 如果添加成功则返回**YES,代表本类中原本没有oriSEL的方法实现;接着,再将父类的方法实现oriMethod替换给本类的swiSEL**;
  3. 添加失败则返回**NO,代表本类中已有oriSEL**的方法实现,进行正常的方法交换即可。

坑点二:交换的父类中并没有实现的方法

如果要交换的父类方法并没有实现呢?直接看下运行结果:

13.png

14.png

什么情况?我的天,递归了!!!为什么呢?我们断点调试一下,看图解释:

15.png

16.png

从上面这些坑中,我们可以得出一些结论:

  • 方法交换要遵循功能单一原则,也就是说本类交换本类中的方法,不能影响父类,否则会影响父类和兄弟姐妹的行为(方法);
  • 即使要交换父类的方法,也要在本类中实现(重写)父类的方法;
  • 本类或父类交换的方法实现不存在,要给本类添加这个方法实现,否则会出现递归调用

基于以上特点,我封装一个更好的方法交换方式,请看以下代码实现:

17.png

运行结果如下:

18.png

苹果 Swift 安卓SDK上线,用一套 Swift 代码开发安卓 App 成为可能!

作者 iOS研究院
2025年10月27日 17:10

背景

10 月 24 日,Swift 官网通过博文宣布,以 Nightly 预览版形式推出首个适用于谷歌安卓系统的 Swift SDK。这一举措标志着 Swift 语言正式突破 iOS 生态边界,向安卓平台迈进。

官方推文.png

回溯今年 6 月,就曾报道 Swift 成立安卓工作组的消息,其核心目标是推动开发者使用 Swift 开发安卓应用。此次 SDK 的发布,意味着该工作组的推进工作取得实质性成果,也让 Swift 跨平台生态建设迈出关键一步。

安卓工作组

Swift 官方在博文中引用开发者 Joannis Orlandos 的观点,强调这一里程碑并非偶然。它既是 Android 工作组数月集中攻坚的结果,也离不开社区多年来的自发贡献。其最终目的是打破不同平台间的技术壁垒,为整个移动生态的创新提速。

目前,该 SDK 预览版已对所有开发者开放,获取方式灵活多样。开发者既可通过最新的 Windows 安装程序直接获取,也能在 Linux 或 macOS 系统上单独下载使用。

为降低上手门槛,官方同步配套了丰富资源。其中包含一份详尽的入门指南,清晰分步讲解环境依赖、软件包与开发包的安装配置流程;同时在 GitHub 上提供多个示例项目,覆盖不同应用场景,供开发者参考学习。

除支持从零搭建新应用外,该 SDK 还为特定需求开发者提供支持。对于希望将 Swift 代码集成到现有 Android 项目的开发者,可通过 swift-java 项目探索 Swift 与 Java 的互操作性,实现两种语言在同一项目中的混合编程。

关于未来规划,Swift 安卓工作组正起草一份规划文件,以明确后续开发方向。同时,工作组也积极邀请开发者在 Swift 官方论坛分享使用体验与建议,共同推动 SDK 完善。

简单来说:

Swift对跨平台的兼容,对于iOS从业来说是一件利好的大事情。不论是从技术栈还是从就业面。

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

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

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

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

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

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

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

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

Swift 官方发布 Android SDK机 - 肘子的 Swift 周报 #108

作者 Fatbobman
2025年10月27日 22:00

10 月 24 日,Swift Android 工作组发布了 Swift SDK for Android 的 nightly 预览版本。这标志着该 SDK 从早期的内部测试阶段迈入了官方支持阶段,也意味着 Swift 在跨平台之路上又向前迈出了一大步。

iOS底层原理:OC对象底层探索之alloc初探

2025年10月27日 16:05

0-0.png

iOS开发的小伙伴们对 [XXX alloc] init] 都不陌生,可以说 allocinit 贯穿我们整个的开发过程中。那么在OC对象的底层,到底做了哪些操作呢?今天我们就来探索一下 alloc 底层的工作流程。

一、抛砖引玉

我们先来看一下下面这张图中的测试代码和打印结果: 1.png

从上面的打印结果来看,p、p1、p2对象的内存地址是一样的,但是p、p1、p2对象的指针地址(&p、&p1、&p2)是不同的。而**pNew对象的内存地址和指针地址和p、p1、p2都不一样,很显然,pNew**属于拥有另一块内存空间的另一个对象了。 由此我们暂时得出结论:

  • **p、p1、p2**对象的指针地址是不同的, 但是他们都指向同一内存空间;
  • alloc 可以开辟内存空间,而 init 不会;
  • p、p1、p2对象的指针地址&p > &p1 > &p2 > &pNew,说明栈区是由高到低连续开辟的
  • p 、pNew对象的内存地址p < pNew,说明堆区是由低到高开辟内存的

结合堆栈的知识 ,我画了下面👇这张图,帮助大家理解。

2.png

二、准备工作

通过上面我们可以发现,对象内存地址是通过 alloc 创建,我们看一下 alloc 是怎么实现的。 点击 alloc 方法进入 NSObject.h: 2.1.png

2.2.png

进入**NSObject.h,我们再点击跳转,发现跳转不进去了,也就看不到alloc**的实现了。难道我们就只能停在这里?就只能在外面蹭一蹭了吗? NO,下面来介绍一下探索底层的三种方法,方便我们在探索底层源码的时候能够顺利的跟对方法(函数)的一个执行流程。

第一种:添加符合断点方式
  • 在工程中选择断点 --> 点击左下角"+" --> Symbolic Breakpoint

3.png

  • 比如我这里想知道**alloc源码位置, 那么就输入alloc**

4.png

  • 然后运行, 我们发现**alloc**的符号断点非常多,到底哪个才是我们想要的呢?

5.png

  • 接着我们还需要在想要执行的代码处增加一个普通断点,比如我们这里在**JQPersonalloc处打上一个断点,然后将alloc**符号断点先禁用

6.png

  • 运行程序,首先来到我们的普通断点**[JQPerson alloc]处,然后我们将符号断点alloc**启用,点击断点操作按钮进入下一步

7.png

8.png

到这里,我们可以看到**alloc方法在libobjc.A.dylib**库中(ps:libobjc.A.dylib是objc的系统库,感兴趣的小伙伴可以去苹果开源官网Open Source下载查看,注意:Open Source上下载下来的源码是不能直接编译和调试的,想要下载的objc源码可编译调试的小伙伴可以移步到我之前的文章iOS底层原理(一):苹果开源 objc4-818 源码项目的编译和调试

第二种: 断点 + step into方式
  • 我们先在要执行的代码打上断点,运行项目,来到断点位置

9.png

  • 然后按住control键,点击setp into一步一步查找,会看到如下结果

10.png

  • 最后再添加**objc_alloc符号断点,点击Continue program execution**继续执行

11.png

这里我们可以看到,断点进入了**libobjc.A.dylib中的objc_alloc函数,由此可知alloc方法的源码在libobjc.A.dylib**库中。

第三种: 汇编跟进方式
  • 首先,我们还是先在要执行的代码打上断点

12.png

  • 然后在Xcode菜单栏找到 Debug ==> Debug Workflow ==> Always Show Disassembly并选中(这里是启用汇编进行调试)

13.png

  • 运行项目,来到如下图的断点处

14.png

  • 我们可以看到当前断点下面两行处,有个**callq xxxx; symbol stub for objc_alloc,接着我们再添加一个objc_alloc符号断点, 点击Continue program execution继续执行(ps:这里解释一下:callq是汇编中的一个指令,代表这个这里即将要调用一个方法,symbol stub for objc_alloc翻译过来是objc_alloc的符号存根,也就是说objc_alloc**是要调用的方法名)

15.png 好了,到此底层探索的三种方式就介绍完了,接下来我们步入正题吧!

三、alloc源码探索

好的,有了上面的探索方法,我们现在就拿 objc 源码项目来探索 alloc 的底层实现吧。 首先,打开之前编译好的 objc4-818.2 项目,需要的小伙伴可以参考我之前文章**iOS底层原理(一):苹果开源 objc4-818 源码项目的编译和调试,到 Open Source 上下载源码自行编译,不想麻烦的也可以直接去 GitHub 上下载:JQObjc4-818.2BuildDebug。 然后,找到 JQObjcBuildDemo 目录下创建一个JQPerson类。然后在main.m**中添加如下代码:

16.png注意: 这里 16、17行 分别有个断点,后面会用到!!!

我们从上面的底层探索方式中可以看到:[JQPerson alloc]在底层libobjc.A.dylib库中执行的objc_alloc方法,接下来我就来验证一下。

第1步:alloc 和 objc_alloc
    1. 点击**alloc跳转到 objc 的源码中,搜索一下objc_alloc,然后分别在allocobjc_alloc**处打上断点

17.png18.png

    1. 然后,先将源码中**allocobjc_alloc**处的断点禁用,运行项目来到main.m中的断点处

19.png

    1. 接着,启用源码中**allocobjc_alloc处的断点,点击下一步,这时会发现:断点来到了objc_alloc**处

20.png 这就验证了我们前面所讲的,alloc方法在底层libobjc.A.dylib库中执行的objc_alloc方法

    1. 再次点击下一步,惊奇的发现:断点来到了**alloc**方法处

21.png 那么为什么**[JQPerson alloc]在底层会先走objc_alloc方法,再走alloc方法呢?按照我们在 objc 源码中看到的方法调用流程,应该是[JQPerson alloc] => alloc**呀?

为了验证这个问题,我们需要请出YYDS(永远滴神):llvm源码(是苹果开源的系统级别的源码),看一看苹果是不是在这里面做了什么骚操作。llvm-project下载地址

第2步:llvm-project 底层分析

由于 llvm-project 项目比较大,这里我们用 VSCode 打开

    1. 首先,我们全局搜索一下**alloc或者OMF_alloc:,来到tryGenerateSpecializedMessageSend**方法,这个方法在 CGObjC.cpp 文件中

21-1.png

我们主要看3号位置的方法解释,这里我翻译了一下,大家可以自行去看,这是苹果对性能的一个优化。主要意思就是:objc在运行时提供了快捷入口,这些入口比普通的消息发送速度更快,如果运行支持所需要的入口的话,这个方法就会调用并返回结果,否则返回None,调用者自己生成一个消息发送。

    1. 知道了**tryGenerateSpecializedMessageSend的作用,接着我再来看一下tryGenerateSpecializedMessageSend方法的调用情况,搜索tryGenerateSpecializedMessageSend,来到GeneratePossiblySpecializedMessageSend**

21-2.png

这个方法是运行时在底层的入口,所有的消息发送都会走这里。从代码可以看出,如果**tryGenerateSpecializedMessageSend方法返回None,这里判断为false,就会走GenerateMessageSend方法,也就是调用者自己生成一个普通的msgSend**。

    1. 然后,我们深入到**tryGenerateSpecializedMessageSend方法中,看看alloc是怎么被执行成了objc_alloc。这里看一下tryGenerateSpecializedMessageSend方法中4号位置的代码,这里有个条件判断 if (Sel.isUnarySelector() && Sel.getNameForSlot(0) == "alloc") ,如果成立,就会走EmitObjCAlloc**方法,搜索一下,进去看一下

21-3.png

可以看到**EmitObjCAlloc方法这里生成了一个objc_alloc的入口(ObjCEntrypoints),包装为emitObjCValueOperation被返回执行,并且llvm对此做一个标记存在Selector中,而Selector则记录在SelectorTable**中

21-4.png21-5.png21-6.png21-7.png21-8.png

由此可以验证:[JQPerson alloc]在底层会先走到objc_alloc

    1. objc_alloc第一次调用callAlloc方法,会执行msgSend(cls, @selector(alloc))(ps:这个第3步 callAlloc中会讲,这里知道一下,先把llvm这个流程讲完)。 此时llvm底层还是会走tryGenerateSpecializedMessageSend,此时,由于已经标记了**allocSelector,不会再走*if (Sel.isUnarySelector() && Sel.getNameForSlot(0) == "alloc")*这个判断中的代码,最终返回None。然后由GenerateMessageSend**走普通的消息发送。
第3步:callAlloc

好了,allocobjc_alloc的调用清晰了。接着,我们来看一下最核心的方法callAlloc

    1. objc 源码中我们可以看到**objc_alloc方法中调用了callAlloc**

22.png

    1. 我们观察一下**callAlloc中的代码,会发现这个方法的最后一行(1937行)对传入的 cls 做了一次消息发送,发送的消息名称正是alloc,这似乎可以解释上面走完objc_alloc方法后,又走到alloc**的现象。但是我们还需要打断点,走一下流程来验证。

23.png

    1. 经断点调试,执行**objc_alloc后,callAlloc确实走到了发送alloc消息这一行,也就是[JQPerson alloc] => objc_alloc => callAlloc**

24.png

    1. 继续走断点,我们会发现执行流程为:[JQPerson alloc] => objc_alloc => callAlloc => alloc => _objc_rootAlloc => callAlloc =>_objc_rootAllocWithZone

25.png26.png

    1. 当前我们已经走完了 main.m 中的16行,也就是**(JQPerson)p1alloc**,此时断点会来到17行 (JQPerson)p2alloc
    1. 继续走源码断点,会发现执行流程为:[JQPerson alloc] => objc_alloc => callAlloc => _objc_rootAllocWithZone

27.png28.png29.png

这里我们就会奇怪,为什么**JQPerson类再次alloc时,就直接走到*if (fastpath(!cls->ISA()->hasCustomAWZ()))***条件判断中的代码了呢?

    1. 那我们就来看一下***if (fastpath(!cls->ISA()->hasCustomAWZ()))这句判断到底执行了什么? 进入到if (fastpath(!cls->ISA()->hasCustomAWZ()))***源码中看一下

30.png31.png32.png33.png

由以上源码可以看出: a. 当**JQPerson类第一次调用alloc方法时,底层会先调用objc_alloc,此时callAlloc被第一次调用,callAlloc内部通过当前clsISA返回一个Class对象; b. 紧接着会去判断当前ClasscacheFAST_CACHE_HAS_DEFAULT_AWZ(存储在元类metaclass中,记录着cache中是否已经缓存了alloc/allocWithZone:方法的实现)这个标志位的值是否为真,由于是第一次执行,没有缓存,所以cache.getBit(FAST_CACHE_HAS_DEFAULT_AWZ)取出来的值是false,前面加个,变成了truecallAllocif (fastpath(!cls->ISA()->hasCustomAWZ()))又加了个,所以值为false; c. 然后走到了if (allocWithZone),由于objc_alloc方法中allocWithZone参数传值为false,所以走到了(objc_msgSend)(cls, @selector(alloc))。然后,callAlloc被第二次调用,由于执行过了alloc方法,所以此时有了alloc的方法缓存,所以if (fastpath(!cls->ISA()->hasCustomAWZ()))判断为true,执行_objc_rootAllocWithZone。 d. 最后就是 main.m 中第17行JQPerson类第二次调用alloc方法,此时由于JQPerson类的cache中已经有了缓存,FAST_CACHE_HAS_DEFAULT_AWZ这个标志位的值为真,也就是if (fastpath(!cls->ISA()->hasCustomAWZ()))这个条件为真,所以,会直接执行_objc_rootAllocWithZone**。 下面我画一下流程图,帮助小伙们理解一下:

[JQPerson alloc]流程图新.png

另外,这里我附一张**NSObject alloc]**的流程图,有兴趣的小伙们可以去试一试:

[NSObject alloc]流程图.png

这里**NSObject alloc]只走了一遍callAlloc方法,猜测原因是:系统对 NSObject 做了优化,提前给cache**添加了缓存。

好了,**alloc的底层探索今天先写到这里。下面一篇文章我们将探索一下alloc**开辟内存空间相关的源码。敬请期待吧!!!

记一次与 Coding Agent 合作实现 Feature 的过程

2025年10月27日 08:00

背景

在实现一个用 SwiftUI 构建的 iOS App 的过程中,我想让 Agent 帮我加一个 Feature:让 Calendar 可以滑动查看上一月/下一月。本以为是个简单的一个需求,过程的艰辛却远超我的预期。这也体现了纯 Vibe Coding 的一个局限:当 AI 撞墙时,即使指令给得再小、再清晰,它都很难独立完成任务。

Gallery image 1

第一次尝试

用的 Claude Code w/Sonnet-4.5,以为是个简单的需求,就给了一个最直接的 prompt:

Make the Boolean Type Calendar Scrollable. Scroll left/right to view previous/next month.

经过几次迭代后,这个 Calendar 可以滚动了,但很卡,于是我把这个信息告诉它,让它进行优化。

there's a scroll glith on boolean type calender view, when I scroll the calender past half, and release, it will lag twice, then slide to the end. think carefully to fix this bug.

又经过了几次迭代,它还是没能 fix 这个卡顿的问题,于是我重置了代码,进行了第二次尝试。

第二次尝试

对于同样的 Feature 不同的 Agent 可能会有不同的解法,所以这次切换到了 Codex w/gpt-5-codex,给了同样的 prompt,除了耗时更长外,结果并没有什么不同。此时我知道,这件事它可能不简单。

第三次尝试

这次只能亲自动手了,卡顿一般跟 re-render 有关,于是我查看代码,将 Calendar 对应的代码,简化成了 Color.random()(Color 本身并没有这个方法,所以加了一个 extension),发现在滑动时,Color 变了好几次,说明这些 View 被 SwiftUI 认为是不同的 View,所以重新创建了。得到这个信息后,让 Calude Code 再次进行优化:

the boolean calendar view is a bit lag while scrolling, it seems to be the view don't have a consistent id, so they re-render while page change. reduce the re-render by giving them appropreate ids.

有了这个信息后,Claude 再次进行优化,几次迭代后,优化完成,它非常自信的告诉我 re-render 的问题解决了,这次应该会非常丝滑。我结合 Self._printChanges(),发现确实重复生成/渲染的问题解决了,但,这个卡顿还在!

第四次尝试

这就很奇怪了,难道是这个用 SwiftUI 实现的 Calendar 有性能问题?为了验证这个想法,我让 CC 简化代码,用最简单的色块代替 Calendar,看看滚动起来是否顺畅。

now it works, but the lag persistent. can we first identify what's causing the lag, by simplify the scenario like use a random color, etc?

CC 听取了建议,把 Calendar 变成了纯色块,滑动是顺畅了,但有个问题,滑动过一半后,色块的颜色就变成了下一个 Calendar 的色块,我分析了下,应该是滑动过半后,page 自动变成了 next page,而这个色块会监听这个值的变化,于是也就变了。把这个信息给 CC 后,它很快就 fix 了。

- the prev/next button works perfect.
- but the scroll still has a problem, it seems to be caused by the page variable, once it pass half, the page change, and current scrolling item will be replace to that page.

给出的结果非常好,没有丝毫卡顿,极其丝滑。

第五次尝试

这么看来确实是 SwiftUI 实现的这个 Calendar 模块有问题,于是我想用 UIKit 重新实现一个,再嵌到 SwiftUI 里,看看是否能解决性能问题。

it seems the SwiftUI's Calendar is the root cause of glitch, maybe we can use UIKit to represent this calendar?

这个改动其实挺大的,所以 CC 也是尝试很多轮后,结合我的反馈,才终于基本可用了,中间还因为用满了 5 小时的 Token 限制,休息了一会。

yeah, its smooth, but after scroll end, the calendar doesn't refresh, all blank day, the title updated though.

---

the behavior is after scroll ended, it first display blank date, then immediately updated with some data, but it's not the final data, it will refresh quickly again to reveal the final data, so visually, like it blinks.

---

yes, it's better now, but the colored background only appear when scroll end, visually its not too good, can we pre fill the calendar?

---

the previous month's colored circles appear immediately, but the month before still blank and fulfilled after scroll end.

---

better, but ${currentMonth}-3 is still blank first when scrolled.

看起来确实丝滑了,实现方案是预加载 3 个 Calendar 的数据,当这 3 个 Calendar 滑动起来,这些蓝色块、红色块会被预先填上,但滑动到第 4 个 Calendar 时会出现先显示空 Calendar,然后再渲染色块的现象。

第六次尝试

CC 的这个策略看起来有点 rigid,能不能先预加载 3 Calendar,当滑动到倒数第二个预加载的 Calendar 时,再往前加载 3 Calendar?

can we make it simpler? because the scroll always from large month to small month (if scroll back to large month, it's already loaded), so why not just prefetch previous 3 months, if scroll to the prefetched month - 1, then start prefetch next 3 months?

很不幸,CC 在 Operate 的过程中触发了 Weekly Limit,好在还有 Codex,于是切换到 Codex,继续这个 CC 未完成的任务。

I'm in the middle of optimizing boolean type calendar scroll performance, I want the strategy be: first preload previous 3 months data, when user scroll to the second to last preloaded data's month, preload next 3 month. help me implement this strategy.

半小多时后,结果出来了,符合需求,但 Token 也用了近 20%(PS:这也是我打算退订 Codex 的一个原因:慢,有时 Token 消耗地还快)。

小结

这个看似简单的需求,如果过程中缺少人的协作,很难达到满意的效果。尽管 AI 在代码生成和辅助开发方面能力强大,但在面对复杂、深层或性能敏感的需求时,它仍然是一个强大的工具,而非完全独立的解决方案。它需要有人能够帮忙诊断问题、制定策略,并在必要时进行干预和引导。纯粹的 Vibe Coding 适用于简单、明确的需求,但对于有挑战的任务,人与 AI 的高效协作,即 “人机协作编程”,才是提升效率和解决问题的关键。

不要在 SwiftUI 中使用 .onAppear() 进行异步(Async)工作——这就是它导致你的 App 出现 Bug 的原因。

作者 JarvanMo
2025年10月27日 09:40

欢迎关注我的微信公众号:OpenFlutter,感恩

这个问题让我付出了沉重的代价——我的 SwiftUI App 每隔几秒就会随机重新加载数据

起初,我以为是我的 API 出了问题。

接着,我责怪我的 @State 变量。

然后是 Combine。

再后来是 CoreData。

有那么一刻,我甚至迁怒于 Xcode(说实话,它有时确实该背锅)。

但真正的罪魁祸首比我想象的要简单得多,也隐蔽得多:

那个看起来无辜的 .onAppear()。

像许多从 UIKit 转向 SwiftUI 的 iOS 开发者一样,我在任何地方都使用了 .onAppear()。

它感觉像是发起异步工作的自然之所——获取数据、加载图片、与 CoreData 同步以及启动后台更新。

它曾经运行得完美无缺……直到它失灵了。

突然间,我的 API 调用开始触发两次。

列表会闪烁。

有些视图会不停刷新。

最奇怪的是什么?它只是偶尔发生——这种“测不准错误(Heisenbug)”在你开启 Xcode 屏幕录制时又无法重现。

事实证明,SwiftUI 中的 .onAppear() 的含义和你想象的并不一样


1. .onAppear() 并非 viewDidLoad

当你从 UIKit 转来时,你期望获得某些生命周期保证。

viewDidLoad 只运行一次。viewWillAppear 在视图即将出现时每次都会运行。

你可以预测这些时刻。

然而,SwiftUI 是一个完全不同的野兽。

SwiftUI 视图是结构体(structs),而不是类。

根据状态如何变化、哪个父视图触发了重新渲染,或者 SwiftUI 如何优化视图层级,它们可以被多次重新创建。

这意味着你的 .onAppear() 可以一遍又一遍地触发——不只是在视图第一次出现时,而是每当 SwiftUI 觉得需要重新附加(reattaching)该视图时

示例:

struct UserListView: View {
    @State private var users: [User] = []

    var body: some View {
        List(users) { user in
            Text(user.name)
        }
        .onAppear {
            Task {
                await loadUsers()
            }
        }
    }

    func loadUsers() async {
        try? await Task.sleep(nanoseconds: 2_000_000_000)
        users = ["John", "Ava", "Noah"].map { User(name: $0) }
    }
}

看起来没问题,对吧?

然而,如果任何父视图发生了变化——比如一个筛选器(filter)、导航状态,或是一个绑定(binding)更新——SwiftUI 就可以**重新创建(recreate)**这个视图。

然后 .onAppear() 就会再次触发

现在你的 loadUsers() 就会运行多次。

  • 如果这是一个 API 调用,你将反复访问服务器
  • 如果这是 CoreData 操作,你将触发不必要的获取(fetches)
  • 如果是 UI 状态更新,你就会看到闪烁和重置

这一切都仅仅是因为 SwiftUI 认为它只是在重新渲染一个结构体。它并不知道你在里面进行了异步工作。


2. 在 .onAppear() 中进行异步工作是危险的

让我们看看当你将 .onAppear()Task 混用时,究竟会发生什么:

.onAppear {
    Task {
        await loadData()
    }
}

乍一看,这似乎是无害的

但这里有一个微妙的问题:

这个异步 Task 并没有绑定到(tied to)你的视图的生命周期。

因此,即使视图消失了(比如用户导航离开了),这个 Task 仍然在后台运行。

当它最终完成时,它会尝试更新一个 @State 变量……而这个变量可能已经不存在了。

这就是你最终遇到奇怪的运行时崩溃(runtime crashes)的原因,例如:

Publishing changes from background threads is not allowed

或者

Fatal error: Modifying state after view is gone

这些错误并非随机出现。它们是.onAppear() 内部启动的孤立异步任务所导致的直接后果

你只是在没有意识到的情况下制造了竞态条件(race condition)


3. SwiftUI 的正确做法:改用 .task { }

苹果公司知道这是一个问题。

因此,他们在 iOS 15 中引入了 .task { }。

乍一看,它和 .onAppear() 很像,但区别巨大。

.task { } 是专门为异步工作而设计的。

它会在视图消失时自动取消你的任务。

这意味着,如果用户导航离开或视图被销毁,SwiftUI 会安全地取消你的异步调用——没有内存泄漏,也没有僵尸更新(zombie updates)。

让我们用正确的方法重写之前的示例:


struct UserListView: View {
    @State private var users: [User] = []

    var body: some View {
        List(users) { user in
            Text(user.name)
        }
        .task {
            await loadUsers()
        }
    }

    func loadUsers() async {
        try? await Task.sleep(nanoseconds: 2_000_000_000)
        users = ["John", "Ava", "Noah"].map { User(name: $0) }
    }
}

现在,它的行为就完全符合你的预期

  • 任务在每次视图出现时只运行一次
  • 如果视图消失,SwiftUI 会自动取消它。
  • 无需手动管理任务的生命周期。
  • 没有“幽灵任务”(Ghost tasks)。
  • 没有重复加载。
  • 没有竞态条件。

4. 但是等等——为什么 .task 如此有效?

因为 SwiftUI 在内部将其绑定到了视图的“身份”(identity)

每个 SwiftUI 视图都有一个唯一的身份,这个身份决定了它何时处于“活动”状态。

当该身份发生变化时(例如,不同的 id、新的状态或导航事件),SwiftUI 就会取消任何与其相关的 .task。

这就是支持 .task(id:) 工作的机制,这是一个更高级的版本,它允许你控制任务何时重启

.task(id: user.id) {
    await fetchProfile(for: user)
}

因此,每当 user.id 发生变化时,你的异步任务就会重新启动

如果 user.id 没有变化,任务就会保持稳定——不会有重复的获取

这对于像分页列表依赖于选择的动态视图等复杂 UI 来说,是极其有用的。


5. .onAppear() 仍有意义的场景

公平地说,.onAppear() 并非一无是处。 它只是有不同的用途

.onAppear() 非常适合用于:

  • 同步状态更新
  • 动画触发
  • 日志记录或分析事件
  • 不涉及 await 或长时间操作的 UI 更改

例如:

.onAppear {
    isVisible = true
    analytics.log("UserList visible")
}

这样做完全没问题。

没有异步工作,没有外部依赖,自然也没有问题。只要你的代码中出现了 await,就应该把它移出 .onAppear()


6. 幽灵重载问题(The Phantom Reload Problem)

误用 .onAppear() 最令人沮丧的副作用之一发生在列表中。

想象一下这种情况:

ForEach(users) { user in
    UserRow(user: user)
        .onAppear {
            Task {
                await fetchProfilePicture(for: user)
            }
        }
}

这看起来无害——在每个用户行出现时获取他们的个人资料图片。

但在实际操作中,当你滚动时,SwiftUI 会回收(recycles)视图

因此,随着单元格不断出现和消失,.onAppear() 会被一遍又一遍地触发

恭喜你,你刚刚制造了一场后台网络风暴(background network storm)

修复方法:

改用 .task(id:),或者在视图层级的更高层级预取(prefetch)你的数据

ForEach(users) { user in
    UserRow(user: user)
        .task(id: user.id) {
            await fetchProfilePicture(for: user)
        }
}

现在,每个用户的图片获取任务都绑定到了它的身份(identity)。 当视图消失时,SwiftUI 会取消该任务。 这样你就避免了所有那些重复的获取


7. 真实世界的生产环境示例

我曾经为一个基于 SwiftUI 的电子商务应用工作,它有一个标签栏(tab bar)。 “首页”标签有一个仪表板视图,该视图在启动时需要获取多个 API 数据——促销信息、用户数据、购物车数量等。

代码看起来是这样的:

struct HomeView: View {
    @State private var data: HomeData?
    var body: some View {
        VStack {
            if let data {
                HomeDashboard(data: data)
            } else {
                ProgressView()
            }
        }
        .onAppear {
            Task {
                await fetchHomeData()
            }
        }
    }
}

在开发过程中,一切似乎都很正常。

但在生产环境中,用户发现应用运行缓慢

网络日志显示,每当他们切换标签时,就会出现重复的请求(duplicate requests)

为什么?

因为 SwiftUI 在底层会根据内存和导航状态**销毁并重新创建(destroys and recreates)**标签视图。

每次重新创建都会触发 .onAppear(),从而启动一个新的异步任务——即使数据已经加载完毕。

在改用 .task { } 之后,这个问题一夜之间就消失了


8. 调试技巧:打印生命周期事件

如果你不确定你的视图出现了多少次,可以试试这个快速技巧:

.onAppear { print("✅ Appeared!") }
.onDisappear { print("❌ Disappeared!") }

你会对这些事件触发的频率感到震惊——有时甚至当你只是在同级视图之间导航,或者切换更高层级的状态时,它们也会触发。

那一刻你就会意识到 .onAppear() 对异步工作来说有多么危险


9. 额外技巧:将 .task.refreshable 结合使用 ✨

当你处理数据获取时,这种组合简直就是纯粹的 SwiftUI 黄金搭档

struct ArticleList: View {
    @State private var articles: [Article] = []
var body: some View {
        List(articles) { article in
            Text(article.title)
        }
        .task { await loadArticles() }
        .refreshable { await loadArticles() }
    }
    func loadArticles() async {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        articles = ["SwiftUI", "Concurrency", "Combine"].map { Article(title: $0) }
    }
}

这为你带来了:

  • 安全的初始加载
  • 轻松实现下拉刷新(pull-to-refresh)
  • 自动任务取消
  • 简洁的、声明式的语法
  • 无需过度思考

10. 经验法则

这是最简单的记忆方法:

如果你的函数包含 await,那么它就不属于 .onAppear()

就是这样。

  • .onAppear() = 用于轻量级、同步的 UI 触发器。
  • .task { } = 用于异步的、可取消的、并绑定到视图生命周期的工作。

11. 针对旧版 iOS 怎么办?

如果你需要支持 iOS 14 或更早的版本.task { } 是不可用的

在这种情况下,你仍然可以使 .onAppear() 变得安全——只需手动添加取消逻辑

示例:

struct LegacyView: View {
    @State private var task: Task<Void, Never>?
    var body: some View {
        VStack {
            Text("Legacy Async Work")
        }
        .onAppear {
            task = Task {
                await loadData()
            }
        }
        .onDisappear {
            task?.cancel()
        }
    }
    func loadData() async {
        try? await Task.sleep(nanoseconds: 2_000_000_000)
    }
}

不像之前那么优雅,但它能让你的异步工作保持在控制之下


12. 吸取的教训

这段经历教会了我这些:

  • SwiftUI 视图是短暂的(ephemeral)——把它们视为快照,而不是屏幕。
  • .onAppear() 可以(而且将会)多次触发——不要依赖它进行一次性的设置。
  • 异步工作需要是可取消的(cancelable)——.task { } 免费为你提供了这一点。
  • 除非你确切知道何时会触发,否则不要在视图结构体内部放置副作用(side effects)
  • 如果你看到随机的重载或闪烁,首先检查你的 .onAppear() 调用

13. 我的最终看法

如果你的 SwiftUI App 随机重新加载数据, 如果你的 API 调用触发了两次, 如果你的加载指示器无故闪烁—— 不要想太多。 检查你的 .onAppear()

在大多数情况下,用 .task { } 替换它会立即修复 90% 的这些问题

SwiftUI 提供了正确的工具;你只需要将它们用于其预期的目的

因为 .onAppear() 并没有坏——它只是不适合承担异步逻辑的重担

结语(Final Thoughts)

我曾经以为 .onAppear() 是无害的。 直到它悄无声息地让我的 SwiftUI App 看起来不稳定且不可预测

一旦我用 .task 替换了它,一切都豁然开朗——无论是字面上还是象征意义上。 UI 停止了闪烁。 API 停止了过度触发。 我的异步代码第一次感觉真正属于 SwiftUI 的世界。

所以,如果你正在与随机重载、奇怪的时序问题或无形的后台任务作斗争——不用再找了。 你很可能用错了 .onAppear()

基于 Metal 的 iOS 全景视频播放器

作者 linghugoogle
2025年10月26日 23:09

项目简介

PNPlayer 是一个基于 Metal 框架开发的 iOS 全景视频播放器,支持 360° 全景视频播放和直观的手势控制。与传统的视频播放器不同,PNPlayer 能够让用户通过手势自由旋转视角,仿佛置身于视频场景之中,带来极具沉浸感的观看体验。

项目地址:github.com/linghugoogl…,欢迎 Star 和 Fork!

核心功能特性

PNPlayer 具备以下关键功能:

  • 🎥 高质量全景视频播放,支持 360° 全视角浏览
  • 🎮 流畅的手势控制,通过拖拽实现视角旋转
  • ⏯️ 完整的播放控制界面,包括播放 / 暂停、进度调节
  • 🔊 音频同步播放,提供完整的音视频体验
  • 🔄 实时渲染优化,确保流畅的播放体验

技术实现亮点

PNPlayer 采用了先进的技术栈和架构设计,值得开发者关注:

1. 底层技术栈

  • 渲染引擎:采用 Metal + Metal Shading Language,充分利用 iOS 设备的 GPU 性能
  • 视频处理:基于 AVFoundation + AVPlayer,实现高效的视频解码和播放控制

2. 视频播放流程

PNPlayer 设计了高效的视频数据处理流水线:

视频文件 → AVPlayerAVPlayerItemVideoOutput → CVPixelBuffer → MTLTexture

核心处理由 VideoTextureManager 负责,通过 AVPlayerItemVideoOutput 提取视频帧数据,再通过 CVMetalTextureCache 将像素缓冲区转换为 Metal 纹理,最终交由 GPU 渲染。

3. 全景渲染机制

全景渲染的核心在于将平面视频映射到球面几何体:

球面几何体 → 顶点着色器 → 纹理映射 → 片段着色器 → 屏幕输出
  • SphereGeometry 生成高精度球面网格(包含顶点和 UV 坐标)
  • 顶点着色器应用 MVP 矩阵变换,实现 3D 空间定位
  • 视频纹理通过 UV 坐标精确映射到球面
  • 相机控制器处理用户手势,实时更新视角旋转

4. 着色器核心代码

Metal 着色器是实现全景渲染的关键:

// 顶点着色器:3D 坐标变换
vertex VertexOut vertex_main(VertexIn in [[stage_in]],
                            constant Uniforms& uniforms [[buffer(1)]]) {
    out.position = uniforms.modelViewProjectionMatrix * float4(in.position, 1.0);
    out.texCoord = in.texCoord;
    return out;
}

// 片段着色器:纹理采样
fragment float4 fragment_main(VertexOut in [[stage_in]],
                             texture2d<float> colorTexture [[texture(0)]],
                             sampler colorSampler [[sampler(0)]]) {
    return colorTexture.sample(colorSampler, in.texCoord);
}

Stack walking: space and time trade-offs

作者 MaskRay
2025年10月26日 15:00

On most Linux platforms (except AArch32, which uses.ARM.exidx), DWARF .eh_frame is required forC++ exceptionhandling and stackunwinding to restore callee-saved registers. While.eh_frame can be used for call trace recording, it is oftencriticized for its runtime overhead. As an alternative, developers canenable frame pointers, or adopt SFrame, a newer format designedspecifically for profiling. This article examines the size overhead ofenabling non-DWARF stack walking mechanisms when building several LLVMexecutables.

Runtime performance analysis will be added in a future update.

Stack walking mechanisms

Here is a survey of mechanisms available for x86-64:

  • Frame pointers: fast but costs a register
  • DWARF .eh_frame: comprehensive but slower, supportsadditional features like C++ exception handling
  • SFrame: a new format being developed, profiling only..eh_frame is still needed for debugging and C++ exceptionhandling. Check out Remarkson SFrame for details.
  • x86 Last Branch Record (LBR): Skylake increased the LBR stack sizeto 32. Supported by AMD Zen 4 as LastBranch Record Extension Version 2 (LbrExtV2)
  • Apple'sCompact Unwinding Format: This has llvm, lld/MachO, and libunwindimplementation. Supports x86-64 and AArch64. This can mostly replaceDWARF CFI, but some entries need DWARF escape.
  • OpenVMS's Compact Unwinding Format: This modifies Apple's CompactUnwinding Format.

Space overhead analysis

Frame pointer size impact

For most architectures, GCC defaults to-fomit-frame-pointer in -O compilation to freeup a register for general use. To enable frame pointers, specify-fno-omit-frame-pointer, which reserves the frame pointerregister (e.g., rbp on x86-64) and emits push/popinstructions in function prologues/epilogues.

For leaf functions (those that don't call other functions), while theframe pointer register should still be reserved for consistency, thepush/pop operations are often unnecessary. Compilers provide-momit-leaf-frame-pointer (with target-specific defaults)to reduce code size.

The viability of this optimization depends on the targetarchitecture:

  • On AArch64, the return address is available in the link register(X30). The immediate caller can be retrieved by inspecting X30, so-momit-leaf-frame-pointer does not compromiseunwinding.
  • On x86-64, after the prologue instructions execute, the returnaddress is stored at RSP plus an offset. An unwinder needs to know thestack frame size to retrieve the return address, or it must utilizeDWARF information for the leaf frame and then switch to the FP chain forparent frames.

Beyond this architectural consideration, there are additionalpractical reasons to use -momit-leaf-frame-pointer onx86-64:

  • Many hand-written assembly implementations (including numerous glibcfunctions) don't establish frame pointers, creating gaps in the framepointer chain anyway.
  • In the prologue sequence push rbp; mov rbp, rsp, afterthe first instruction executes, RBP does not yet reference the currentstack frame. When shrink-wrapping optimizations are enabled, theinstruction region where RBP still holds the old value becomes larger,increasing the window where the frame pointer is unreliable.

Given these trade-offs, three common configurations have emerged:

  • omitting FP:-fomit-frame-pointer -momit-leaf-frame-pointer (smallestoverhead)
  • reserving FP, but removing FP push/pop for leaf functions:-fno-omit-frame-pointer -momit-leaf-frame-pointer (framepointer chain omitting the leaf frame)
  • reserving FP:-fno-omit-frame-pointer -mno-omit-leaf-frame-pointer(complete frame pointer chain, largest overhead)

The size impact varies significantly by program. Here's a Rubyscript section_size.rb that compares section sizes:

1
2
3
4
5
6
7
8
9
% ~/Dev/unwind-info-size-analyzer/section_size.rb /tmp/out/custom-{none,nonleaf,all}/bin/{llvm-mc,opt}
Filename | .text size | EH size | VM size | VM increase
------------------------------------+------------------+----------------+----------+------------
/tmp/out/custom-none/bin/llvm-mc | 2114687 (23.7%) | 367992 (4.1%) | 8914057 | -
/tmp/out/custom-nonleaf/bin/llvm-mc | 2124143 (24.0%) | 301688 (3.4%) | 8856713 | -0.6%
/tmp/out/custom-all/bin/llvm-mc | 2149535 (24.0%) | 362408 (4.1%) | 8942729 | +0.3%
/tmp/out/custom-none/bin/opt | 39018511 (70.2%) | 4561112 (8.2%) | 55583965 | -
/tmp/out/custom-nonleaf/bin/opt | 38879897 (71.4%) | 3542288 (6.5%) | 54424789 | -2.1%
/tmp/out/custom-all/bin/opt | 38980905 (71.0%) | 3888624 (7.1%) | 54871285 | -1.3%

For instance, llvm-mc is dominated by read-only data,making the relative .text percentage quite small, so framepointer impact on the VM size is minimal. ("VM size" is a metric used bybloaty, representing the total p_memsz size ofPT_LOAD segments, excluding alignmentpadding.) As expected, llvm-mc grows larger as morefunctions set up the frame pointer chain. However, optactually becomes smaller when -fno-omit-frame-pointer isenabled—a counterintuitive result that warrants explanation.

Without frame pointer, the compiler uses RSP-relative addressing toaccess stack objects. When using the register-indirect + disp8/disp32addresing mode, RSP needs an extra SIB byte while RBP doesn't. Forlarger functions accessing many local variables, the savings fromshorter RBP-relative encodings can outweigh the additionalpush rbp; mov rbp, rsp; pop rbp instructions in theprologues/epilogues.

1
2
3
4
5
6
% echo 'mov rax, [rsp+8]; mov rax, [rbp-8]' | /tmp/Rel/bin/llvm-mc -x86-asm-syntax=intel -output-asm-variant=1 -show-encoding
mov rax, qword ptr [rsp + 8] # encoding: [0x48,0x8b,0x44,0x24,0x08]
mov rax, qword ptr [rbp - 8] # encoding: [0x48,0x8b,0x45,0xf8]

# ModR/M byte 0x44: Mod=01 (register-indirect addressing + disp8), Reg=0 (dest reg RAX), R/M=100 (SIB byte follows)
# ModR/M byte 0x45: Mod=01 (register-indirect addressing + disp8), Reg=0 (dest reg RAX), R/M=101 (RBP)

SFrame vs .eh_frame

Oracle is advocating for SFrame adoption in Linux distributions. TheSFrame implementation is handled by the assembler and linker rather thanthe compiler. Let's build the latest binutils-gdb to test it.

Building test program

We'll use the clang compiler from https://github.com/llvm/llvm-project/tree/release/21.xas our test program.

There are still issues related to garbage collection (object fileformat design issue), so I'll just disable-Wl,--gc-sections.

1
2
3
4
5
6
7
8
9
--- i/llvm/cmake/modules/AddLLVM.cmake
+++ w/llvm/cmake/modules/AddLLVM.cmake
@@ -331,4 +331,4 @@ function(add_link_opts target_name)
# TODO Revisit this later on z/OS.
- set_property(TARGET ${target_name} APPEND_STRING PROPERTY
- LINK_FLAGS " -Wl,--gc-sections")
+ #set_property(TARGET ${target_name} APPEND_STRING PROPERTY
+ # LINK_FLAGS " -Wl,--gc-sections")
endif()
1
2
configure-llvm custom-sframe -DLLVM_TARGETS_TO_BUILD=host -DLLVM_ENABLE_PROJECTS='clang' -DLLVM_ENABLE_UNWIND_TABLES=on -DLLVM_ENABLE_LLD=off -DCMAKE_{EXE,SHARED}_LINKER_FLAGS=-fuse-ld=bfd -DCMAKE_C_COMPILER=$HOME/opt/gcc-15/bin/gcc -DCMAKE_CXX_COMPILER=$HOME/opt/gcc-15/bin/g++ -DCMAKE_C_FLAGS="-B$HOME/opt/binutils/bin -Wa,--gsframe" -DCMAKE_CXX_FLAGS="-B$HOME/opt/binutils/bin -Wa,--gsframe"
ninja -C /tmp/out/custom-sframe clang
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
% ~/Dev/bloaty/out/release/bloaty /tmp/out/custom-sframe/bin/clang
FILE SIZE VM SIZE
-------------- --------------
63.9% 88.0Mi 73.9% 88.0Mi .text
11.1% 15.2Mi 0.0% 0 .strtab
7.2% 9.96Mi 8.4% 9.96Mi .rodata
6.4% 8.87Mi 7.5% 8.87Mi .sframe
5.1% 7.07Mi 5.9% 7.07Mi .eh_frame
2.9% 3.96Mi 0.0% 0 .symtab
1.4% 1.98Mi 1.7% 1.98Mi .data.rel.ro
0.9% 1.23Mi 1.0% 1.23Mi [LOAD #4 [R]]
0.7% 999Ki 0.8% 999Ki .eh_frame_hdr
0.0% 0 0.5% 614Ki .bss
0.2% 294Ki 0.2% 294Ki .data
0.0% 23.1Ki 0.0% 23.1Ki .rela.dyn
0.0% 8.99Ki 0.0% 8.99Ki .dynstr
0.0% 8.77Ki 0.0% 8.77Ki .dynsym
0.0% 7.24Ki 0.0% 7.24Ki .rela.plt
0.0% 6.73Ki 0.0% 0 [Unmapped]
0.0% 6.29Ki 0.0% 3.84Ki [21 Others]
0.0% 4.84Ki 0.0% 4.84Ki .plt
0.0% 3.36Ki 0.0% 3.30Ki .init_array
0.0% 2.50Ki 0.0% 2.50Ki .hash
0.0% 2.44Ki 0.0% 2.44Ki .got.plt
100.0% 137Mi 100.0% 119Mi TOTAL
% ~/Dev/unwind-info-size-analyzer/eh_size.rb /tmp/out/custom-sframe/bin/clang
clang: sframe=9303875 eh_frame=7408976 eh_frame_hdr=1023004 eh=8431980 sframe/eh_frame=1.2558 sframe/eh=1.1034

The results show that .sframe (8.87 MiB) isapproximately 10% larger than the combined size of.eh_frame and .eh_frame_hdr (7.07 + 0.99 =8.06 MiB). While SFrame is designed for efficiency during stack walking,it carries a non-trivial space overhead compared to traditional DWARFunwind information.

SFrame vs FP

Having examined SFrame's overhead compared to .eh_frame,let's now compare the two primary approaches for non-hardware-assistedstack walking.

  • Frame pointer approach: Reserve FP but omitpush/pop for leaf functionsg++ -fno-omit-frame-pointer -momit-leaf-frame-pointer
  • SFrame approach: Omit FP and use SFrame metadatag++ -fomit-frame-pointer -momit-leaf-frame-pointer -Wa,--gsframe

To conduct a fair comparison, we build LLVM executables using bothapproaches with both Clang and GCC compilers. The following scriptconfigures and builds test binaries with each combination:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/zsh
conf() {
configure-llvm $@ -DCMAKE_EXE_LINKER_FLAGS='-pie -Wl,-z,pack-relative-relocs' -DLLVM_ENABLE_UNWIND_TABLES=on \
-DCMAKE_{EXE,SHARED}_LINKER_FLAGS=-fuse-ld=bfd -DLLVM_ENABLE_LLD=off
}

clang=-fno-integrated-as
gcc=("-DCMAKE_C_COMPILER=$HOME/opt/gcc-15/bin/gcc" "-DCMAKE_CXX_COMPILER=$HOME/opt/gcc-15/bin/g++")

fp="-fno-omit-frame-pointer -momit-leaf-frame-pointer -B$HOME/opt/binutils/bin -Wa,--gsframe=no"
sframe="-fomit-frame-pointer -momit-leaf-frame-pointer -B$HOME/opt/binutils/bin -Wa,--gsframe"

conf custom-fp -DCMAKE_{C,CXX}_FLAGS="$clang $fp"
conf custom-sframe -DCMAKE_{C,CXX}_FLAGS="$clang $sframe"
conf custom-fp-gcc -DCMAKE_{C,CXX}_FLAGS="$fp" ${gcc[@]}
conf custom-sframe-gcc -DCMAKE_{C,CXX}_FLAGS="$sframe" ${gcc[@]}

for i in fp sframe fp-gcc sframe-gcc; do ninja -C /tmp/out/custom-$i llvm-mc opt; done

The results reveal interesting differences between compilerimplementations:

1
2
3
4
5
6
7
8
9
10
11
% ~/Dev/unwind-info-size-analyzer/section_size.rb /tmp/out/custom-{fp,sframe,fp-gcc,sframe-gcc}/bin/{llvm-mc,opt}
Filename | .text size | EH size | .sframe size | VM size | VM increase
---------------------------------------+------------------+----------------+----------------+----------+------------
/tmp/out/custom-fp/bin/llvm-mc | 2124031 (23.5%) | 301136 (3.3%) | 0 (0.0%) | 9050149 | -
/tmp/out/custom-sframe/bin/llvm-mc | 2114383 (22.3%) | 367452 (3.9%) | 348235 (3.7%) | 9483621 | +4.8%
/tmp/out/custom-fp-gcc/bin/llvm-mc | 2744214 (29.2%) | 301836 (3.2%) | 0 (0.0%) | 9389677 | +3.8%
/tmp/out/custom-sframe-gcc/bin/llvm-mc | 2705860 (27.7%) | 354292 (3.6%) | 356073 (3.6%) | 9780985 | +8.1%
/tmp/out/custom-fp/bin/opt | 38872825 (69.9%) | 3538408 (6.4%) | 0 (0.0%) | 55598265 | -
/tmp/out/custom-sframe/bin/opt | 39011167 (62.4%) | 4557012 (7.3%) | 4452908 (7.1%) | 62494509 | +12.4%
/tmp/out/custom-fp-gcc/bin/opt | 54654471 (78.1%) | 3631068 (5.2%) | 0 (0.0%) | 70001565 | +25.9%
/tmp/out/custom-sframe-gcc/bin/opt | 53644639 (70.4%) | 4857236 (6.4%) | 5263558 (6.9%) | 76205645 | +37.1%
  • SFrame incurs a significant VM size increase.
  • GCC-built binaries are significantly larger than their Clangcounterparts, probably due to more aggressive inlining or vectorizationstrategies.

With Clang-built binaries, the frame pointer configuration produces asmaller opt executable (55.6 MiB) compared to the SFrameconfiguration (62.5 MiB). This reinforces our earlier observation thatRBP addressing can be more compact than RSP-relative addressing forlarge functions with frequent local variable accesses.

Assembly comparison reveals that functions using RBP and RSPaddressing produce quite similar code.

In contrast, GCC-built binaries show the opposite trend: the framepointer version of opt (70.0 MiB) is smaller than theSFrame version (76.2 MiB).

The generated assembly differs significantly between omit-FP andnon-omit-FP builds, I have compared symbol sizes between two GCC builds.

1
nvim -d =(/tmp/Rel/bin/llvm-nm -U --size-sort /tmp/out/custom-fp-gcc/bin/llvm-mc) =(/tmp/Rel/bin/llvm-nm -U --size-sort /tmp/out/custom-sframe-gcc/bin/llvm-mc)

Many functions, such as_ZN4llvm15ELFObjectWriter24executePostLayoutBindingEv, havesignificant more instructions in the keep-FP build. This suggests thatGCC's frame pointer code generation may not be as optimized as itsdefault omit-FP path.

Runtime performance analysis

TODO

perf record overhead with EH

perf record overhead with FP

Summary

This article examines the space overhead of different stack walkingmechanisms when building LLVM executables.

Frame pointer configurations: Enabling framepointers (-fno-omit-frame-pointer) can paradoxically reducex86-64 binary size when stack object accesses are frequent. This occursbecause RBP-relative addressing produces more compact encodings thanRSP-relative addressing, which requires an extra SIB byte. The savingsfrom shorter instructions can outweigh the prologue/epilogueoverhead.

SFrame vs .eh_frame: For the x86-64clang executable, SFrame metadata is approximately 10%larger than the combined size of .eh_frame and.eh_frame_hdr. Given the significant VM size overhead andthe lack of clear advantages over established alternatives, I amskeptical about SFrame's viability as the future of stack walking foruserspace programs. While SFrame will receive a major revision V3 in theupcoming months, it needs to achieve substantial size reductionscomparable to existing compact unwinding schemes to justify its adoptionover frame pointers. I hope interested folks can implement somethingsimilar to macOS's compact unwind descriptors (with x86-64 support) andOpenVMS's.

GCC's frame pointer code generation appears less optimized than itsdefault omit-frame-pointer path, as evidenced by substantial differencesin generated assembly.

Runtime performance analysis remains to be conducted to complete thetrade-off evaluation.

Appendix:configure-llvm

This script specifies common options when configuring llvm-project:https://github.com/MaskRay/Config/blob/master/home/bin/configure-llvm

  • -DCMAKE_CXX_ARCHIVE_CREATE="$HOME/Stable/bin/llvm-ar qc --thin <TARGET> <OBJECTS>" -DCMAKE_CXX_ARCHIVE_FINISH=::Use thin archives to reduce disk usage
  • -DLLVM_TARGETS_TO_BUILD=host: Build a singletarget
  • -DCLANG_ENABLE_OBJC_REWRITER=off -DCLANG_ENABLE_STATIC_ANALYZER=off:Disable less popular components
  • -DLLVM_ENABLE_PLUGINS=off -DCLANG_PLUGIN_SUPPORT=off:Disable -Wl,--export-dynamic, preventing large.dynsym and .dynstr sections

Appendix: My SFrame build

1
2
3
4
mkdir -p out/release && cd out/release
../../configure --prefix=$HOME/opt/binutils --disable-multilib
make -j $(nproc) all-ld all-binutils all-gas
make -j $(nproc) install-ld install-binutils install-gas

gcc -B$HOME/opt/binutils/bin andclang -B$HOME/opt/binutils/bin -fno-integrated-as will useas and ld from the install directory.

Appendix: Scripts

Ruby scripts used by this post are available at https://github.com/MaskRay/unwind-info-size-analyzer/

昨天以前iOS

Swift 下标(Subscripts)详解:从基础到进阶的完整指南

作者 unravel2025
2025年10月26日 23:18

什么是下标

官方一句话:“类、结构体、枚举可以用下标(subscripts)快速访问集合、列表、序列中的元素,而无需再写专门的存取方法。”

换句话说:someArray[index]someDictionary[key] 这种“中括号”语法糖,就是下标。

你自己写的类型也能拥有这种“中括号”魔法。

下标语法 101

subscript(index: Int) -> ReturnType {
    get {
        // 返回与 index 对应的值
    }
    set(newValue) {   // 可省略 (newValue)
        // 用 newValue 保存
    }
}

要点速记:

  1. 用关键字 subscript 开头,不是 func
  2. 参数列表可以是任意个数、任意类型。
  3. 返回值也可以是任意类型。
  4. 可以是只读(省略 set),也可以是读写。
  5. 不支持 inout 参数。

只读下标:最简单的入门示例

需求:做一个“n 乘法表”结构体,通过 table[6] 直接得到 n * 6

struct TimesTable {
    let multiplier: Int
    
    // 只读下标:省略了 get 关键字
    subscript(index: Int) -> Int {
        multiplier * index
    }
}

let threeTimesTable = TimesTable(multiplier: 3)
print(threeTimesTable[6])   // 输出 18

注意:

  • 没有 set,外界只能读不能写。
  • 如果写成 threeTimesTable[6] = 100 会直接编译报错。

可读写下标:让下标也能“赋值”

需求:自己封装一个“固定长度”的数组,禁止越界。

struct SafeArray<Element> {
    private var storage: [Element]
    
    init(repeating: Element, count: Int) {
        storage = Array(repeating: repeating, count: count)
    }
    
    // 可读可写下标
    subscript(index: Int) -> Element {
        get {
            // 越界直接崩溃,提前暴露问题
            precondition(index >= 0 && index < storage.count,
                         "Index \(index) out of range 0..<\(storage.count)")
            return storage[index]
        }
        set {
            precondition(index >= 0 && index < storage.count,
                         "Index \(index) out of range 0..<\(storage.count)")
            storage[index] = newValue
        }
    }
}

var sa = SafeArray(repeating: 0, count: 5)
sa[2] = 10
print(sa[2])   // 10
// sa[7] = 1   // 运行时触发 precondition 失败

多参数、多维度:二维矩阵实战

struct Matrix {
    let rows: Int, cols: Int
    private var grid: [Double]
    
    init(rows: Int, cols: Int) {
        self.rows = rows
        self.cols = cols
        grid = Array(repeating: 0.0, count: rows * cols)
    }
    
    // 二维下标
    subscript(row: Int, col: Int) -> Double {
        get {
            precondition(indexIsValid(row: row, col: col))
            return grid[row * cols + col]
        }
        set {
            precondition(indexIsValid(row: row, col: col))
            grid[row * cols + col] = newValue
        }
    }
    
    func indexIsValid(row: Int, col: Int) -> Bool {
        row >= 0 && row < rows && col >= 0 && col < cols
    }
    
    // 调试打印:方便看扁平化结果
    func debug() {
        print("grid = \(grid)")
    }
}

var m = Matrix(rows: 2, cols: 2)
m[0, 1] = 1.5
m[1, 0] = 3.2
m.debug()          // grid = [0.0, 1.5, 3.2, 0.0]

下标重载:一个类型多个“中括号”

下标也能像函数一样“重载”:参数类型或数量不同即可。

struct MultiSub {
    // 1. 通过 Int 索引
    subscript(i: Int) -> String {
        "Int 下标:\(i)"
    }
    
    // 2. 通过 String 索引
    subscript(s: String) -> String {
        "String 下标:\(s)"
    }
    
    // 3. 两个参数
    subscript(x: Int, y: Int) -> String {
        "二维:(\(x), \(y))"
    }
}

let box = MultiSub()
print(box[5])          // Int 下标:5
print(box["hello"])    // String 下标:hello
print(box[2, 3])       // 二维:(2, 3)

类型下标(static / class):不依赖实例也能用[]

实例下标必须“先有一个对象”;类型下标直接挂在类型上,用法类似 Type[key]

enum AppTheme {
    case light, dark
    
    // 类型下标:根据字符串返回颜色
    static subscript(name: String) -> UInt32? {
        switch name {
        case "background":
            return 0xFFFFFF
        case "text":
            return 0x000000
        default:
            return nil
        }
    }
}

// 无需实例
let bgColor = AppTheme["background"]   // 0xFFFFFF
  • 结构体/枚举用 static subscript
  • 类如果想让子类可覆写,用 class subscript

常见陷阱与调试技巧

  1. 越界问题

    Matrixprecondition 在运行时断言,开发阶段建议全开,生产环境可换成 guard + 抛出错误。

  2. 性能陷阱

    下标语法糖容易隐藏复杂逻辑。若 get 里做大量计算,会让“一行代码”拖慢整体。必要时加缓存或改用方法。

  3. 与函数歧义

    下标不支持 inout,也不能 throws。若需要这些能力,请定义成方法。

  4. 可读性

    滥用多参数下标会降低可读性。建议保持“语义直观”,例如 matrix[row, col] 很直观,但 foo[a, b, c, d] 就要谨慎。

总结

核心 3 句话:

  1. 下标 = “中括号”语法糖,语法像计算属性。
  2. 参数、返回值随便定,可重载,不支持 inout。
  3. 实例下标最常用,类型下标适合“全局字典”式场景。

扩展场景:自定义 JSON、缓存、稀疏矩阵

(1)“SwiftyJSON”式下标

struct JSON {
    private var raw: Any
    
    init(_ raw: Any) { self.raw = raw }
    
    // 允许 json["user"]["name"] 一路点下去
    subscript(key: String) -> JSON {
        guard let dict = raw as? [String: Any],
              let value = dict[key] else { return JSON(NSNull()) }
        return JSON(value)
    }
    
    // 支持数组
    subscript(index: Int) -> JSON {
        guard let arr = raw as? [Any], index < arr.count else { return JSON(NSNull()) }
        return JSON(arr[index])
    }
    
    var stringValue: String? { raw as? String }
}

let json = JSON(["user": ["name": "Alice"]])
if let name = json["user"]["name"].stringValue {
    print(name)   // Alice
}

(2)LRU 缓存下标

final class Cache<Key: Hashable, Value> {
    private var lru = NSCache<NSString, AnyObject>()
    
    subscript(key: Key) -> Value? {
        get { lru.object(forKey: "\(key)" as NSString) as? Value }
        set {
            if let v = newValue { lru.setObject(v as AnyObject, forKey: "\(key)" as NSString) }
            else { lru.removeObject(forKey: "\(key)" as NSString) }
        }
    }
}

(3)稀疏矩阵

当 99% 都是 0 时,可用字典存“非零下标”:

struct SparseMatrix {
    private var storage: [String: Double] = [:]
    
    subscript(row: Int, col: Int) -> Double {
        get { storage["\(row),\(col)"] ?? 0.0 }
        set {
            if newValue == 0.0 {
                storage.removeValue(forKey: "\(row),\(col)")
            } else {
                storage["\(row),\(col)"] = newValue
            }
        }
    }
}

下标 × 属性包装器:让“语法糖”再甜一点

从 Swift 5.1 开始,Property Wrapper 把“存储逻辑”抽成了可复用的注解;

把“下标”与“属性包装器”放在一起,可以做出带访问钩子的数组/字典,而调用端仍然只用一对中括号。

场景:线程安全数组

需求:

  • 对外像普通数组一样用 [] 读写;
  • 内部用 DispatchQueue 做同步锁;
  • 编译期自动注入,无需手写 queue.async {}

实现思路:

  1. @propertyWrapper 做一个泛型 ThreadSafeBox
  2. wrappedValue 里用 queue.sync { ... } 实现读写;
  3. 再给它一个下标投影(projectedValue 返回自身),让外部能用 $ 语法拿到“带下标的实例”。

代码:

import Foundation

@propertyWrapper
class ThreadSafeArray<Element> {
    private var storage: [Element] = []
    private let queue = DispatchQueue(label: "sync.array", attributes: .concurrent)
    
    init(wrappedValue: [Element]) {
        self.wrappedValue = wrappedValue
    }
    
    var wrappedValue: [Element] {
        get { queue.sync { storage } }
        set { queue.async(flags: .barrier) { self.storage = newValue } }
    }
    
    // 对外暴露“自身”做下标
    var projectedValue: ThreadSafeArray { self }
    
    // 下标:读,写
    subscript(index: Int) -> Element {
        get { queue.sync { storage[index] } }
        set { queue.async(flags: .barrier) { self.storage[index] = newValue } }
    }
    
    // 方便扩展 append、count 等
    func append(_ element: Element) {
        queue.async(flags: .barrier) { self.storage.append(element) }
    }
    
    var count: Int {
        queue.sync { storage.count }
    }
}

/* ============ 使用端 ============== */
class ViewModel {
    // 看似普通数组,实则线程安全
    @ThreadSafeArray var numbers: [Int] = []
    
    func demo() {
        // 1. 直接读写(走 wrappedValue)
        numbers = [1, 2, 3]
        
        // 2. 用下标(走 projectedValue)
        $numbers[0] = 99
        print($numbers[0])   // 99
        
        // 3. 并发安全
        DispatchQueue.concurrentPerform(iterations: 1000) { i in
            $numbers.append(i)
        }
        print("final count:", $numbers.count) // 1003
    }
}
let vm = ViewModel()
vm.demo()

关键点:

  • projectedValue 返回 self,于是 $numbers 就能继续用 []
  • 读写分离:读用 sync,写用 async(flags: .barrier),多读单写模型。
  • 对调用者完全透明,看起来像普通数组,却自带锁。

场景:@UserDefault 下标版

系统自带的 UserDefaults 标准写法:

UserDefaults.standard.set(true, forKey: "darkMode")

我们可以把“键”做成下标,并且用属性包装器自动同步:

@propertyWrapper
class UserDefault<T> {
    let key: String
    let defaultValue: T
    
    init(key: String, defaultValue: T) {
        self.key = key
        self.defaultValue = defaultValue
    }
    
    var wrappedValue: T {
        get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
    
    // 允许 $ 语法直接取下标
    var projectedValue: UserDefault { self }
    
    // 下标:支持动态 key
    subscript(_ suffix: String) -> T {
        get {
            let fullKey = "\(key)_\(suffix)"
            return UserDefaults.standard.object(forKey: fullKey) as? T ?? defaultValue
        }
        set {
            let fullKey = "\(key)_\(suffix)"
            UserDefaults.standard.set(newValue, forKey: fullKey)
        }
    }
}

/* ============ 使用端 ============== */
@MainActor
struct Settings {
    @UserDefault(key: "darkMode", defaultValue: false)
    static var darkMode: Bool
    
    // 投影后支持 $darkMode["iPhone"] 这种动态 key
    static func demo() {
        darkMode = true
        print(darkMode)                    // true
        
        $darkMode["iPad"] = false
        print($darkMode["iPad"])           // false
    }
}

Settings.demo()

下标与 Result Builder 的化学反应

SwiftUI 的 ViewBuilder 让大家见识到“DSL”之美;

其实我们自己也能用 Result Builder + 下标 做出“声明式语法”。

示例:做一个极简版 JSON DSL,支持这种写法:

let obj = JSON {
    "user" {
        "name" <<< "Alice"
        "age"  <<< 25
    }
}

实现要点:

  1. @resultBuilderJSONBuilder
  2. <<< 运算符把 key ~ value 塞进字典;
  3. JSON 支持 subscript(key: String) -> JSONNode 实现嵌套。
@resultBuilder
enum JSONBuilder {
    static func buildBlock(_ components: JSONNode...) -> JSONNode {
        JSONNode(children: components)
    }
}

struct JSONNode {
    enum Value {
        case string(String)
        case number(Double)
        case object([String: JSONNode])
    }
    var value: Value?
    var children: [JSONNode] = []
}

struct JSON {
    private var root: JSONNode = JSONNode()
    
    init(@JSONBuilder content: () -> JSONNode) {
        root = content()
    }
    
    // 关键下标:支持链式嵌套
    subscript(key: String) -> JSONNode {
        get {
            guard case .object(let dict) = root.value,
                  let node = dict[key] else { return JSONNode() }
            return node
        }
        set {
            if case .object(var dict) = root.value {
                dict[key] = newValue
                root.value = .object(dict)
            } else {
                root.value = .object([key: newValue])
            }
        }
    }
}

infix operator <<< : AssignmentPrecedence
func <<< (lhs: JSONNode, rhs: Any) {
    // 简化版:把 rhs 转成 JSONNode 并赋值
}

性能深度剖析:下标真的“零成本”吗?

  1. 纯“转发”下标(直接读数组)

    • 经编译器优化后,与手动裸数组访问无差别;
    • -O -whole-module-optimization 下会内联。
  2. preconditionsync 锁的下标

    • 运行时多了一次函数调用 + 条件判断;

    • 若放在热点循环,建议:

      a. 把“裸指针”提取到循环外;

      b. 或者用 withUnsafeBufferPointer 一次性处理。

  3. 多参数下标

    • 调用约定与多参函数相同,不会额外装箱;
    • 但泛型参数过多时可能触发 specialization 爆炸,注意模块划分。

老司机 iOS 周报 #356 | 2025-10-27

作者 ChengzhiHuang
2025年10月26日 18:12

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

新手推荐

🐕 Derived Data: 5 Things iOS Developers Do Wrong

@极速男孩:这篇文章总结了 iOS 开发者常对 Derived Data 犯的 5 个错误:

  • 不理解其用途
  • 手动查找文件夹
  • 出错时删除整个根目录(应只删特定项目的)
  • 不监控构建时间
  • 不检查 .app 产物以优化

文章

🐕 Why a custom ViewModifier is often useless

@Barney:文章主要阐述何时需要创建自定义 ViewModifier。作者指出,若只需封装不涉及 @State@Environment 的修饰符,直接使用 View 扩展方法即可,无需创建 ViewModifier 结构体。仅当需要管理状态或使用属性包装器时,才必须创建 ViewModifier 以正确处理这些需求。简而言之,ViewModifier 并非总是必需的,应根据实际需求选择合适的实现方式。

🐎 Don't make this mistake with a TaskGroup

@Smallfly:这篇文章聚焦 Swift 并发编程中 TaskGroup 的常见误用场景,通过示例代码揭示任务结果顺序的潜在问题,并提供简洁的解决方案。核心内容包括:

  • 问题现象:首次使用 TaskGroup 时,任务结果默认按完成顺序返回,而非任务创建顺序,导致数组结果顺序随机(如 fetchData(id:) 模拟网络延迟后,结果顺序与 id 无关)。
  • 解决方法:修改任务返回值为元组(包含原始参数与结果),例如 (index, result),收集结果后通过参数排序,确保最终数组顺序与任务创建顺序一致。

文章通过具体代码演示问题与修复过程,为开发者避免 TaskGroup 使用中的「顺序陷阱」提供了清晰的实践指导。

🐕 深入理解 Flutter 的 PlatformView 如何在鸿蒙平台实现混合开发

@david-clang:本文深入解析了 Flutter 在鸿蒙平台实现 PlatformView 同层渲染的技术方案,其核心实现机制如下:

  1. 渲染架构基础:采用类似 Android 的 VD 模式,通过 ArkUI 的 NodeContainer 作为占位容器,BuilderNode 将原生 ArkUI 组件转换为可渲染纹理。
  2. 数据驱动管理:基于 DVModel 数据模型驱动 DynamicView 进行节点的动态挂载与更新,契合鸿蒙声明式 UI 架构。
  3. 纹理合成流水线:Flutter Engine 通过鸿蒙 Graphic2D 创建 OH_NativeImage,该实例同时作为 Surface 供 BuilderNode 渲染 ArkUI 控件,并作为 Texture 供 Flutter 引用与合成。
  4. 事件传递机制:触摸事件从 Dart 层下发,经 EmbeddingNodeController 中转并转发至原生组件,确保交互响应。
  5. 组件生命周期:通过 EmbeddingNodeController 管理 BuilderNode 的创建与销毁,实现 PlatformView 的完整生命周期管理。

代码

🐕 An Apple Intelligence-Style Glow Effect in SwiftUI

@阿权:文章通过“多图层描边 + 模糊 + 动态渐变”的组合,复现了 Apple Intelligence 风格的发光效果,且支持所有 InsettableShape。你可以用它来突出按钮、卡片或文本容器,为界面增添现代感和表现力。

代码细节详见 GitHub repo

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

Flutter插件与包的本质差异

作者 CodingFisher
2025年10月26日 07:44

原文:xuanhu.info/projects/it…

Flutter插件与包的本质差异

1 核心概念定义

1.1 Flutter包(Package)

  • 纯Dart实现:仅包含Dart语言编写的逻辑代码
  • 跨平台特性:不依赖任何原生平台(Android/iOS)API
  • 典型用例
    // 日期格式化工具包示例
    class DateFormatter {
      static String formatDateTime(DateTime dt) {
        return '${dt.year}-${dt.month}-${dt.day}'; 
      }
    }
    

1.2 Flutter插件(Plugin)

  • 混合架构:包含Dart接口 + 平台特定实现
  • 平台通道:通过MethodChannel进行通信
    // Dart端调用原生摄像头
    final cameraPlugin = CameraPlugin();
    final imagePath = await cameraPlugin.takePicture(); 
    
  • 原生层实现
    // Android端Java实现
    public class CameraPlugin implements MethodCallHandler {
      @Override
      public void onMethodCall(MethodCall call, Result result) {
        if (call.method.equals("takePicture")) {
          dispatchTakePictureIntent(result); // 启动相机
        }
      }
    }
    

2 底层通信机制剖析

2.1 平台通道(Platform Channel)工作原理

sequenceDiagram
    Flutter->>Native: 调用方法 (MethodCall)
    Native->>Native: 执行原生操作
    Native->>Flutter: 返回结果 (Result)

2.2 数据类型映射表

Dart类型 Android类型 iOS类型
int java.lang.Integer NSNumber
double java.lang.Double NSNumber
String java.lang.String NSString
Uint8List byte[] FlutterStandardTypedData

3 实战场景对比

3.1 何时使用纯包

  • UI组件库:如pub.dev/packages/fl…
  • 业务逻辑封装:JWT令牌解析工具
  • 状态管理:Riverpod状态管理库
// 纯Dart包实现状态管理
final authProvider = Provider<User>((ref) {
  return User.fromToken(JWTParser.parse(token));
});

3.2 何时必须用插件

// 蓝牙插件使用示例
FlutterBlue flutterBlue = FlutterBlue.instance;
flutterBlue.scan().listen((scanResult) {
  print('发现设备: ${scanResult.device.name}');
});

4 混合开发进阶技巧

4.1 FFI(外部函数接口)替代方案

import 'dart:ffi';

typedef NativeAddFunc = Int32 Function(Int32, Int32);

final dylib = DynamicLibrary.open('libmath.dylib');
final add = dylib.lookupFunction<NativeAddFunc, NativeAddFunc>('add');

void main() {
  print('3+5=${add(3, 5)}'); // 直接调用C函数
}

4.2 插件性能优化策略

  • 批处理调用:减少平台通道通信次数
  • 二进制传输:使用ByteData代替Base64
  • 后台线程:耗时操作脱离UI线程
// 图像处理优化示例
final Isolate isolate = await Isolate.spawn(_processImage, imageData);

static void _processImage(Uint8List data) {
  // 在独立isolate中处理
  final result = applyFilters(data); 
  Isolate.exit(result);
}

5 企业级项目架构

5.1 分层架构设计

lib/
├── domain/       # 业务逻辑层(纯Dart包)
├── infrastructure/ # 基础设施层
│   ├── api_client.dart # 网络请求(纯Dart)
│   └── sensors.dart    # 传感器(插件)
└── presentation/ # UI层

5.2 联邦插件(Federated Plugins)

graph TD
    A[接口包] --> B[Android实现包]
    A --> C[iOS实现包]
    A --> D[Web实现包]

6 版本兼容性管理

6.1 多平台支持矩阵

插件版本 Flutter SDK Android API iOS版本
1.x >=2.0 21+ 11+
2.x >=3.3 24+ 13+

6.2 依赖冲突解决方案

dependency_overrides:
  plugin_core: 1.2.3 # 强制统一核心版本

总结

::: tabs#platform

@tab 核心差异

  • :纯Dart逻辑复用,适用于UI组件/工具类
  • 插件:平台桥接器,用于硬件/系统服务访问

@tab 选型决策树

flowchart TD
    A[需要访问硬件/系统API?] -->|是| B[使用插件]
    A -->|否| C[开发纯Dart包]

@tab 未来演进

  • WebAssembly支持:Dart与Rust的FFI深度整合
  • 宏编程:Dart 3元编程简化插件开发
  • 统一渲染引擎:减少平台特定代码需求

::: tip 最佳实践建议
1. **优先纯包架构**:90%业务逻辑应保持平台无关
2. **插件轻量化**:原生层仅做必要代理
3. **性能监控**:使用`devtools`检查通道调用耗时
4. **联邦插件策略**:支持多平台渐进增强
:::

```dart
// 健康检查工具(同时使用包和插件)
void checkHealth() {
  final memory = SystemInfo.getMemoryUsage(); // 插件访问系统API
  final status = HealthAnalyzer.analyze(memory); // 纯Dart分析逻辑
  print('系统健康度: ${status.level}');
}

结论

技术选型

考量维度 包(Package) 插件(Plugin)
开发成本 ★★☆☆☆ ★★★★☆
跨平台一致性 ★★★★★ ★★★☆☆
系统能力访问
热重载支持 部分支持
空安全支持 100% 依赖原生实现

演进趋势预测

  1. 插件包融合:Dart FFI技术将缩小两者差异
  2. Wasm跨平台:WebAssembly可能替代部分原生插件
  3. AI代码生成:GPT工程自动生成平台通道代码

原文:xuanhu.info/projects/it…

Swift 中基础概念:「函数」与「方法」

作者 unravel2025
2025年10月25日 11:28

为什么要区分「函数」和「方法」

写 Swift 时,我们每天都在写 func

但同一个关键字,有时叫「函数」,有时又叫「方法」。

名字不同,背后其实是作用域与归属权的差异:

  • 函数(function)——独立存在,像一把瑞士军刀,谁都能拿来用。
  • 方法(method)——寄居在类型内部,能直接访问类型的数据,像家电的遥控器,只能操控指定品牌。

搞清这一点,再读苹果文档或第三方库源码,就不会迷糊。

函数:真正独立的代码块

  1. 定义与调用
// 函数定义:全局可用,不依赖任何类型
func greet(name: String) -> String {
    return "Hello, \(name)!"
}

// 调用
let msg = greet(name: "Alice")
print(msg)   // 输出:Hello, Alice!
  1. 生活化比喻

把函数想成微波炉:你把它搬到任何厨房(项目),它都能加热食物(完成任务)。

微波炉不隶属于某套房子(对象),完全独立。

  1. 再看一个无参无副作用的工具函数
/// 返回当前时间戳(秒)
func currentTimestamp() -> TimeInterval {
    return Date().timeIntervalSince1970
}

这种「纯工具」逻辑,做成函数最合适,因为任何类型都可能用得到。

方法:绑定在类型上的函数

方法 = 函数 + 上下文。

上下文就是类型(class/struct/enum)里的属性与其他方法。

  1. 实例方法(Instance Method)
struct Car {
    var speed: Int      // 属性

    // 实例方法:只能由具体 Car 实例调用
    func describe() -> String {
        return "The car is going \(speed) km/h."
    }
}

let myCar = Car(speed: 100)
print(myCar.describe())   // 输出:The car is going 100 km/h.

要点:

  • describe 写在 Car 内部,直接读取 speed
  • 如果脱离 Car 实例,describe 无法存在。
  1. 变异方法(Mutating Method)

值类型(struct/enum)默认不可修改自身属性,需要显式标记 mutating

struct Counter {
    var count = 0

    // 变异方法:可以修改属性
    mutating func increment() {
        count += 1
    }
}

var counter = Counter()
counter.increment()
print(counter.count)   // 输出:1

注意:

  1. 只有 值类型 才用 mutating;class 是引用类型,直接改即可。

  2. 调用者必须用 var 声明,不能用 let

  3. 类型方法(Type Method)

相当于其他语言的「静态方法」,由类型本身调用,不依赖实例。

class Car {
    // 类型属性
    static let defaultWheelCount = 4
    
    // 类型方法
    static func numberOfWheels() -> Int {
        return defaultWheelCount
    }
}

print(Car.numberOfWheels())   // 输出:4

使用场景:

  • 工厂方法(创建实例)
  • 与实例无关的全局配置或工具逻辑

函数与方法协同实战:跑步记录器

下面把「函数」与「方法」放在同一段业务里,感受它们如何各司其职。

// 1️⃣ 独立函数:公里转英里,任何模块都能用
func convertToMiles(kilometers: Double) -> Double {
    return kilometers * 0.621371
}

// 2️⃣ 类型:Runner
struct Runner {
    var name: String
    var distanceInKm: Double
    
    // 实例方法:生成专属报告
    func progressReport() -> String {
        // 直接调用上面的独立函数
        let miles = convertToMiles(kilometers: distanceInKm)
        return "\(name) 今天跑了 \(String(format: "%.2f", miles)) 英里"
    }
    
    // 变异方法:增加跑量
    mutating func runExtra(km: Double) {
        distanceInKm += km
    }
}

// 3️⃣ 使用
var emma = Runner(name: "Emma", distanceInKm: 5)
print(emma.progressReport())   // Emma 今天跑了 3.11 英里

emma.runExtra(km: 2)
print(emma.progressReport())   // Emma 今天跑了 4.35 英里

看到吗?

  • convertToMiles 是纯逻辑,与任何类型无关。
  • progressReport 需要 Runner 内部数据,所以做成实例方法。
  • 两者组合,代码既复用又内聚。

易错点小结

场景 正确做法 常见错误
struct 里想改属性 mutating 忘记写,编译报错
类型方法里用实例属性 ❌ 不能 static 当实例方法用
全局工具逻辑 写成函数 强行塞进某个类型,导致复用困难

理解 & 使用原则

  1. 优先写成函数:只要逻辑不依赖任何实例数据,就独立出来。

    方便单元测试、跨模块复用,也减少类型体积。

  2. 升阶为方法的时机:

    • 需要频繁访问类型内部私有属性
    • 需要多态(子类重写)
    • 需要链式调用(return self

    满足一条,再考虑搬进类型。

  3. 命名统一,作用域清晰:

    函数用「动词」开头,convertdownloadvalidate

    方法也用「动词」,但可省略主语,describeincrementsave

    让调用者一眼看出谁属于谁。

扩展场景:把知识用到实际项目

  1. 网络层
   // 独立函数,任何模型都能调
   func GET<T: Decodable>(_ url: URL) async throws -> T { ... }

   // 模型内部方法,自己知道怎么拼装 URL
   struct Article {
       let id: Int
       func detailURL() -> URL { ... }
   }
  1. SwiftUI 视图
   // 全局函数,做颜色插值
   func interpolateColor(from: Color, to: Color, percent: Double) -> Color { ... }

   // 视图的方法,负责自己的业务
   struct ProgressCircle: View {
       var progress: Double
       private func strokeColor() -> Color {
           return interpolateColor(from: .green, to: .red, percent: progress)
       }
   }
  1. 算法库

    快速排序、哈希函数等纯算法,一律写成函数,避免绑定具体类型。

结语

所有方法都是函数,但函数不一定是方法。

记住「是否依赖类型数据」这一把尺子,该独立就独立,该内聚就内聚。

当你把边界划清楚,代码会自然呈现出高内聚、低耦合的漂亮形态。

希望这篇笔记能帮你把「函数 vs 方法」彻底吃透,写出更 Swifty 的代码!

参考资料

Swift 官方语言指南 - Functions

Swift 官方语言指南 - Methods

iOS进阶1-combine

2025年10月24日 20:50

iOS 进阶:深入浅出 Swift Combine 框架

本文深入探讨 Apple 的响应式编程框架 Combine。通过核心概念解析、丰富代码示例和实战场景,系统介绍如何利用 Combine 优雅地处理异步事件和数据流,提升代码的可读性、可维护性。

一、Combine 框架概述

Combine 是 Apple 在 2019 年 WWDC 上推出的一个声明式响应式编程框架,专为 Swift 语言设计。它的核心思想是处理随时间变化的值或异步事件。想象一下诸如网络请求响应、用户界面输入、定时器事件等场景,这些都可以被建模为事件流,而 Combine 提供了一套统一的 API 来组合和转换这些流 。

在 SwiftUI 中,Combine 是数据驱动 UI 的基石(如 @Published 和 ObservableObject)。但它同样强大地适用于 UIKit 开发,用于简化复杂的异步操作和状态管理 。

核心概念三要素

Combine 的运作主要围绕三个核心角色,它们共同构成一条清晰的数据流水线:

核心概念 角色描述 现实世界类比
Publisher(发布者) 事件的源头,负责产出值。 报社
Subscriber(订阅者) 事件的终点,负责接收并消费值。 订户
Operator(操作符) 数据的处理站,位于 Publisher 和 Subscriber 之间,负责转换、过滤、组合值。 报社的编辑部门(校对、排版、内容整合)

一条完整的 Combine 链条工作流程如下:Publisher -> (零个或多个 Operator) -> Subscriber 

。接下来,我们详细看看每个组成部分。

二、核心组件详解

2.1 Publisher(发布者)

Publisher 是一个协议,它定义了一个能够发出一系列元素(Values)、完成事件(Completion)或错误(Failure)的类型。它有两种终止状态:正常完成(.finished)失败(.failure)。在收到终止事件后,数据流即结束 。

常用的内置 Publisher 包括:

  • **Just**: 发送一个值后立即完成。

    swift
    复制
    let publisher = Just("Hello, Combine!") // 输出类型是 String, 错误类型是 Never
    
  • **Fail**: 立即发送一个错误并终止。

  • **PassthroughSubject**: 一个可以手动发送值的 Subject(主题),不保留当前值。

  • **CurrentValueSubject**: 一个可以手动发送值的 Subject,并保存当前值的状态 。

  • **@Published**: 属性包装器,能将一个属性转换为 Publisher 。

2.2 Subscriber(订阅者)

Subscriber 是数据的消费者。Publisher 在有了 Subscriber 之后才会开始发送数据。Combine 提供了两种常用的内置 Subscriber:

  1. **sink**
    最通用的订阅者,它接收两个闭包:一个处理接收到的值(receiveValue),另一个处理完成事件(receiveCompletion)。

    swift
    复制
    [1, 2, 3, 4, 5].publisher // Publisher 发布数据
        .sink(
            receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("数据流正常结束")
                case .failure(let error):
                    print("数据流因错误结束: (error)")
                }
            },
            receiveValue: { value in
                print("接收到值: (value)") // 依次打印 1, 2, 3, 4, 5
            }
        )
    
  2. **assign**
    将接收到的值直接绑定到某个对象的某个属性上,用于更新 UI 非常方便。

    swift
    复制
    class MyViewController: UIViewController {
        @IBOutlet weak var nameLabel: UILabel!
        var cancellables = Set<AnyCancellable>()
        
        func viewDidLoad() {
            super.viewDidLoad()
            // $name 是一个 Publisher<String, Never>
            viewModel.$name
                .assign(to: .text, on: nameLabel) // 将值直接赋给 label 的 text 属性
                .store(in: &cancellables)
        }
    }
    

2.3 Operator(操作符)

操作符是 Combine 强大功能的体现,它们是 Publisher 协议上定义的方法,每个操作符都会返回一个新的 Publisher,从而允许进行链式调用。以下是一些常用类别:

操作符类别 代表操作符 功能说明 示例
转换 map 将接收到的值转换为另一种形式。 .map { $0.count } (将字符串流转换为整数流)
过滤 filter 只允许满足条件的值通过。 .filter { $0 > 10 } (只保留大于10的值)
错误处理 catch 捕获错误,并返回一个备用的 Publisher。 .catch { _ in return Just("Default Value") }
组合 combineLatest 组合多个 Publisher,当任何一个有新值时,发送所有 Publisher 最新值的元组。 用于表单验证,同时监听用户名和密码输入框。
时间控制 debounce 防抖,例如用于搜索框,在用户停止输入一段时间后才发送请求。 .debounce(for: .seconds(0.5), scheduler: RunLoop.main)

链式调用示例

swift
复制
// 一个综合使用操作符的示例
let cancellable = [1, 2, 3, 4, 5].publisher
    .filter { $0 % 2 == 0 } // 过滤偶数:2, 4
    .map { $0 * $0 }        // 转换平方:4, 16
    .sink { value in
        print(value) // 最终输出:4, 16
    }

三、实战应用场景

3.1 网络请求

Combine 能极大地简化网络请求的处理。URLSession 直接提供了 dataTaskPublisher 用于网络调用。

swift
复制
import Combine

struct User: Decodable {
    let name: String
}

func fetchUser(userId: String) -> AnyPublisher<User, Error> {
    guard let url = URL(string: "https://api.example.com/users/(userId)") else {
        return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
    }
    
    return URLSession.shared.dataTaskPublisher(for: url) // 1. 创建网络请求 Publisher
        .map(.data)                                   // 2. 提取数据部分
        .decode(type: User.self, decoder: JSONDecoder()) // 3. 解码 JSON
        .receive(on: DispatchQueue.main)              // 4. 切换到主线程更新 UI
        .eraseToAnyPublisher()                         // 5. 类型擦除,方便返回
}

// 使用
var cancellables = Set<AnyCancellable>()
fetchUser(userId: "123")
    .sink(receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("请求失败: (error)")
        }
    }, receiveValue: { user in
        print("获取到用户: (user.name)")
        // 在这里更新 UI
    })
    .store(in: &cancellables)

3.2 处理用户输入(UIKit)

使用 Combine 可以轻松响应 UIKit 控件的各种事件。

swift
复制
import Combine
import UIKit

class SearchViewController: UIViewController {
    @IBOutlet weak var searchTextField: UITextField!
    @IBOutlet weak var resultsLabel: UILabel!
    
    var viewModel = SearchViewModel()
    var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindTextField()
    }
    
    private func bindTextField() {
        // 1. 创建文本变化的 Publisher
        let textPublisher = NotificationCenter.default
            .publisher(for: UITextField.textDidChangeNotification, object: searchTextField)
            .compactMap { ($0.object as? UITextField)?.text } // 确保有文本
            .eraseToAnyPublisher()
        
        // 2. 将 Publisher 绑定到 ViewModel
        viewModel.performSearch(with: textPublisher)
        
        // 3. 订阅 ViewModel 的结果来更新 UI
        viewModel.$searchResults
            .receive(on: DispatchQueue.main)
            .assign(to: .text, on: resultsLabel)
            .store(in: &cancellables)
    }
}

class SearchViewModel: ObservableObject {
    @Published var searchResults: String = ""
    private var cancellables = Set<AnyCancellable>()
    
    func performSearch(with queryPublisher: AnyPublisher<String, Never>) {
        queryPublisher
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main) // 防抖,避免频繁请求
            .removeDuplicates() // 去除连续重复的输入
            .flatMap { query -> AnyPublisher<String, Never> in
                // 模拟网络搜索,例如返回 "Results for '(query)'"
                return Just("Results for '(query)'").eraseToAnyPublisher()
            }
            .assign(to: .searchResults, on: self)
            .store(in: &cancellables)
    }
}

3.3 状态管理与 SwiftUI 集成

在 SwiftUI 中,Combine 是无缝集成的。ObservableObject 协议和 @Published 属性包装器是核心。

swift
复制
import SwiftUI
import Combine

class CounterViewModel: ObservableObject {
    @Published var count: Int = 0 // 使用 @Published 标记,当其变化时会通知视图更新
    @Published var isEven: Bool = false
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // 监听 count 的变化,自动推导 isEven 的状态
        $count
            .map { $0 % 2 == 0 }
            .assign(to: .isEven, on: self)
            .store(in: &cancellables)
    }
    
    func increment() {
        count += 1
    }
    
    func decrement() {
        count -= 1
    }
}

struct CounterView: View {
    @StateObject var viewModel = CounterViewModel() // StateObject 监听 ObservableObject 的变化
    
    var body: some View {
        VStack {
            Text("Count: (viewModel.count)")
            Text(viewModel.isEven ? "Even" : "Odd")
            Button("+1") {
                viewModel.increment()
            }
            Button("-1") {
                viewModel.decrement()
            }
        }
    }
}

四、内存管理与最佳实践

4.1 内存管理:AnyCancellable

当您调用 sink 或 assign 时,返回值是一个 AnyCancellable 实例。你必须强引用这个对象,否则订阅会立即被取消,数据流也会中断 。

标准做法是使用一个集合(通常是 Set<AnyCancellable>)来存储所有订阅。

swift
复制
class MyViewController: UIViewController {
    private var cancellables = Set<AnyCancellable>() // 存储订阅的集合
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        SomePublisher()
            .sink { ... }
            .store(in: &cancellables) // 关键:将订阅存储到集合中
    }
}

4.2 最佳实践建议

  1. 线程切换:使用 receive(on:) 操作符确保 UI 更新在主线程进行(DispatchQueue.main)。

  2. 错误处理:合理使用 catchretry 等操作符来优雅地处理可能发生的错误。

  3. 避免循环引用:在 sink 的闭包内使用 [weak self] 来避免循环引用,特别是在将值赋给 self 的属性时。

    swift
    复制
    .sink { [weak self] value in
        self?.updateUI(with: value)
    }
    
  4. 合理使用操作符:操作符虽好,但不宜过度嵌套,保持链式的可读性。

  5. 调试:使用 print() 操作符可以打印出事件流的发生,便于调试。

    swift
    复制
    .print("Debug Stream")
    .sink { ... }
    

总结

Combine 框架通过其声明式的语法,将复杂的异步代码转化为清晰、线性的数据流管道。虽然初学时有一定门槛,但一旦掌握,它将极大地提升你处理异步事件和状态管理的能力,使代码更健壮、更易维护。从简单的 UI 绑定到复杂的异步操作链,Combine 都是一个强大的工具。建议从简单的例子开始实践,逐步深入到更复杂的场景中。

希望这篇博客能为你打开 Combine 世界的大门!

KN:Kotlin 与 OC 交互

作者 Dashing
2025年10月24日 17:49

Kotlin Native (KN)

Kotlin/Native | Kotlin Documentation

KN是一种技术,可以将Kotlin的代码编译为原生的二进制库,无需依赖虚拟机即可运行,主要包括下面两部分的内容:

● 基于LLVM的Kotlin编译器的后端

● Kotlin标准库的原生实现

 

 

KN 是 Kotlin Multiplatform 的一部分,KN让 Kotlin 代码可以在更多的目标平台运行。

 

目标平台(Target Platform) 指代码编译后最终运行的硬件和操作系统环境

处理器指令集架构 x86、x86_64、arm32、arm64
二进制文件的组织规范 ELF(Linux)、Mach-O(macOS/iOS)、PE(Windows)
操作系统特有的接口 不同的系统调用接口(IO、多线程)

 

 

编译

image.png

将 Kotlin IR 转换为 LLVM IR,最终通过LLVM生成对应平台的可执行文件。

处理器指令集架构 以及 二进制文件格式 的差异被处理

 

 

Runtime

运行时库,抹平操作系统的差异,为Kotlin标准库提供底层功能实现

 

image.png

可以参考C/C++运行时库理解,可以自行搜索

 

 

编译器跟runtime是相互配合的,runtime 提供的能力,需要编译器来桥接,编译器也需要知道哪些功能可以怎么桥接才能完成编译。

 

 

Helloworld

创建一个 Hello.kt 文件,内容如下:

interface HelloAble {
    fun makeIt(): String
}
 
open class Hello(open var name: String) {
 
    var helloable: HelloAble? = null
 
    open fun greetPrefix(): String {
        return "Hello"
    }
 
    fun greet(): String {
        return ("${greetPrefix()} $name")
    }
 
    fun triggleInterface() {
        helloable?.makeIt()
    }
 
    companion object {
        fun createObj(): Hello {
            return Hello("luyu")
        }
    }
}
 
open class Helloworld(override var name: String) : Hello(name) {
    override fun greetPrefix(): String {
        val s = super.greetPrefix()
        return "$s world"
    }
}
 
fun main() {
    val h = Helloworld("cindy")
    println(h.greet())
    h.triggleInterface()
}

 

执行命令:

kotlinc-native Hello.kt -g -Xno-inline -p program -o Hello

● kotlinc-native:编译Kotlin文件

● -g:带调试信息

● -p program:编译为可执行程序,还可以编译为动态库,静态库,framework等

● -o Hello:输出文件名为Hello

 

在我目前的Mac电脑上,生成下面两个产物:

 

可以直接运行,不依赖虚拟机环境

 

 

启动过程

使用MachOView打开Hello.kexe,搜索main函数,发现函数符号名为kfun:#main(){},可以使用lldb调试看看:

查看堆栈:

需要先初始化runtime,创建参数

调到我们的main函数

 

对应的runtime源码

 

对象创建

main函数反编译结果

 

整体跟C++的机制比较像,成员方法编译为全局的C函数,对象实例是一个C结构体; 调用成员方法就是调用全局函数,并把对象作为第一个参数传入

 

通过 MachOView 可以看到编译后所有的对应的函数

 

 

我们打断点到init方法

清楚的看到有两个参数,第一个参数是this对象,第二个参数是name,分别通过x0寄存器跟x0寄存器 跟 x1 寄存器传递

 

 

我们给name传的值是字符串"cindy",验证下是否正确

● x/8gx $x1 读取 $x1 地址对应的内存中的内容,g:8个字节为一组 x:以16进制显示 重复 8 次

● x/8hx 0x1000b22c0 读取0x1000b22c0地址对应的内存中的内容 h:两个字节为一组 x:以16进制显示 重复 8 次 似乎看到了ASCII 吗

● 以字符串显示,发现了确实是cindy,Kotlin每个字符大小是两个字节

 

 

编译器已经把源码中的 "cindy" 字符串编译为了放在全局数据区的一个 String 对象(全局常量),C结构体定义如下

struct StringHeader {
    TypeInfo* typeInfoOrMeta_;    uint32_t count_;
    int32_t hashCode_;
    uint16_t flags_;
    alignas(KChar) char data_[];
}

 

x1是字符串"cindy",编译器生成的全局常量,x1是字符串"cindy",编译器生成的全局常量,x0是Helloworld对象吗?怎么来的?

 

可以看main函数中调用 init方法之前的一段代码

 

 

● 从数据段获取了Helloworld的类型的信息 kclass:Helloworld

● 调用 AllocInstance 分配内存

● 调用 UpdateStackRef 将分配好的内存的地址保存到栈上

 

类型信息

kclass:Helloworld 是一个地址,里面存的内容是什么?为什么创建对象需要它?

 

通过查看源码,对应的类型是 TypeInfo 的结构体,存储一个类的信息(类比与OC的类对象)

 

对应的定义如下(截图不完整):

● 这些信息本身都是在编译期的常量,编译器组织起来放在了数据段(不可变),运行时可以读取使用

● 很多跟OC类似的信息,父类型、实例大小、成员变量的便宜、遵守的协议等等

 

可以读取信息看下

有 3 个成员变量:objOffsetsCount_ = 3,便宜地址如下:

 

relativeName_ = Helloworld

 

看看ExtendedTypeInfo

三个变量的名字分别是:name helloable name

 

instanceSize_ = 0x20 占用32个字节

三个成员变量,三个指针,每个指针占用8个字节,这里为啥是32个字节?

 

对象

返回的对象使用ObjHeader来表示

 

所有的Kotlin对象,都有一个成员叫 typeInfoOrMeta_,指向自己的类对象 (类比OC中的isa指针)

 

小结

对象创建的流程大概如下:

● 编译器根据类的定义生成类信息(TypeInfo),并放在全局数据段(__DATA_CONST.__const)

● 代码中遇到类实例化,则将代码翻译为以下几步

● 从数据段取到类信息

● 调用 AllocInstance 分配内存,分配内存的大小 从 TypeInfo 中获取

● 并把前 8 个字节的内容填写为TypeInfo的地址

● 调用class的构造方法初始化成员变量

 

方法调用

初始化方法

会先调用父类的构造方法

 

父类设置name属性

 

子类设置name属性

 

 

greet方法

直接调用函数,并且把上面初始化的对象作为第一个参数传入

 

重载greetPrefix

查看函数,greetPrefix 有三个方法,除了父类与子类的实现个一个外,还有一个trampoline的辅助函数

打断点看到,第一个参数也是我们上面创建的对象实例

逻辑如下:

通过对象(读取前八个字节里的地址对应的内容)获取对应的类对象(TypeInfo)

类对象中有虚函数表,虚函数表里有函数地址,通过偏移读取到目标函数的地址,然后跳转到具体的函数

 

查看虚函数表

由于我们没有重载toString等方法的实现,所以虚函数表中对应的函数地址是父类的函数的地址。

 

 

与OC交互

重新将产物编译为 framework

kotlinc-native Hello.kt -g -Xno-inline -Xstatic-framework -p framework -o Hello

 

得到如下产物:

 

可以集成到 Xcode 中运行了

 

生成的头文件也都可以调用了

 

main函数

在二进制的符号表中搜索#main,发现多了一个包装函数

 

objc2kotlin_kfun:#main(){} 中会调用 kfun:#main(){}

 

objc2kotlin_kfun:#main(){} 函数不对应任何一句原代码,完全由编译器生成

objc2kotlin前缀表示 从objc调用kotlin的函数

 

kfun:#main(){} 的逻辑不变(创建对象,方法调用)

 

 

成员方法

查看二进制文件符号,发现每个成员方法也生成了对应的objc2kotlin前缀的函数,例如:

 

在KN编译代码中找到了"objc2kotlin"

编译器如何通过生成 objc2kotlin_ 前缀的方法?

 

objc2kotlin_xxx方法是在OC侧调用,所以其接受的参数以及返回值都是 OC 的对象

objc2kotlin_xxx 要"复用" xxx(KN kotlin 方法) 的逻辑

 

根据相关位置的代码以及编译结果看,大概的流程为以下三步:

 

1.  转换 OC对象 -> Kotlin对象(struct ObjHeader)

2.  调用kotlin函数,获得Kotlin对象的返回值

3.  将返回值转回 OC 对象(还有其他分支,暂不关心)

 

 

比如:

 

由于是init方法,返回值就是传入的self对象,所以不需要第三部转换,可以通过下面这个函数来看下返回值的情况

 

 

 

OC Kotlin 交互

Kotlin_ObjCExport_refFromObjC

查看源码可知是调用objc_msgSend,target是obj,SEL 是toKotlin:

 

 

任意一个对象能响应这个方法吗?

可以响应,runtime给NSObject添加了分类方法

 

当然对于 OC 的原生类有具体的处理:

 

还有一个特殊的KotlinBase的类,是 所有从Kotlin生成的OC的类的 基类

比如:HelloHello,是从Kotlin的Hello类生成的,其基类就是 KotlinBase

这个类有一个特殊的成员变量 refHolder

在toKotlin:中,返回值就是从refHolder中获取的,所以需要看下这个refHolder什么时候赋值的,其内容是什么?

 

情况1

● 94:分配内存,创建 OC 的实例对象(Oojb)

● 96:获取到HelloHello类对应的Kotlin Hello类的TypeInfo

○ OC的HelloHello 是 编译器根据 Hello 生成的,编译器跟runtime有办法获取到,具体获取方法暂不介绍

●  109:通过 Hello类的TypeInfo,创建一个Hello类的实例对象(Kobj),并把 Oobj 绑定到 Kobj 上

○ 绑定:通过Kobj可以轻松的(成本很低的)获取到Oobj

● 112:把 Kobj 放在 Oobj 的 refHolder 成员变量里

 

串一下这段代码后面发生的事情:

 

● alloc 过程:有两个分配内存的过程(创建了两个对象),OC 侧跟Kotlin侧个创建了一个对象,并且把两个对象绑定起来了(互相持有引用),通过其中任意一个对象,可以轻松获取到对侧的对象

● init 过程:给上述Alloc获取的对象发消息,SEL='initWithName',对应的实现是objc2kotlin_kfun:Hello#<init>(kotlin.String){}函数,函数中self对象跟NSString 通过 toKotlin: 转换为 Kotlin 侧的对象,并作为调用 kfun:Hello#<init>(kotlin.String){}函数的参数

 

问题

上述过程好像只是把Kobj初始化了,Oobj初始化成功了吗?

Oobj有成员变量吗?

 

output:

ivar count = 0; property count = 0; method count = 8;

 

其实HelloHello这个类没有实例变量,其父类有一个成员变量:refHolder

 

可以认为OC 侧的对象是 Kotlin侧对象的一个包装,自己不保存任何数据,对OC 侧做的操作(函数调用)都完全转发给Kotlin侧来实现

● OC函数是对Kotlin函数的包装

● OC对象是对Kotlin对象的包装

 

 

情况2

Kotlin_ObjCExport_refToRetainedObjC

● 462:获取Kobj绑定的Oobj

○ 之前绑定过,获取成功直接返回

● 下面的逻辑:需要走新建对象并绑定

○ 1. 通过TypeInfo获取到对应的OC class

○ 2. 调用 createRetainedWrapper 方法

● 分配内存,创建 Oobj

● 双向绑定

 

下面的情况属于这种情况,先创建Kotlin对象,然后创建OC对象

 

情况3

不是从Kotlin class 生成的OC 的类的实例对象转Kotlin对象

● MyObject 从Kotlin 的class生成的

● 如果有一个类集成了 'HelloHello',这个类也不是从Kotlin class 映射过来的

转Kotlin对象走Kotlin_ObjCExport_convertUnmappedObjCObject

动态生成一个TypeInfo,然后单向绑定,只有从Kotlin对象可以找到OC对象

 

动态生成一个TypeInfo里会根据父类,遵守的协议设置虚函数表(这些虚标是编译器提前为每个类型准备好的),对应的实现函数是 以kotlin2objc为前缀的这些函数:

 

这些函数是跟上面介绍的objc2kotlin的作用相反

● kotlin2objc函数 在Kotlin侧调用(绑定在TypeInfo的虚表中);objc2kotlin函数在OC侧调用,绑定在OC类对应的SEL上

● kotlin2objc 将参数转为OC对象,然后给OC对象发消息,获取返回值转为Kotlin对象

● objc2kotlin 将参数转为Kotlin对象,然后调用Kotlin的函数,获取返回值转为OC对象

 

 

 

背后的过程是

● OC_37

○ 创建 MyObject 的实例对象 O1

○ 调用 h1 的 setHelloable 的方法 参数是 h1(self) SEL O1 函数对应的实现是 objc2kotlin_kfun:Hello#<set-helloable>(HelloAble?){}

○ objc2kotlin_kfun:Hello#<set-helloable>(HelloAble?){} 将 h1 跟 O1 转为 对应的 Kotlin 对象 Kh1 KO1

○ h1 -> Kh1 的转换读取refHolder即可,因为 h1 是通过 createRetainedWrapper 方法创建的,创建的时候已经绑定了kotlin对象

○ O1 -> KO1 的过程: 通过 Kotlin_ObjCExport_convertUnmappedObjCObject实现,过程中会动态创建 TypeInfo,通过 O1 遵守的HelloHelloAble协议(OC的runtime可以获取到) 找到Kotlin侧 HelloAble 协议对应的虚表,并设置到动态创建的TypeInfo上,然后通过创建的TypeInfo创建一个 KO1 对象,

○ 然后将 KO1 设置到 Kh1 对应的成员变量上

● OC_38

○ 调用 h1 的 triggleInterface 方法,方法的实现是 objc2kotlin_kfun:Hello#triggleInterface(){}

○ 获取 h1 对应的 Kh1

○ 调用 kotlin 侧的 triggleInterface 方法

● Kotlin_18

○ 读取 Kh1 的成员变量helloable,读取到的对象是 KO1

○ 调用 KO1 makeIt 方法 kfun:HelloAble#makeIt(){}kotlin.String-trampoline -trampoline 后缀的方法是需要通过 TypeInfo 读虚表(此TypeInfo是上面动态创建的,创建的时候设置好了虚表)来确定调用最终的函数为kotlin2objc_kfun:HelloAble#makeIt(){}kotlin.String

○ kotlin2objc_kfun:HelloAble#makeIt(){}kotlin.String 中会把 KO1 转为 O1 对象,调用 O1 对象的 makeIt 方法,通过msgSend 实现,获取到返回值通过toKotlin: 方法转为Kotlin侧的对象

 

总结

● KN 处理 Kotlin 的逻辑整体跟C++的方式比较类似

● refHolder、toKotlin:、objc2kotlin_xx、kotlin2objc_xx 实现了与OC的交互

 

 

附录

objc2kotlin_xx 与 kotlin2objc_xx 方法如何绑定

● 每个class 每个 interface 编译器都会未其生成 TypeInfo

● 如果需要跟OC交互,TypeInfo中有个变量

● objc2kotlin_xx 与 kotlin2objc_xx 就存在这些列表里,runtime在运行时会根据类型匹配,动态绑定这些函数入口

● 在OC类的initialize方法中,会给OC类添加(通过 OC 的 runtime) directAdapters,classAdapters,implementedInterfaces_

● 在动态创建TypeInfo 的过程中,则会使用到kotlinVtable,kotlinItable,reverseAdapters

5.A.swift 使用指南

作者 JZXStudio
2025年10月24日 16:57

大家好,我是K哥。一名独立开发者,同时也是Swift开发框架【Aquarius】的作者,悦记爱寻车app的开发者。

Aquarius开发框架旨在帮助独立开发者和中小型团队,完成iOS App的快速实现与迭代。使用框架开发将给你带来简单、高效、易维护的编程体验。


Aquarius 是一个为 Swift 开发者打造的高效、轻量级开发框架,致力于提供简洁统一的 API 设计,帮助开发者快速构建高质量的 iOS 应用。本文将重点介绍其核心工具集 A.swift 的使用方法。

Aquarius 框架中的 A.swift(简称 A)是一个功能强大的工具箱,它将常用的开发功能封装为静态方法,覆盖了 UI 构建、颜色图片、数据存储、文件操作、事件管理、日志、内购等高频场景。使用 A,开发者可以告别繁琐的底层调用,大幅提升开发效率与代码整洁度。

一、A.swift 是什么?

A 是 Aquarius 框架中的核心便捷层,以命名空间(Namespace)的形式组织代码,提供了一系列静态属性和方法,让开发者能够通过类似 A.uiA.colorA.file 这样的语法快速调用功能模块。

其主要优势包括:

  • 统一入口:所有功能通过 A 访问,降低记忆成本
  • 类型安全:多数接口设计为类型安全或可选值,避免隐性崩溃
  • 功能完备:涵盖 UI、主题、存储、系统交互、日志、支付等常用场景
  • 现代并发支持:如 IAP 模块使用 async/await 封装,适配 Swift 并发编程

二、核心模块概览

下面我们简要介绍 A 中常用的子模块及其典型用途:

模块名 功能说明
A.ui 快速创建常用 UI 控件
A.color / A.image 主题色、系统图标与图片工具
A.userDefaults(_:) UserDefaults 便捷封装,支持 App Group
A.file 文件路径、目录与文件操作
A.calendarEvent 日历事件管理(基于 EventKit)
A.log 分级日志输出,支持 emoji 标识
A.iap 内购流程封装,基于 StoreKit 现代 API

三、实战演示:感受编码效率的飞跃

3.1 快速创建并添加按钮

传统方式

let button = UIButton(type: .system)
button.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)

使用A.swift

let button = A.ui.button
button.addTouchUpInsideBlock { [weak self] control in
    ...
}
let label = A.ui.label
let imageView = A.ui.imageView
let tableView = A.ui.tableView
...

告别不同UI控件创建方式的不同,统一UI控件创建方式。

3.2 使用主题色与系统图标

view.backgroundColor = A.color.blackColor
imageView.image = A.image.systemImage(systemName: "star.fill")

统一管理颜色与图标,轻松适配暗黑模式与主题切换。

3.3 读写 UserDefaults

//写入
A.userDefaults("group.com.jzx.app").forKey("username")
A.userDefaults("group.com.jzx.app").setValue("张三", forKey: "username")
//读取
let name: String? = A.userDefaults("group.com.jzx.app").getStringValue("username")

支持 App Group,并提供类型安全的读取接口。

3.4 文件操作

let path = A.file.pathFromDocuments("data/user.json")
if !A.file.isExist(path) {
    try? A.file.createFolder(at: "data")
}
// 写入文件...

封装常用文件操作方法,提升代码可读性。

3.5 创建日历事件

A.calendarEvent.add(title: "发布会", startDate: start, endDate: end) { result in
    switch result {
    case .success(let id): A.log.info("创建成功:\(id)")
    case .failure(let err): A.log.error("创建失败:\(err)")
    }
}

自动处理权限申请与事件添加,回调清晰。

3.6 分级日志

A.log.debug("用户点击按钮")
A.log.warning("网络请求超时")
A.log.error("解析失败:\(error)")

日志自带 emoji 和等级标识,调试更直观。

3.7 发起内购

Task {
    do {
        let products = try await A.iap.fetchProducts(["com.jzx.pro"])
        if let product = products.first {
            try await A.iap.purchase(product: product)
            A.log.info("购买成功")
        }
    } catch {
        A.log.error("购买失败:\(error)")
    }
}

基于现代 StoreKit API,支持 async/await,逻辑清晰。

四、最佳实践与注意事项

4.1 错误处理要到位

A 中多数可能出错的操作都会通过 Resultthrows 或可选值来表示失败,请务必处理这些情况,避免直接使用 try! 或强制解包。

4.2 权限管理不能忘

如使用 A.calendarEventA.reminderEvent,请确保已在 Info.plist 中添加相应权限说明,并在使用前检查授权状态。

4.3 线程安全需注意

涉及 UI 更新的操作请确保在主线程执行。A.iap 等异步方法已自动处理线程切换,但仍建议使用 MainActorDispatchQueue.main 更新界面。

4.4 结合 MVVM 架构

你可以在 ViewModel 中直接使用 A.fileA.userDefaultsA.iap 等模块,将平台相关代码与 UI 逻辑分离:

class SettingViewModel {
    func clearCache() {
        let cachePath = A.file.pathFromCaches("")
        try? A.file.removeItem(cachePath)
    }
}

五、总结

A.swift 作为 Aquarius 框架中的"瑞士军刀",极大地简化了 iOS 开发中常见的任务流程。无论是创建界面、管理数据、记录日志,还是处理内购和系统事件,A 都提供了简洁而强大的接口。

如果你正在寻找一个能提升开发效率、减少样板代码的 Swift 工具集,不妨试试 Aquarius 框架中的 A.swift


立即体验Aquarius:

第一步:探索资源

第二步:体验效果

  • 📱 下载示例APP悦记 | 爱寻车 - 感受真实项目中的流畅体验

第三步:沟通交流

🧩 iOS DiffableDataSource 死锁问题记录

作者 songgeb
2025年10月24日 16:49

本文提到的问题是实际项目中遇到的,但文章内容由ChatGPT完成,人工进行了review

🪪 错误信息

在使用 UITableViewDiffableDataSource / UICollectionViewDiffableDataSource 时,
调用 apply(_:animatingDifferences:completion:) 方法更新数据时出现了如下崩溃错误

Deadlock detected: calling this method on the main queue with outstanding async updates is not permitted and will deadlock. Please always submit updates either always on the main queue or always off the main queue

⚙️ 问题本质

  • 由于 apply() 本身是 异步执行 diff 计算与 UI 更新 的,如果在前一次 apply() 尚未完成时又调用了新的 apply(),UIKit 就会检测到潜在死锁并抛出上述错误。
  • “outstanding async updates” 表示仍在进行中的异步更新。 即上一次 diff 操作尚未完成,又发起了新的一次 diff

需要注意的是,虽然崩溃信息中提示是线程问题,但根据实际测试,即使所有调用都在主线程执行,也仍然可能发生此错误,因为 UIKit 内部的 diff 计算与视图更新是异步的。

错误代码以及错误原因如下所示:

import UIKit

class MyViewController: UIViewController {
    enum Section { case main }
    struct Item: Hashable { let id = UUID(); let title: String }

    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
    private var items: [Item] = [.init(title: "A")]

    override func viewDidLoad() {
        super.viewDidLoad()

        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: UICollectionViewFlowLayout())
        view.addSubview(collectionView)
        collectionView.register(MyCell.self, forCellWithReuseIdentifier: "cell")

        dataSource = UICollectionViewDiffableDataSource<Section, Item>(
            collectionView: collectionView
        ) { [weak self] collectionView, indexPath, item in
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyCell
            cell.label.text = item.title

            // 第二次通过apply刷新列表,❌ 错误:cell 回调中再次触发 apply
            cell.onTap = {
                guard let self = self else { return }
                self.items.append(.init(title: "New"))
                self.applySnapshot() // 上一次 apply 未完成时调用,可能触发错误
            }
            return cell
        }
        // 第一次通过apply刷新列表
        applySnapshot()
    }

    private func applySnapshot() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.main])
        snapshot.appendItems(items)
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}


🧭 解决思路

✅ 1. 防止重入(推荐)

使用标志位,确保同一时间只执行一次 apply(),并缓存后续请求:

private var isApplyingSnapshot = false
private var pendingSnapshot: NSDiffableDataSourceSnapshot<Section, Item>?

func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>) {
    guard !isApplyingSnapshot else {
        pendingSnapshot = snapshot
        return
    }
    isApplyingSnapshot = true

    dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
        guard let self = self else { return }
        self.isApplyingSnapshot = false
        if let next = self.pendingSnapshot {
            self.pendingSnapshot = nil
            self.applySnapshot(next)
        }
    }
}

并可以根据该思想封装一个防止重入的类

import UIKit

/// 一个安全的 DiffableDataSource 快照更新管理器
/// 支持自动排队多次 apply,防止死锁与丢帧
final class SafeDiffableApplier<Section: Hashable, Item: Hashable> {
    private let dataSource: UITableViewDiffableDataSource<Section, Item>
    private var isApplying = false
    private var queue: [QueuedSnapshot] = []

    private struct QueuedSnapshot {
        let snapshot: NSDiffableDataSourceSnapshot<Section, Item>
        let animatingDifferences: Bool
        let completion: (() -> Void)?
    }

    init(dataSource: UITableViewDiffableDataSource<Section, Item>) {
        self.dataSource = dataSource
    }

    /// 安全地应用快照(自动排队,避免死锁)
    func apply(
        _ snapshot: NSDiffableDataSourceSnapshot<Section, Item>,
        animatingDifferences: Bool = true,
        completion: (() -> Void)? = nil
    ) {
        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }
            let task = QueuedSnapshot(snapshot: snapshot, animatingDifferences: animatingDifferences, completion: completion)
            self.queue.append(task)
            self.processNextIfNeeded()
        }
    }

    /// 按顺序依次执行队列中的快照更新
    private func processNextIfNeeded() {
        guard !isApplying, !queue.isEmpty else { return }
        isApplying = true

        let next = queue.removeFirst()
        dataSource.apply(next.snapshot, animatingDifferences: next.animatingDifferences) { [weak self] in
            guard let self = self else { return }
            next.completion?()
            self.isApplying = false
            self.processNextIfNeeded() // 递归继续下一个
        }
    }
}

✅ 2. 合并或节流更新

如果更新非常频繁,可以合并多次变化后再统一 apply()

func scheduleSnapshotUpdate() {
    pendingWorkItem?.cancel()
    let workItem = DispatchWorkItem { [weak self] in
        guard let self = self else { return }
        let snapshot = self.generateSnapshot()
        self.dataSource.apply(snapshot, animatingDifferences: true)
    }
    pendingWorkItem = workItem
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
}

🧠 总结

  • apply()异步 的,不可重复调用。
  • 错误提示的 “outstanding async updates” 即代表上一次 diff 尚未完成。
  • 必须串行化更新操作,或合并多次更新。
  • 仅仅在主线程调度(DispatchQueue.main.async) 并不能根本解决问题。

参考


《Flutter全栈开发实战指南:从零到高级》- 06 -常用布局组件

2025年10月24日 10:04

Flutter常用布局

1. 引言:为什么布局系统如此重要?

比方说你要装修一间房子:你需要规划每个房间的位置、大小,考虑家具的摆放,确保空间利用合理且美观。Flutter的布局系统就是你在数字世界中的"室内设计师",它决定了每个UI元素的位置、大小和相互关系。

一个好的布局应该具备:

  • 精确的元素定位
  • 兼容自适应屏幕
  • 渲染性能高效
  • 视觉层次美观

今天就带你详细介绍Flutter的常用布局,让你熟练掌握布局系统~~~

2. Container:万能的布局容器

它是最基础也是最强大的布局组件之一。 71bbb7f5f8f06c5f6fe93e939eee55b8.png

2.1 Container的基本用法

Container(
  width: 200,                    // 设置宽度
  height: 100,                   // 设置高度
  color: Colors.blue,            // 背景颜色
  child: Text('Hello Flutter'),  // 子组件
)

这就像给文字套上了一个蓝色的相框,简单直接。

2.2 Container的装饰功能

但Container的真正威力在于它的装饰能力:

Container(
  width: 200,
  height: 100,
  decoration: BoxDecoration(
    color: Colors.white,                     // 背景色
    borderRadius: BorderRadius.circular(16), // 圆角
    boxShadow: [                             // 阴影
      BoxShadow(
        color: Colors.black12,
        blurRadius: 10,
        offset: Offset(0, 4),
      ),
    ],
    border: Border.all(                    // 边框
      color: Colors.blue,
      width: 2,
    ),
    gradient: LinearGradient(              // 渐变背景
      colors: [Colors.blue, Colors.purple],
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
    ),
  ),
  child: Center(
    child: Text(
      '精美的容器',
      style: TextStyle(
        color: Colors.white,
        fontWeight: FontWeight.bold,
      ),
    ),
  ),
)

Container的核心属性:

  • width / height:控制尺寸
  • margin:外边距,与其他组件的距离
  • padding:内边距,内容与边框的距离
  • decoration:装饰效果(颜色、边框、阴影等)
  • constraints:尺寸约束

2.3 实际应用场景

下面以具体的实际开发场景为例,带大家深入了解Container组件

场景1:用户头像容器

Container(
  width: 80,
  height: 80,
  decoration: BoxDecoration(
    color: Colors.grey[200],
    borderRadius: BorderRadius.circular(40), // 圆形
    border: Border.all(color: Colors.blue, width: 2),
    image: DecorationImage(
      image: NetworkImage('https://example.com/avatar.jpg'),
      fit: BoxFit.cover,
    ),
  ),
)

场景2:消息气泡

Container(
  constraints: BoxConstraints(
    maxWidth: 250,  // 最大宽度限制
  ),
  padding: EdgeInsets.all(12),
  decoration: BoxDecoration(
    color: Colors.blue[50],
    borderRadius: BorderRadius.only(
      topLeft: Radius.circular(16),
      topRight: Radius.circular(16),
      bottomRight: Radius.circular(4),
    ),
  ),
  child: Text('这是一条消息内容'),
)

3. Padding和Margin

Padding和Margin就像人与人之间的安全距离,它们控制着组件之间的空间关系,但作用对象不同。

3.1 Padding:内部空间

Padding是组件内容与边框之间的距离,好比相框与照片之间的留白:

Container(
  color: Colors.blue,
  child: Padding(
    padding: EdgeInsets.all(16),  // 四周都留16像素的空白
    child: Text(
      '有呼吸空间的文字',
      style: TextStyle(color: Colors.white),
    ),
  ),
)

EdgeInsets的四种用法:

// 1. 统一间距
EdgeInsets.all(16)

// 2. 分别设置上下左右
EdgeInsets.fromLTRB(10, 20, 10, 20)

// 3. 设置水平和垂直
EdgeInsets.symmetric(horizontal: 10, vertical: 20)

// 4. 只设置一边
EdgeInsets.only(left: 10, top: 5)

3.2 Margin:外部安全距离

Margin是组件与其他组件之间的距离,就像两个人谈话时的舒适距离:

Container(
  width: 100,
  height: 100,
  color: Colors.red,
  margin: EdgeInsets.all(20),  // 四周都保持20像素的距离
  child: Text('我有外边距'),
)

3.3 实际应用:卡片布局

Container(
  margin: EdgeInsets.all(16),      // 卡片与其他组件的距离
  padding: EdgeInsets.all(20),     // 卡片内容与边框的距离
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Colors.black12,
        blurRadius: 8,
      ),
    ],
  ),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text(
        '产品标题',
        style: TextStyle(
          fontSize: 18,
          fontWeight: FontWeight.bold,
        ),
      ),
      SizedBox(height: 8),         // 文字之间的间距
      Text('产品描述信息...'),
    ],
  ),
)

4. Row和Column:线性布局

Row是横向布局,Column是竖向布局。它们让组件按照线性方式排列,是使用频率最高的布局组件。

4.1 Row:水平排列

Row让子组件水平排列,就像我们生活中排队买票的人群: 97750a37af503adc2ed73b58323a389f.png

Row(
  children: [
    Icon(Icons.star, color: Colors.orange),
    Icon(Icons.star, color: Colors.orange),
    Icon(Icons.star, color: Colors.orange),
    Icon(Icons.star_border, color: Colors.grey),
    Icon(Icons.star_border, color: Colors.grey),
  ],
)

Row的核心属性:

  • mainAxisAlignment:主轴对齐方式(水平方向)
  • crossAxisAlignment:交叉轴对齐方式(垂直方向)
  • mainAxisSize:主轴尺寸

4.2 主轴对齐方式

Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween, // 两端对齐,均匀分布
  children: [
    Container(width: 50, height: 50, color: Colors.red),
    Container(width: 50, height: 50, color: Colors.green),
    Container(width: 50, height: 50, color: Colors.blue),
  ],
)

MainAxisAlignment的选项:

  • start:左对齐
  • end:右对齐
  • center:居中对齐
  • spaceBetween:两端对齐,组件间隔相等
  • spaceAround:每个组件两侧间隔相等
  • spaceEvenly:组件间隔与边框间隔都相等

4.3 Column:垂直排列

Column让子组件垂直排列,就像叠放的一摞书籍: f397e34988953bbbdb8ad0d9a937f65d.png

Column(
  crossAxisAlignment: CrossAxisAlignment.start, // 左对齐
  children: [
    Text('标题', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
    SizedBox(height: 8),
    Text('副标题', style: TextStyle(fontSize: 16, color: Colors.grey)),
    SizedBox(height: 16),
    Text('内容描述...'),
  ],
)

4.4 实际应用:用户信息卡片

Row(
  children: [
    // 头像
    Container(
      width: 60,
      height: 60,
      decoration: BoxDecoration(
        color: Colors.grey[300],
        borderRadius: BorderRadius.circular(30),
      ),
      child: Icon(Icons.person, size: 30, color: Colors.grey[600]),
    ),
    
    // 间距
    SizedBox(width: 16),
    
    // 用户信息
    Expanded(  // 占据剩余空间
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('张小明', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
          SizedBox(height: 4),
          Text('高级用户体验设计师', style: TextStyle(color: Colors.grey[600])),
          SizedBox(height: 4),
          Text('2小时前在线', style: TextStyle(color: Colors.green, fontSize: 12)),
        ],
      ),
    ),
    
    // 右侧图标
    Icon(Icons.chevron_right, color: Colors.grey[400]),
  ],
)

5. Flex和Expanded:弹性布局

举个例子:Flex和Expanded就像弹簧和橡皮筋,它们让布局具有弹性,能够根据可用空间自动调整。

5.1 Flex布局基础

Flex是Row和Column的父类,提供了更灵活的布局方式: f6851a8990489db85a134b5058f86981.png

Flex(
  direction: Axis.horizontal,  // 水平排列,相当于Row
  children: [
    // 子组件
  ],
)

5.2 Expanded:占据剩余空间

Expanded让子组件占据剩余空间,就像弹簧可以拉伸:

Row(
  children: [
    Container(
      width: 80,
      height: 50,
      color: Colors.red,
    ),
    Expanded(  // 占据剩余的所有水平空间
      child: Container(
        height: 50,
        color: Colors.blue,
        child: Center(child: Text('弹性区域')),
      ),
    ),
  ],
)

5.3 Flexible:尺寸控制

Flexible提供更精细的弹性控制:

Row(
  children: [
    Flexible(
      flex: 1,  // 权重为1
      child: Container(height: 50, color: Colors.red),
    ),
    Flexible(
      flex: 2,  // 权重为2,占据两倍的空间
      child: Container(height: 50, color: Colors.green),
    ),
    Flexible(
      flex: 1,  // 权重为1
      child: Container(height: 50, color: Colors.blue),
    ),
  ],
)

Flexible vs Expanded:

  • Expanded是Flexible(fit: FlexFit.tight)的简写
  • Flexible默认是FlexFit.loose,子组件可以选择不填满空间
  • Expanded强制子组件填满空间

5.4 实际应用:比例布局

Column(
  children: [
    // 标题栏
    Container(
      height: 60,
      color: Colors.blue,
      child: Center(child: Text('仪表盘', style: TextStyle(color: Colors.white))),
    ),
    
    // 内容区域(占据剩余空间)
    Expanded(
      child: Row(
        children: [
          // 侧边栏(固定宽度)
          Container(
            width: 200,
            color: Colors.grey[100],
            child: ListView(
              children: [
                ListTile(title: Text('菜单项1')),
                ListTile(title: Text('菜单项2')),
                ListTile(title: Text('菜单项3')),
              ],
            ),
          ),
          
          // 主内容区(占据剩余空间)
          Expanded(
            child: Container(
              color: Colors.white,
              child: Center(child: Text('主内容区域')),
            ),
          ),
        ],
      ),
    ),
    
    // 底部栏
    Container(
      height: 50,
      color: Colors.grey[800],
      child: Center(child: Text('版权所有 © 2024', style: TextStyle(color: Colors.white))),
    ),
  ],
)

6. Stack:层叠布局

Stack好比透明的幻灯片,可以让多个组件重叠在一起,组合出丰富的页面视觉效果。 f4cc635cca69f396ef656e89d5e5a1d0.png

6.1 Stack基础用法

Stack(
  children: [
    // 底层背景
    Container(
      width: 200,
      height: 200,
      color: Colors.blue,
    ),
    
    // 中间层
    Positioned(
      top: 20,
      left: 20,
      child: Container(
        width: 100,
        height: 100,
        color: Colors.red,
      ),
    ),
    
    // 顶层
    Positioned(
      bottom: 20,
      right: 20,
      child: Container(
        width: 80,
        height: 80,
        color: Colors.green,
      ),
    ),
  ],
)

6.2 Positioned:精确定位

Positioned用于在Stack中精确定位子组件:

Positioned(
  top: 10,      // 距离顶部10像素
  left: 20,     // 距离左边20像素
  right: 30,    // 距离右边30像素
  bottom: 40,   // 距离底部40像素
  child: Container(color: Colors.orange),
)

6.3 Alignment:相对定位

除了Positioned,还可以使用Alignment进行相对定位:

Stack(
  alignment: Alignment.center,  // 所有子组件默认居中对齐
  children: [
    Container(width: 200, height: 200, color: Colors.blue),
    Container(
      width: 100,
      height: 100,
      color: Colors.red,
    ),
    Align(  // 单独设置对齐方式
      alignment: Alignment.bottomRight,
      child: Container(
        width: 50,
        height: 50,
        color: Colors.green,
      ),
    ),
  ],
)

6.4 实际应用:用户头像徽章

Stack(
  children: [
    // 用户头像
    Container(
      width: 80,
      height: 80,
      decoration: BoxDecoration(
        color: Colors.grey[300],
        borderRadius: BorderRadius.circular(40),
      ),
      child: Icon(Icons.person, size: 40, color: Colors.grey[600]),
    ),
    
    // 在线状态指示器
    Positioned(
      bottom: 0,
      right: 0,
      child: Container(
        width: 20,
        height: 20,
        decoration: BoxDecoration(
          color: Colors.green,
          borderRadius: BorderRadius.circular(10),
          border: Border.all(color: Colors.white, width: 2),
        ),
      ),
    ),
    
    // VIP徽章
    Positioned(
      top: 0,
      right: 0,
      child: Container(
        padding: EdgeInsets.all(4),
        decoration: BoxDecoration(
          color: Colors.orange,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          'VIP',
          style: TextStyle(
            color: Colors.white,
            fontSize: 10,
            fontWeight: FontWeight.bold,
          ),
        ),
      ),
    ),
  ],
)

7. 响应式布局设计:适配各种屏幕

响应式布局能够根据不同的屏幕尺寸自动调整布局,提升用户体验。

7.1 MediaQuery:获取屏幕信息

MediaQuery可以获取屏幕尺寸、方向等信息:

class ResponsiveLayout extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 获取屏幕尺寸
    final screenWidth = MediaQuery.of(context).size.width;
    final screenHeight = MediaQuery.of(context).size.height;
    
    // 判断屏幕方向
    final isPortrait = MediaQuery.of(context).orientation == Orientation.portrait;
    
    return Container(
      width: screenWidth,
      height: screenHeight,
      color: Colors.grey[200],
      child: Center(
        child: Text(
          '屏幕尺寸: ${screenWidth.toInt()} × ${screenHeight.toInt()}\n'
          '方向: ${isPortrait ? '竖屏' : '横屏'}',
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

7.2 LayoutBuilder:根据约束调整布局

LayoutBuilder可以根据父组件的约束动态调整布局:

LayoutBuilder(
  builder: (context, constraints) {
    // 根据可用宽度决定布局方式
    if (constraints.maxWidth > 600) {
      // 宽屏布局
      return Row(
        children: [
          Container(width: 200, color: Colors.blue, child: Text('侧边栏')),
          Expanded(child: Container(color: Colors.green, child: Text('主内容'))),
        ],
      );
    } else {
      // 窄屏布局
      return Column(
        children: [
          Container(height: 100, color: Colors.blue, child: Text('顶部导航')),
          Expanded(child: Container(color: Colors.green, child: Text('主内容'))),
        ],
      );
    }
  },
)

7.3 实际应用:响应式仪表盘

class ResponsiveDashboard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('响应式仪表盘')),
      body: LayoutBuilder(
        builder: (context, constraints) {
          final isWideScreen = constraints.maxWidth > 768;
          
          return Row(
            children: [
              // 侧边栏(在宽屏显示,窄屏隐藏)
              if (isWideScreen)
                Container(
                  width: 250,
                  color: Colors.grey[100],
                  child: ListView(
                    children: [
                      ListTile(title: Text('仪表盘')),
                      ListTile(title: Text('用户管理')),
                      ListTile(title: Text('数据分析')),
                      ListTile(title: Text('系统设置')),
                    ],
                  ),
                ),
              
              // 主内容区域
              Expanded(
                child: Container(
                  padding: EdgeInsets.all(16),
                  child: GridView.count(
                    // 根据屏幕宽度调整列数
                    crossAxisCount: isWideScreen ? 3 : 2,
                    crossAxisSpacing: 16,
                    mainAxisSpacing: 16,
                    children: [
                      _buildStatCard('用户数', '1,234', Colors.blue),
                      _buildStatCard('订单数', '567', Colors.green),
                      _buildStatCard('收入', '\$8,901', Colors.orange),
                      _buildStatCard('增长率', '12.3%', Colors.purple),
                      _buildStatCard('满意度', '98%', Colors.red),
                      _buildStatCard('活跃度', '87%', Colors.teal),
                    ],
                  ),
                ),
              ),
            ],
          );
        },
      ),
      
      // 窄屏时显示底部导航
      bottomNavigationBar: constraints.maxWidth <= 768 ? BottomNavigationBar(
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.dashboard), label: '首页'),
          BottomNavigationBarItem(icon: Icon(Icons.people), label: '用户'),
          BottomNavigationBarItem(icon: Icon(Icons.settings), label: '设置'),
        ],
      ) : null,
    );
  }
  
  Widget _buildStatCard(String title, String value, Color color) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.black12,
            blurRadius: 6,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            value,
            style: TextStyle(
              fontSize: 24,
              fontWeight: FontWeight.bold,
              color: color,
            ),
          ),
          SizedBox(height: 8),
          Text(
            title,
            style: TextStyle(
              color: Colors.grey[600],
            ),
          ),
        ],
      ),
    );
  }
}

8. 实战案例:用户资料卡片页面

让我们把所有知识融合起来,创建一个完整的用户资料卡片:

class UserProfileCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.all(16),
      padding: EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        boxShadow: [
          BoxShadow(
            color: Colors.black12,
            blurRadius: 10,
            offset: Offset(0, 4),
          ),
        ],
      ),
      child: Column(
        children: [
          // 头部:头像和基本信息
          Row(
            children: [
              // 头像区域(带徽章)
              Stack(
                children: [
                  Container(
                    width: 80,
                    height: 80,
                    decoration: BoxDecoration(
                      color: Colors.blue[100],
                      borderRadius: BorderRadius.circular(40),
                      image: DecorationImage(
                        image: NetworkImage('https://example.com/avatar.jpg'),
                        fit: BoxFit.cover,
                      ),
                    ),
                  ),
                  // 在线状态
                  Positioned(
                    bottom: 0,
                    right: 0,
                    child: Container(
                      width: 20,
                      height: 20,
                      decoration: BoxDecoration(
                        color: Colors.green,
                        borderRadius: BorderRadius.circular(10),
                        border: Border.all(color: Colors.white, width: 2),
                      ),
                    ),
                  ),
                ],
              ),
              
              // 间距
              SizedBox(width: 16),
              
              // 用户信息
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      '张小明',
                      style: TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    SizedBox(height: 4),
                    Text(
                      '高级用户体验设计师',
                      style: TextStyle(
                        color: Colors.grey[600],
                      ),
                    ),
                    SizedBox(height: 8),
                    Row(
                      children: [
                        Icon(Icons.location_on, size: 16, color: Colors.grey),
                        SizedBox(width: 4),
                        Text(
                          '北京市海淀区',
                          style: TextStyle(fontSize: 12, color: Colors.grey),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              
              // 更多操作按钮
              IconButton(
                icon: Icon(Icons.more_vert, color: Colors.grey),
                onPressed: () {},
              ),
            ],
          ),
          
          // 分隔线
          Padding(
            padding: EdgeInsets.symmetric(vertical: 16),
            child: Divider(height: 1, color: Colors.grey[300]),
          ),
          
          // 统计信息
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildStatItem('关注', '234'),
              _buildStatItem('粉丝', '1.2k'),
              _buildStatItem('作品', '56'),
              _buildStatItem('点赞', '3.4k'),
            ],
          ),
          
          // 分隔线
          Padding(
            padding: EdgeInsets.symmetric(vertical: 16),
            child: Divider(height: 1, color: Colors.grey[300]),
          ),
          
          // 操作按钮
          Row(
            children: [
              Expanded(
                child: ElevatedButton(
                  onPressed: () {},
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.blue,
                    foregroundColor: Colors.white,
                  ),
                  child: Text('关注'),
                ),
              ),
              SizedBox(width: 12),
              Expanded(
                child: OutlinedButton(
                  onPressed: () {},
                  child: Text('发消息'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
  
  Widget _buildStatItem(String label, String value) {
    return Column(
      children: [
        Text(
          value,
          style: TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.bold,
          ),
        ),
        SizedBox(height: 4),
        Text(
          label,
          style: TextStyle(
            fontSize: 12,
            color: Colors.grey[600],
          ),
        ),
      ],
    );
  }
}

9. 性能优化与最佳实践

9.1 布局性能优化

  1. 避免过度嵌套

    // ❌ 不好的做法:过度嵌套
    Container(
      child: Padding(
        padding: EdgeInsets.all(10),
        child: Container(
          child: Center(
            child: Text('Hello'),
          ),
        ),
      ),
    )
    
    // ✅ 好的做法:使用Container的padding属性
    Container(
      padding: EdgeInsets.all(10),
      child: Center(
        child: Text('Hello'),
      ),
    )
    
  2. 使用const构造函数

    // ✅ 好的做法:使用const
    const Text('静态文本')
    
    // ❌ 不好的做法:不使用const
    Text('静态文本')
    

9.2 代码优化

  1. 提取重复布局

    // 提取为独立组件
    Widget _buildListItem(String title, String subtitle) {
      return ListTile(
        title: Text(title),
        subtitle: Text(subtitle),
        trailing: Icon(Icons.chevron_right),
      );
    }
    
  2. 使用扩展方法

    extension PaddingExtension on Widget {
      Widget withPadding(EdgeInsets padding) {
        return Padding(padding: padding, child: this);
      }
    }
    
    // 使用
    Text('Hello').withPadding(EdgeInsets.all(16))
    

当然还有很多其他优化的点,这里就不一一介绍了,需要大家花时间去一步步摸索尝试~

10. 知识点总结

1e81305d8cf662284c242d04e69e3050.png

通过今天的学习,我们掌握了Flutter布局系统的核心概念:

1. 基础容器:

  • Container:万能的布局容器,支持装饰效果
  • Padding:控制内部间距
  • Margin:控制外部间距

2. 线性布局:

  • Row:水平排列组件
  • Column:垂直排列组件
  • Flex / Expanded:弹性布局,按比例分配空间

3. 层叠布局:

  • Stack:组件重叠布局
  • Positioned:在Stack中精确定位
  • Align:相对对齐定位

4. 响应式设计:

  • MediaQuery:获取屏幕信息
  • LayoutBuilder:根据约束动态布局
  • 断点设计和方向适配

重点:布局设计思维

  1. 从外到内:先确定整体结构,再细化内部组件
  2. 优先使用简单布局:能用Row/Column解决的问题不要用复杂布局
  3. 考虑扩展性:设计时要考虑不同屏幕尺寸和内容变化
  4. 性能意识:避免过度嵌套,合理使用const

写在最后的话

好的布局就像好的建筑,不仅要美观,更要实用和稳固。

布局设计是一个需要不断练习和实践的过程。多观察优秀的App界面,思考它们的布局方式,然后用自己的代码实现出来。很快你就会发现,面对任何UI设计稿,你都能轻松地用Flutter实现出来!

如果这篇教程对你有帮助,请给我点个赞 👍 支持一下! 有什么布局方面的疑问?欢迎在评论区留言讨论~Happy Coding! ✨

Swift 方法全解:实例方法、mutating 方法与类型方法一本通

作者 unravel2025
2025年10月24日 09:52

前言

官方文档已经把语法和规则写得足够严谨,但初学者常遇到三个卡点:

  1. 结构体/枚举居然也能定义方法?
  2. mutating 到底“变异”了什么?
  3. static 与 class 关键字在类型方法里的区别与实战意义。

方法(Method)到底是什么

一句话:方法是“挂在某个类型上的函数”。

  • 在 Swift 里,类(class)、结构体(struct)、枚举(enum)都能挂函数,有两大类,分别叫做实例方法或类型方法。

  • 与 C/Objective-C 不同,C 只有函数指针,Objective-C 只有类能定义方法;Swift 把“方法”能力下放到了值类型,带来了更灵活的建模方式。

实例方法(Instance Method)

  1. 定义与调用
class Counter1 {
    var count = 0
    
    // 实例方法:默认访问全部实例成员
    func increment() {
        count += 1
    }
    
    // 带参数的方法
    func increment(by amount: Int) {
        count += amount
    }
    
    func reset() {
        count = 0
    }
}

// 调用
let counter = Counter1()
counter.increment()          // 1
print(counter.count)
counter.increment(by: 5)     // 6
print(counter.count)
counter.reset()              // 0
print(counter.count)
  1. self 的隐式与显式
  • 不写 self:编译器默认你访问的是“当前实例”成员。
  • 必须写 self:局部变量/参数与属性重名时,用来消歧。
struct Point {
    var x = 0.0, y = 0.0
    
    func isToTheRightOf(x: Double) -> Bool {
        // 如果省略 self,x 会被当成参数 x
        return self.x > x
    }
}

值类型内部修改自身:mutating 实例方法

  1. 默认禁止修改

结构体/枚举是值类型,实例方法里不能改自己属性——除非加 mutating

  1. 加 mutating 后发生了什么
  • 方法被标记为“会改本体”,编译器会把调用处生成的 let 常量拦截掉。
  • 底层实现:方法拿到的是 inout self,可以整体替换。
struct Point: CustomStringConvertible {
    var description: String {
        "{ x: \(x), y: \(y)}"
    }
    
    var x = 0.0, y = 0.0
    
    // 移动自身
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {
        x += deltaX
        y += deltaY
    }
    
    // 更激进的写法:直接给 self 赋新实例
    mutating func teleport(toX x: Double, toY y: Double) {
        self = Point(x: x, y: y)
    }
}

var p = Point(x: 1, y: 1)
print(p)
p.moveBy(x: 2, y: 3)   // 现在 (3,4)
print(p)
p.teleport(toX: 0, toY: 0) // 整体替换为 (0,0)
print(p)

// 以下会编译错误
let fixedPoint = Point(x: 3, y: 3)
print(fixedPoint)
// fixedPoint.moveBy(x: 1, y: 1) // ❌
  1. 枚举也能 mutating
enum TriStateSwitch {
    case off, low, high
    
    mutating func next() {
        switch self {
        case .off: self = .low
        case .low: self = .high
        case .high: self = .off
        }
    }
}

var oven = TriStateSwitch.low
print(oven)
oven.next() // high
print(oven)
oven.next() // off
print(oven)

类型方法(Type Method)

  1. 关键字
  • static:类、结构体、枚举都能用;子类不能重写。
  • class:仅类能用;子类可 override。
  1. 调用方式

“类型名.方法名”,无需实例。

  1. 方法体内 self 指“类型自身”
class SomeClass {
    class func helloType() {
        print("Hello from \(self)") // 打印类名
    }
}
SomeClass.helloType()

完整实战:游戏关卡管理

下面把“类型属性 + 类型方法 + 实例方法”揉到一起,演示一个常用模式:

  • 类型层保存“全局状态”(最高解锁关卡)。
  • 实例层保存“个人状态”(当前玩家在第几关)。
struct LevelTracker {
    // 1. 类型属性:所有玩家共享
    nonisolated(unsafe) static var highestUnlockedLevel = 1
    
    // 2. 类型方法:解锁
    static func unlock(_ level: Int) {
        if level > highestUnlockedLevel {
            highestUnlockedLevel = level
        }
    }
    
    // 3. 类型方法:查询是否解锁
    static func isUnlocked(_ level: Int) -> Bool {
        return level <= highestUnlockedLevel
    }
    
    // 4. 实例属性:个人当前关卡
    var currentLevel = 1
    
    // 5. 实例方法(mutating):进阶到指定关
    @discardableResult
    mutating func advance(to level: Int) -> Bool {
        if LevelTracker.isUnlocked(level) {
            currentLevel = level
            return true
        }
        return false
    }
}

// 玩家类
class Player {
    let name: String
    var tracker = LevelTracker()
    
    init(name: String) {
        self.name = name
    }
    
    // 完成某关
    func complete(level: Int) {
        LevelTracker.unlock(level + 1)          // 全局解锁下一关
        tracker.advance(to: level + 1)          // 个人进度推进
    }
}

// 场景脚本
let player = Player(name: "Argyrios")
player.complete(level: 1)
print("最高解锁关卡:\(LevelTracker.highestUnlockedLevel)") // 2

// 第二个玩家想跳关
let player2 = Player(name: "Beto")
if player2.tracker.advance(to: 6) {
    print("直接跳到 6 成功")
} else {
    print("6 关尚未解锁") // 走进这里
}

易忘细节速查表

  1. mutating 只能用于 struct/enum;class 天生可变,不需要。
  2. static 与 class 区别:
    • 结构体/枚举只能用 static。
    • 类里 static = final class,不允许子类覆盖;class 允许覆盖。
  3. 在类型方法里调用同类类型方法/属性,可直接写名字,无需前缀类型。
  4. @discardableResult 用于“调用方可以不处理返回值”的场景,消除警告。

总结与实战扩展

  1. 方法不再“是类的专利”后,优先用 struct 建模数据,再根据需要升级成 class,可大幅降低引用类型带来的共享状态问题。
  2. mutating 让“值语义 + 链式调用”成为可能,例如:
extension Array where Element: Comparable {
    mutating func removeMin() -> Element? {
        guard let min = self.min() else { return nil }
        remove(at: firstIndex(of: min)!)
        return min
    }
}
var scores = [98, 67, 84]
while let min = scores.removeMin() {
    print("从低到高处理", min)
}
  1. 类型方法是做“全局状态但作用域受限”的利器:
  • App 配置中心(static 存储 + 类型方法读写)
  • 网络请求 stub 中心(type method 注册/注销 mock)
  • 工厂方法(class func makeDefaultXxx())
  1. 与协议组合

把 mutating 写进协议,让 struct/enum 也能提供“可变更”接口,而 class 实现时自动忽略 mutating:

protocol Resettable {
    mutating func reset()
}
❌
❌