阅读视图

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

iOS 的 OC 语言:二进制数据处理与技巧

在 iOS 开发中,尤其是涉及蓝牙通信(BLE)Socket 数据传输设备指令解析等场景时,处理二进制数据(NSData、十六进制字符串、大小端等)成为必备技能。

本篇文章将基于 Objective-C,深入介绍如何高效处理二进制数据,并配合实用示例和技巧,帮助你构建稳定可靠的底层数据通信逻辑。

应用场景举例

  • 蓝牙设备协议解析(如:<0x01 0x0A 0xFF>
  • Socket 数据封包解包
  • 物联网(IoT)设备指令转换
  • 图片/音频等二进制资源校验与处理

基础工具:NSData 与 NSString 互转

一、十六进制字符串 → NSData

/// 将十六进制字符串转换为 NSData
+ (NSData *)transToDataWithString:(NSString *)hexString {
    NSMutableData *data = [NSMutableData data];
    for (int i = 0; i < hexString.length; i += 2) {
        NSString *byteStr = [hexString substringWithRange:NSMakeRange(i, 2)];
        unsigned int byteValue;
        [[NSScanner scannerWithString:byteStr] scanHexInt:&byteValue];
        [data appendBytes:&byteValue length:1];
    }
    return data;
}

示例:

NSString *hex = @"aabbccdd";
NSData *data = [ParseDataTool transToDataWithString:hex];
NSLog(@"data: %@", data); // 输出:<aa bb cc dd>

二、NSData → 十六进制字符串


/// 将 NSData 转为十六进制字符串
+ (NSString *)transDataToHexString:(NSData *)data {
    const unsigned char *dataBuffer = (const unsigned char *)data.bytes;
    if (!dataBuffer) return @"";

    NSMutableString *hexString = [NSMutableString stringWithCapacity:data.length * 2];
    for (int i = 0; i < data.length; ++i) {
        [hexString appendFormat:@"%02x", dataBuffer[i]];
    }
    return [hexString copy];
}

进阶处理:大小端转换(Big Endian vs Little Endian)

为什么需要大小端?

在不同平台(如 iOS vs 嵌入式设备)之间传输数据时,字节顺序可能不同。一个整数 0x12345678

  • 大端(Big Endian):12 34 56 78
  • 小端(Little Endian):78 56 34 12

OC 示例:NSData 转整型(小端)

+ (NSInteger)transLittleEndianDataToInteger:(NSData *)data {
    NSInteger value = 0;
    for (NSInteger i = 0; i < data.length; i++) {
        value |= ((uint8_t *)data.bytes)[i] << (8 * i);
    }
    return value;
}

示例:

NSData *littleEndianData = [ParseDataTool transToDataWithString:@"78563412"];
NSInteger intValue = [ParseDataTool transLittleEndianDataToInteger:littleEndianData];
// 输出:0x12345678 -> 305419896

数据描述格式化:调试更方便

添加空格分隔(便于人眼阅读)

// “aabbcc” → “aa bb cc”
+ (NSString *)describeBlankFormatWithString:(NSString *)dataString {
    NSMutableArray *result = [NSMutableArray array];
    for (NSInteger i = 0; i < dataString.length; i += 2) {
        NSString *byte = [dataString substringWithRange:NSMakeRange(i, 2)];
        [result addObject:byte];
    }
    return [result componentsJoinedByString:@" "];
}

添加 0x 前缀(用于打印/调试)


// “aabbcc” → “0xaa, 0xbb, 0xcc”
+ (NSString *)describeBytesFormatWithString:(NSString *)dataString {
    NSMutableArray *result = [NSMutableArray array];
    for (NSInteger i = 0; i < dataString.length; i += 2) {
        NSString *byte = [dataString substringWithRange:NSMakeRange(i, 2)];
        [result addObject:[NSString stringWithFormat:@"0x%@", byte]];
    }
    return [result componentsJoinedByString:@", "];
}

字节倒序与逆序指令处理

在解析某些设备协议时,你可能需要将指令内容 倒序排列,如下:

// 输入:"a7000003" 输出:"030000a7"
+ (NSString *)invertedOrderWithStr:(NSString *)str {
    NSMutableString *result = [NSMutableString string];
    for (NSInteger i = str.length; i >= 2; i -= 2) {
        NSString *byte = [str substringWithRange:NSMakeRange(i - 2, 2)];
        [result appendString:byte];
    }
    return [result copy];
}

校验算法:XOR 异或校验

某些协议中结尾校验位采用 XOR 校验,即所有字节异或后得到校验值。

+ (NSString *)xorWithDataString:(NSString *)dataString {
    NSData *data = [self transToDataWithString:dataString];
    uint8_t result = 0;
    const uint8_t *bytes = (const uint8_t *)data.bytes;

    for (int i = 0; i < data.length; i++) {
        result ^= bytes[i];
    }
    return [NSString stringWithFormat:@"%02x", result];
}

示例:

NSString *xor = [ParseDataTool xorWithDataString:@"030a2713ea"];
NSLog(@"异或结果:%@", xor); // 输出:XX

实战案例:构造蓝牙写入命令

NSString *cmdHex = @"a10201"; // 指令 + 参数
NSString *xor = [ParseDataTool xorWithDataString:cmdHex];
NSString *finalHex = [cmdHex stringByAppendingString:xor]; // 拼接校验位
NSData *writeData = [ParseDataTool transToDataWithString:finalHex];
// 然后通过蓝牙 writeCharacteristic 发送

最后

本文深入介绍了我在iOS实际工作中使用 Objective-C 处理二进制数据的技巧与实战经验,包括:

  • NSDataNSString 的互转
  • 大小端整数转换
  • 指令格式化、倒序、校验
  • 真实通信场景中的构造与解析

这些内容在蓝牙、Socket、物联网等开发场景中具有广泛应用。如果你在实际开发中遇到数据解析难题,不妨将这些工具方法封装进自己的项目中,快速定位和解决问题。

如有说错的地方,烦请大家指正,谢谢~

苹果审核对于4.3a的态度,只是包容性强, 不代表没脾气!

前言

4.3(a)作为给AppStore兜底的被拒条款,一直是重灾区。不可否认4.3(a)提高了新手上车的门槛,也给AppStore恶意产品竞争多了一道保驾护航的底牌。

虽然4.3(a)是有一定的过审技巧,除了AppStore本身的机器算法,但是其实也有人工的方式可以帮助过审。

4.3(a)会存在误判所以,AppStore的包容性其实是很强的,但是并不代表可以被人践踏!今天就分享一个践踏4.3条款,不幸3.2f中招的案例。

真实案例

直接上AppStore被拒原文内容

Guideline 4.3(a) - Design - Spam

We noticed your app still shares a similar binary, metadata, and/or concept as apps previously submitted by a terminated Apple Developer Program account.

Submitting similar or repackaged apps is a form of spam that creates clutter and makes it difficult for users to discover new apps.

Next Steps

Since we do not accept spam apps on the App Store, we encourage you to review your app concept and submit a unique app with distinct content and functionality.

不用细看,这种就是标准4.3(a)被拒,并且还存在一定代码或元数据的关联问题。

该开发者面对此类问题,自以为是的认为自己没有任何问题,想偷懒,一把硬钢

没有修改任何代码,没有修改任何元数据,直接提交审核

先是进入审核的状态持续了4天,撤回重新提交。1天后喜提3.2f封号。

当然对于4.3(a)的容忍性众说纷纭,有说可以提交N次,有说可以提交3次,也有7~8次提交没有被封号的。

这里不要冒险的以身试法,苹果审核本身就具有多样性,就如同哲学所说:世界上没有两片相同的叶子🍃

4.3硬杠的前提

最纯粹的名字,不包含任何有风险的内容,竞品品牌词,全新且干净的代码。不借助AI,不介入有问题的代码。

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

相关推荐

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

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

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

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

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

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

知识星球

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

fluter在混合模式便捷的热加载

1. 前言

flutter应用有两种调试环境:

  • 纯flutter工程:整个app被flutter接管
  • flutter混合模式开发,也称之为add-to-app模式。flutter作为sdk集成到App中。

这里要讲的是混合模式下的调试

2. add-to-app模式调试

flutter开发效率高其中一个原因是代码的hot reload。flutter在debug模式下会开启hot reload,代码为JIT。

纯flutter工程通过命令 flutter run命令启动app,天然支持热加载。但在混合模式下如何实现热加载呢?

有两种办法:

  1. app启动后,用命令flutter attach
  2. 使用VSCode插件

对比两者,我个人感觉VSCode插件比较方便,为什么?因为当修改代码后,第一种方法需要在命令行按下r才可以实现hot reload,而vscode插件可以实现自动hot reload,不需要额外的步骤。

2.1. flutter attach

➜  my_flutter git:(main) ✗ flutter attach
Connected devices:
iPhone13 Pro (mobile) • 00008110-000925E02146401E            • ios • iOS 18.5 22F76
iPhone 16 (mobile)    • 0F117065-677D-4941-B66B-99EDC4B99B0A • ios • com.apple.CoreSimulator.SimRuntime.iOS-18-5 (simulator)

No wireless devices were found.

[1]: iPhone13 Pro (00008110-000925E02146401E)
[2]: iPhone 16 (0F117065-677D-4941-B66B-99EDC4B99B0A)
Please choose one (or "q" to quit): 1
Waiting for a connection from Flutter on iPhone13 Pro...
Syncing files to device iPhone13 Pro...                             6.5s

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h List all available interactive commands.
c Clear the screen
q Quit (terminate the application on the device).

2.2. VSCode插件

  1. 安装flutter插件:官方文档。比我讲的清楚。
  2. debug模式启动add-to-app。
  3. 在vscode工程中按下command+shift+p:选择Flutter:Select Device,选择你的设备
  4. 在vscode工程中按下command+shift+p:选择Debug:Attach to Flutter on Device,选择你的设备
  5. 查看vscode提示,等待链接成功
  6. 修改代码实现hot reload

3. 如何实现脱机(离开xcode环境)调试

3.1. 结论

不能实现

3.2. 为什么不能实现?

  1. Debug 模式使用的是 动态加载的 Dart 代码(JIT)
  2. Flutter 引擎在启动时默认会从开发机上的 Dart VM 服务端口加载 main.dart 编译后的代码(kernel_blob.bin 等);
  3. 脱机环境(非 flutter run 启动或非 Xcode 启动)下,App 本身没有包含这些文件;
  4. 所以在脱离 Flutter CLI 或 Xcode 的 debug 会话后启动,会找不到 Flutter 的入口代码。

4. debug包提测

4.1. 问题:

  • 有些业务需要再debug模式下提测,但提交给测试工程师的debug包flutter初始化会失败。

4.2. 原因:

4.2.1. 分析

  • Debug 模式的 Flutter Add-to-App 项目中,flutter module 会被编译为debug包。FlutterEngine 启动时会尝试 连接开发主机上的 flutter attach 服务,用于:
    • 调试功能(热重载、日志打印、DevTools)
    • 加载 Dart VM snapshot 等临时调试资源
  • 而这些功能只有在 Xcode 启动时会设置好相关参数路径(通过环境变量或启动参数传入)

4.2.2. 所以:

  • ✅ Xcode 启动时,会自动设置 --observatory-port 等参数,FlutterEngine 知道去哪找 Dart runtime
  • ❌ 图标点击启动时,这些参数没有传入,FlutterEngine 找不到调试环境、资源路径,导致 run 返回 NO(Dart isolate 无法启动)

4.3. 如何解决

  • 思路:在debug App包中中使用flutter module的profile或release包。profile或release包不依赖xcode提供的环境。但也不能hot reload dart代码。
  • 实现:可以通过设置上面提到的“environment['FLUTTER_BUILD_MODE']”环境变量,控制flutter module的编译模式
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/yidao/Documents/env/1/flutter"
export "FLUTTER_APPLICATION_PATH=/Users/yidao/Documents/code/flutter/my_flutter"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

# debug profile release
export "FLUTTER_BUILD_MODE=release"

F1:电影很好看,赛事很挣钱 | 肘子的 Swift 周报 #094

issue94.webp

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

F1:电影很好看,赛事很挣钱

上周我去影院看了苹果出品的电影《F1》,体验真的超乎预期。虽然剧情略显简单,但影片营造出的赛车沉浸感和观赛体验已经远超票价。当布拉德·皮特驾驶着 APXGP 赛车在银石赛道上疾驰时,那引擎的咆哮通过 IMAX 音响系统传来的震撼感,让我仿佛回到了 20 多年前,在电视机前为舒马赫和阿隆索激烈对决而热血沸腾的日子。

三亿美元的制作成本听起来不少,但看完后你很难不惊叹:这真的是三亿能拍出来的吗?真车、真人(众多顶级车手客串)、真场地(银石赛道、迈凯伦车队总部、梅赛德斯风洞实验室)——这些资源若没有汉密尔顿担任制片人时的人脉、苹果的企业背书,以及 F1 官方的全力配合,恐怕花再多钱都未必能实现。

有趣的是,苹果目前也在竞争 2026 年 F1 在美国的转播权。这是受电影成功鼓舞的顺势而为,还是从拍摄之初就已纳入战略布局?无论如何,这部电影或许不会在电影史上留下浓重的印记,但作为一部顶级商业运动的“宣传片”,其效果无疑是拉满的。或许我们很快就会看到更多与高商业价值运动组织深度合作的体育题材电影。

二十多年前我热衷 F1 的时候,FIA 对各车队的限制相对宽松,轮胎供应商之间有米其林和普利司通的激烈竞争,车队可以在空气动力学、引擎开发、燃油策略等多个方面大胆创新。即便是小车队,也有机会通过独特的战术选择在某场比赛中跑进积分区,给车迷带来意想不到的惊喜。

但随着 FIA 逐步收紧规则,比赛变得越来越程式化。统一轮胎供应商、混合动力时代复杂的引擎规则,每一次改革似乎都在削弱那种“anything goes”的爽快感和刺激感。

FIA 的初衷是好的——通过增加限制来缩小车队间差距,让比赛更加激烈、不可预测。理论上,当大家都被施加相同限制时,实力差距应该会缩小。但现实却并非如此。大车队依旧凭借几十年的技术积累、顶级设施和成熟管理体系维持统治,而小车队则丧失了“剑走偏锋”的机会。爆冷和奇迹变得越来越稀少,比赛也失去了许多曾经令人热血沸腾的变化和不确定性。

这并非 F1 独有的现象。放眼整个体育产业,几乎所有高商业价值的运动组织都在采取类似的“平衡策略”:NBA 的工资帽和奢侈税、NFL 的选秀制度、英超的财政公平法案……这些规则的初衷都是限制强队、增加竞争性,从而提升联赛整体商业价值。但实际效果往往有限。长期积累的资源优势不会因为规则限制而消失,只是换了种表现形式。而在追求“平衡”的过程中,这些运动可能也在悄悄失去一些独特的魅力。

我并不反对商业与竞技结合——体育需要商业支撑才能可持续发展。但当商业考量开始主导竞技规则的制定时,就需要警惕了。变化和不可预测性,始终是竞技运动的核心吸引力。如果为了追求表面的“平衡”而扼杀了创新和变化,我们最终可能会看到这样一个局面:赛事越来越赚钱,但运动本身的魅力却在逐渐消退。

前一期内容全部周报列表

近期推荐

如何在 mac Finder Action 扩展中使用新并发 (Writing a macOS Finder "action" Extension with Swift 6 Concurrency)

对于开发者来说,一段标注了详细注释的代码,或许胜过千言万语。在这篇文章中(90% 是代码),Chris Jones 向我们展示了如何在系统回调场景中(同步调用,线程不确定),巧妙地与 Swift 现代并发模型协作。

为了方便快速抓住重点,我整理了以下几个关键技术点:

  • 通过 DispatchGroup 协调多个异步操作,确保所有准备工作完成后再返回结果
  • 使用 Mutex 保护共享状态,避免多线程环境下的数据竞争
  • 通过 Task.detached 创建独立执行上下文,解决异步/同步桥接问题(避免死锁)

在开发者的精心设计下,传统 Cocoa API 与现代 Swift 并发或许可以“愉快”地共存。


Icon Composer 见招拆招

随着 Liquid Glass 风格的推出,苹果同步发布了 Icon Composer,帮助开发者制作符合新标准的应用图标。虽然官方介绍中“拖放分层图片即可”的流程看似简单,但实际使用过程中仍有不少细节需要留意。本文中,Megabits 分享了他在使用 Icon Composer 制作 Liquid Glass 图标时的实战经验,包括如何处理 SVG 导出、单色模式适配、层数限制 以及 Xcode 的导入路径等问题,让你在设计新图标时少走弯路。


SwiftUI 2025:修了哪些坑,哪些还在 (SwiftUI 2025: What’s Fixed, What’s Not, and How I Build Apps Now)

相较于初期版本,SwiftUI 已经取得了显著进步,但它真的足以支撑复杂 App 的开发吗?哪些坑已经被填平,哪些问题依然存在?Weichao Deng 分享了他在一个 SwiftUI-first 项目 中遇到的主要挑战,以及如何通过混合 UIKit 提升性能和解决兼容性问题。他认为,尽管 SwiftUI 获得了大量改进,但出于兼容性和性能考量,仍建议用 UIKit/AppKit 管理生命周期(如 UIScene、UIWindow),将 SwiftUI 作为主要视图库;而对于个人小工具或 demo,纯 SwiftUI 已经足够。


用 TestHelpers 打造高效测试目标 (Hitting The Target with TestHelpers)

随着项目规模增长,模块化已成为管理复杂度的有效手段。但在许多项目中,模块化的收益往往集中在生产代码,而测试目标(test target)却被忽视。William Boles 提出了一种简单而实用的做法:为每个模块引入 TestHelper target,消除重复的测试桩代码,提升团队开发效率。

在我自己构建的项目中,也采用了与 William 建议非常接近的方案。实践下来,我最大的感受是:初期投入时间去打造高质量的 Mock 和测试工具确实不轻松,但这些投入会在后续开发中带来“复利效应”——让测试编写更快、维护成本更低、代码质量更高。


Mutex 与同步框架 (Modern Swift Lock: Mutex & the Synchronization Framework)

Swift 6.2 新增的 Synchronization 框架 引入了 Mutex 锁(SE-433),提供了一种更简洁、线程安全的方式来管理可变状态。Antoine van der Lee 在文章中不仅介绍了其用法,还对比了传统 Lock 的差异。Mutex(互斥锁)只能由获取它的线程/任务释放,更严格,适合避免解锁错误;同时,withLock 方法让我们能够以同步且线程安全的方式访问数据。

Mutex 给我的最大感受是优雅:它让我可以放心、自然地实现 Sendable 类型,而不必向编译器提供人格担保(@unchecked Sendable)。唯一的遗憾是,该功能仅支持 iOS 18 及以上系统。


在 SwiftUI 中检测交互式返回手势 (Detecting the Interactive Pop Gesture in SwiftUI)

虽然 NavigationStack 底层基于 UINavigationController,但 SwiftUI 仅暴露了有限的接口,导致我们在某些场景下难以精准判断用户意图。比如,感知返回手势,以便在用户滑动返回时暂停任务、取消网络请求或触发动画。Artem Mirzabekian 在本文分享了一种实用技巧:通过嵌入一个隐形 UIViewController,监听父级 UINavigationController(即 NavigationStack)的 interactivePopGestureRecognizer 状态。


iPadOS 窗口化:开发者的下一个挑战 (Windowing on iPadOS)

在 WWDC 2025,iPadOS 迎来了重大升级,带来了与 macOS 接近的窗口系统体验。这不仅丰富了用户体验,也为开发者带来了新的挑战:应用需要适配更多窗口尺寸状态,包括“微型窗口”(甚至小于 iPhone SE 的尺寸)。Danny Bolella 在本文分享了多种适配技巧与注意事项,并提出一个有趣的假设——或许可以借助 iPad 的窗口系统,提前模拟折叠屏 iPhone 的体验。

窗口化是 iPadOS 的下一步进化,拥抱它,也是在实践 Liquid Glass 设计理念。


SwiftData 性能提升实战 (High Performance SwiftData Apps)

你能接受在 SwiftData 中保存一张照片要等 20 秒吗?显然 Jacob Bartlett 不行——他觉得用户更不会买账。本文记录了他从“灾难性设计”到彻底重构优化的全过程:包括如何将数据读写移出主线程、重构数据模型以避免存储庞大的 PNG 数据、将图片迁移到文件系统并引用路径,以及借助第三方库(如 Nuke)实现高效加载。

随着采用 SwiftData 的开发者增多,性能问题开始困扰更多人。至少在目前阶段,SwiftData 的易用性优势正在被这些性能隐患逐渐削弱。

工具

XLKit - 不仅仅导出数据

一个迫切的需求,往往能催生出有特色的第三方库。The Acharya 团队在电影制作工作流程中,需要处理 Final Cut Pro 的 FCPXML 并导出 Excel 文件,尤为关键的是导出数据中的图片既要保持“完美长宽比”,又要支持单元格自动适配其尺寸。为此,Vigneswaran Rajkumar 开发了 XLKit——一个支持完整 .xlsx 文件输出的 Swift 库,内置 17 种专业视频/影院长宽比,并特别为 Final Cut Pro 工作流优化。


Apple Docs MCP

苹果开发者文档向来被诟病更新慢、检索难,在 AI 编程时代这一问题愈发凸显。为解决这一痛点,Sungwhee Kim 开发了 Apple Docs MCP:一个基于 Model Context Protocol 的苹果文档服务器,让 Claude、Cursor 等 AI 助手能够直接访问苹果官方文档、API 参考和 WWDC 视频资料。配置该 MCP 后,你可以直接向 AI 提问:“Show me withAnimation API with related APIs” 或 “Find UIViewController delegate methods”,让大模型真正成为苹果开发者的智能助手。

往期内容

THANK YOU

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

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

为什么要学习Flutter编译过程

1. 为什么要了解编译过程

  • 因为只有了解了flutter的编译过程,才能更好的做flutter CI CD工作。尤其是解决“如何让flutter开发适配目前已有的完备的iOS/Android的开发工作流”问题, 尤为重要。(如果是个人开发者可以忽略)。
  • 其次你会发现一个现象:“debug包 在离开xcode环境,脱机运行的时候,在初始化Flutter engine会失败。但是在xcode环境下运行debug包一切正常”。这就带来一个问题:有些业务需要再debug模式下提测,但提交给测试工程师的debug包flutter初始化会失败。为了解决这个问题,你需要了解flutter的编译过程来解决这个问题。

2. 编译过程

编译过程可以分为:

  • 编译前:为编译做准备,工程参数设定,脚本注入等
  • 编译:将flutter module编译为framework,并打包资源文件
  • 编译后:将打包后的framework和资源复制到相应的文件夹

我们会举例说明三个过程

2.1. Demo环境:目录结构和Podfile

2.1.1. 目录结构

/path/to/MyApp
├── my_flutter/
│   └── .ios/
│       └── Flutter/
│         └── podhelper.rb
└── MyApp/
    └── Podfile

2.1.2. Podfile

flutter_application_path = '../my_flutter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

MyApp/Podfile
target 'MyApp' do
  install_all_flutter_pods(flutter_application_path)
end

post_install do |installer|
  flutter_post_install(installer) if defined?(flutter_post_install)
end

重点看podhelper.rb文件内的install_all_flutter_pods和flutter_post_install两个函数做了什么

2.2. 编译器前

编译前可以理解为pod install所做的事情,主要由install_all_flutter_pods和flutter_post_install两个函数完成

2.2.1. install_all_flutter_pods

def install_all_flutter_pods(flutter_application_path = nil)
  # defined_in_file is a Pathname to the Podfile set by CocoaPods.
  pod_contents = File.read(defined_in_file)
  unless pod_contents.include? 'flutter_post_install'
    puts  <<~POSTINSTALL
Add `flutter_post_install(installer)` to your Podfile `post_install` block to build Flutter plugins:

post_install do |installer|
  flutter_post_install(installer)
end
POSTINSTALL
    raise 'Missing `flutter_post_install(installer)` in Podfile `post_install` block'
  end

  flutter_application_path ||= File.join('..', '..')
  install_flutter_engine_pod(flutter_application_path)
  install_flutter_plugin_pods(flutter_application_path)
  install_flutter_application_pod(flutter_application_path)
end

这里主要调用了三个函数:

  • install_flutter_engine_pod
  • install_flutter_plugin_pods
  • install_flutter_application_pod

这里重点看:install_flutter_application_pod。它为MyApp工程文件导入两个脚本:

  • Run Flutter Build my_flutter Script:根据环境编译flutter 和 App framework
  • Embed Flutter Build my_flutter Script:把编译后的产出复制到相应的目录
def install_flutter_application_pod(flutter_application_path)
  flutter_application_path ||= File.join('..', '..')

  export_script_directory = File.join(flutter_application_path, '.ios', 'Flutter')

  # Keep script phase paths relative so they can be checked into source control.
  relative = flutter_relative_path_from_podfile(export_script_directory)

  flutter_export_environment_path = File.join('${SRCROOT}', relative, 'flutter_export_environment.sh')

  # Compile App.framework and move it and Flutter.framework to "BUILT_PRODUCTS_DIR"
  script_phase name: 'Run Flutter Build my_flutter Script',
    script: "set -e\nset -u\nsource "#{flutter_export_environment_path}"\nexport VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh build",
    execution_position: :before_compile

  # Embed App.framework AND Flutter.framework.
  script_phase name: 'Embed Flutter Build my_flutter Script',
    script: "set -e\nset -u\nsource "#{flutter_export_environment_path}"\nexport VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh embed_and_thin",
    execution_position: :after_compile
end

2.2.2. flutter_post_install

def flutter_post_install(installer, skip: false)
  return if skip

  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |_build_configuration|
      # flutter_additional_ios_build_settings is in Flutter root podhelper.rb
      flutter_additional_ios_build_settings(target)
    end
  end
end

进一步调用了flutter_additional_ios_build_settings,它的主要做用是根据当前环境设置MyApp的编译配置

为每个编译配置。比如framework搜索路径等

  • flutter_additional_ios_build_settings所在的文件:flutter_root/package/flutter_tools/bin/podhelper.rb
def flutter_additional_ios_build_settings(target)
  return unless target.platform_name == :ios

  # [target.deployment_target] is a [String] formatted as "8.0".
  inherit_deployment_target = target.deployment_target[/\d+/].to_i < 12

  # ARC code targeting iOS 8 does not build on Xcode 14.3.
  force_to_arc_supported_min = target.deployment_target[/\d+/].to_i < 9

  # This podhelper script is at $FLUTTER_ROOT/packages/flutter_tools/bin.
  # Add search paths from $FLUTTER_ROOT/bin/cache/artifacts/engine.
  artifacts_dir = File.join('..', '..', '..', '..', 'bin', 'cache', 'artifacts', 'engine')
  debug_framework_dir = File.expand_path(File.join(artifacts_dir, 'ios', 'Flutter.xcframework'), __FILE__)

  unless Dir.exist?(debug_framework_dir)
    # iOS artifacts have not been downloaded.
    raise "#{debug_framework_dir} must exist. If you're running pod install manually, make sure "flutter precache --ios" is executed first"
  end

  release_framework_dir = File.expand_path(File.join(artifacts_dir, 'ios-release', 'Flutter.xcframework'), __FILE__)
  # Bundles are com.apple.product-type.bundle, frameworks are com.apple.product-type.framework.
  target_is_resource_bundle = target.respond_to?(:product_type) && target.product_type == 'com.apple.product-type.bundle'

  target.build_configurations.each do |build_configuration|
    # Build both x86_64 and arm64 simulator archs for all dependencies. If a single plugin does not support arm64 simulators,
    # the app and all frameworks will fall back to x86_64. Unfortunately that case is not detectable in this script.
    # Therefore all pods must have a x86_64 slice available, or linking a x86_64 app will fail.
    build_configuration.build_settings['ONLY_ACTIVE_ARCH'] = 'NO' if build_configuration.type == :debug

    # Workaround https://github.com/CocoaPods/CocoaPods/issues/11402, do not sign resource bundles.
    if target_is_resource_bundle
      build_configuration.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
      build_configuration.build_settings['CODE_SIGNING_REQUIRED'] = 'NO'
      build_configuration.build_settings['CODE_SIGNING_IDENTITY'] = '-'
      build_configuration.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = '-'
    end

    # ARC code targeting iOS 8 does not build on Xcode 14.3. Force to at least iOS 9.
    build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0' if force_to_arc_supported_min

    # Skip other updates if it does not depend on Flutter (including transitive dependency)
    next unless depends_on_flutter(target, 'Flutter')

    # Bitcode is deprecated, Flutter.framework bitcode blob will have been stripped.
    build_configuration.build_settings['ENABLE_BITCODE'] = 'NO'

    # Profile can't be derived from the CocoaPods build configuration. Use release framework (for linking only).
    # TODO(stuartmorgan): Handle local engines here; see https://github.com/flutter/flutter/issues/132228
    configuration_engine_dir = build_configuration.type == :debug ? debug_framework_dir : release_framework_dir
    Dir.new(configuration_engine_dir).each_child do |xcframework_file|
      next if xcframework_file.start_with?('.') # Hidden file, possibly on external disk.
      if xcframework_file.end_with?('-simulator') # ios-arm64_x86_64-simulator
        build_configuration.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]'] = ""#{configuration_engine_dir}/#{xcframework_file}" $(inherited)"
      elsif xcframework_file.start_with?('ios-') # ios-arm64
        build_configuration.build_settings['FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]'] = ""#{configuration_engine_dir}/#{xcframework_file}" $(inherited)"
       # else Info.plist or another platform.
      end
    end
    build_configuration.build_settings['OTHER_LDFLAGS'] = '$(inherited) -framework Flutter'

    build_configuration.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO'
    # Suppress warning when pod supports a version lower than the minimum supported by Xcode (Xcode 12 - iOS 9).
    # This warning is harmless but confusing--it's not a bad thing for dependencies to support a lower version.
    # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target.
    # If the pod only supports a higher version, do not delete to correctly produce an error.
    build_configuration.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' if inherit_deployment_target

    # Override legacy Xcode 11 style VALID_ARCHS[sdk=iphonesimulator*]=x86_64 and prefer Xcode 12 EXCLUDED_ARCHS.
    build_configuration.build_settings['VALID_ARCHS[sdk=iphonesimulator*]'] = '$(ARCHS_STANDARD)'
    build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = '$(inherited) i386'
    build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphoneos*]'] = '$(inherited) armv7'
  end
end

2.3. 编译

当MyApp工程开始编译,就胡已调用'Run Flutter Build my_flutter Script'脚本编译flutter module。

set -e
set -u
source "${SRCROOT}/../my_flutter/.ios/Flutter/flutter_export_environment.sh"
export VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh build

这里还加载了一个文件flutter_export_environment.sh。添加了一些环境变量,这些环境变量可以控制编译flutter module的参数。

#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/yidao/Documents/env/1/flutter"
export "FLUTTER_APPLICATION_PATH=/Users/yidao/Documents/code/flutter/my_flutter"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

# debug profile release
#export "FLUTTER_BUILD_MODE=release"

xcode_backend.sh其实是一个转发脚本到xcode_backend.sh build

#!/usr/bin/env bash
# Copyright 2014 The Flutter Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# exit on error, or usage of unset var
set -euo pipefail

# Needed because if it is set, cd may print the path it changed to.
unset CDPATH

function follow_links() (
  cd -P "$(dirname -- "$1")"
  file="$PWD/$(basename -- "$1")"
  while [[ -h "$file" ]]; do
    cd -P "$(dirname -- "$file")"
    file="$(readlink -- "$file")"
    cd -P "$(dirname -- "$file")"
    file="$PWD/$(basename -- "$file")"
  done
  echo "$file"
)

PROG_NAME="$(follow_links "${BASH_SOURCE[0]}")"
BIN_DIR="$(cd "${PROG_NAME%/*}" ; pwd -P)"
FLUTTER_ROOT="$BIN_DIR/../../.."
DART="$FLUTTER_ROOT/bin/dart"

"$DART" "$BIN_DIR/xcode_backend.dart" "$@"

xcode_backend.sh进一步调用了同文件夹下的xcode_backend.dart build。看xcode_backend关键代码:

void main(List<String> arguments) {
  File? scriptOutputStreamFile;
  final String? scriptOutputStreamFileEnv = Platform.environment['SCRIPT_OUTPUT_STREAM_FILE'];
  if (scriptOutputStreamFileEnv != null && scriptOutputStreamFileEnv.isNotEmpty) {
    scriptOutputStreamFile = File(scriptOutputStreamFileEnv);
  }
  Context(
    arguments: arguments,
    environment: Platform.environment,
    scriptOutputStreamFile: scriptOutputStreamFile,
  ).run();
}

class Context {
  Context({required this.arguments, required this.environment, File? scriptOutputStreamFile}) {
    if (scriptOutputStreamFile != null) {
      scriptOutputStream = scriptOutputStreamFile.openSync(mode: FileMode.write);
    }
  }

  final Map<String, String> environment;
  final List<String> arguments;
  RandomAccessFile? scriptOutputStream;

  void run() {
    if (arguments.isEmpty) {
      // Named entry points were introduced in Flutter v0.0.7.
      stderr.write(
        'error: Your Xcode project is incompatible with this version of Flutter. '
        'Run "rm -rf ios/Runner.xcodeproj" and "flutter create ." to regenerate.\n',
      );
      exit(-1);
    }

    final String subCommand = arguments.first;
    switch (subCommand) {
      case 'build':
        buildApp(); //>>>>>>>>>>>>>>>看这里<<<<<<<<<<<
      case 'prepare':
        prepare();
      case 'thin':
        // No-op, thinning is handled during the bundle asset assemble build target.
        break;
      case 'embed':
        embedFlutterFrameworks();
      case 'embed_and_thin':
        // Thinning is handled during the bundle asset assemble build target, so just embed.
        embedFlutterFrameworks();
      case 'test_vm_service_bonjour_service':
        // Exposed for integration testing only.
        addVmServiceBonjourService();
    }
  }
}
  • main函数调用Context.run
  • run通过判断传参为build,然后调用buildApp()方法
  void buildApp() {
    final bool verbose = (environment['VERBOSE_SCRIPT_LOGGING'] ?? '').isNotEmpty;
    final String sourceRoot = environment['SOURCE_ROOT'] ?? '';
    final String projectPath = environment['FLUTTER_APPLICATION_PATH'] ?? '$sourceRoot/..';

    final String buildMode = parseFlutterBuildMode();

    final List<String> flutterArgs = _generateFlutterArgsForAssemble('build', buildMode, verbose);

    flutterArgs.add('${buildMode}_ios_bundle_flutter_assets');

    final ProcessResult result = runSync(
      '${environmentEnsure('FLUTTER_ROOT')}/bin/flutter',
      flutterArgs,
      verbose: verbose,
      allowFail: true,
      workingDirectory: projectPath, // equivalent of RunCommand pushd "${project_path}"
    );

    if (result.exitCode != 0) {
      echoError('Failed to package $projectPath.');
      exitApp(-1);
    }

    streamOutput('done');
    streamOutput(' └─Compiling, linking and signing...');

    echo('Project $projectPath built and packaged successfully.');
  }

buildApp完成:

  • 基于环境准备编译参数
  • 通过runSync函数编译flutter module

重点看一下对parseFlutterBuildMode();的调用。

  • 它会返回当前的编译模式:debug、profile、release

编译模式从两个环境变量获取:

  • environment['FLUTTER_BUILD_MODE']:优先使用此变量。可以在上面提到的flutter_export_environment.sh中设置,也可以通过其他途径设置。
  • environment['CONFIGURATION']:如果上面变量没有设置,则使用此变量。此变量是Xcode从工程文件设置中读取并设置此变量。
  • 总结:如果没有设置FLUTTER_BUILD_MODE,则按照xcode当前编译设置选择编译模式。这样就说xcode编译debug包,则flutter module也编译debug包,如果是.......
  String parseFlutterBuildMode() {
    // Use FLUTTER_BUILD_MODE if it's set, otherwise use the Xcode build configuration name
    // This means that if someone wants to use an Xcode build config other than Debug/Profile/Release,
    // they _must_ set FLUTTER_BUILD_MODE so we know what type of artifact to build.
    final String? buildMode =
        (environment['FLUTTER_BUILD_MODE'] ?? environment['CONFIGURATION'])?.toLowerCase();

    if (buildMode != null) {
      if (buildMode.contains('release')) {
        return 'release';
      }
      if (buildMode.contains('profile')) {
        return 'profile';
      }
      if (buildMode.contains('debug')) {
        return 'debug';
      }
    }
  }

2.4. 编译后

通过编译前注入的“Embed Flutter Build my_flutter Script”脚本,完成Embed Flutter Build

set -e
set -u
source "${SRCROOT}/../my_flutter/.ios/Flutter/flutter_export_environment.sh"
export VERBOSE_SCRIPT_LOGGING=1 && "$FLUTTER_ROOT"/packages/flutter_tools/bin/xcode_backend.sh embed_and_thin

这个阶段的职责:

操作 说明
嵌入 Flutter.framework 和 App.framework 确保它们包含在最终 App 中
瘦身处理(thin) 移除不必要的架构,提高打包效率
复制资源 包括 Dart AOT、Asset、VM Snapshot 等运行时资源

和编译脚本同样也会调用Context的run函数:

void run() {
  if (arguments.isEmpty) {
    // Named entry points were introduced in Flutter v0.0.7.
    stderr.write(
      'error: Your Xcode project is incompatible with this version of Flutter. '
      'Run "rm -rf ios/Runner.xcodeproj" and "flutter create ." to regenerate.\n',
    );
    exit(-1);
  }

  final String subCommand = arguments.first;
  switch (subCommand) {
    case 'build':
      buildApp();
    case 'prepare':
      prepare();
    case 'thin':
      // No-op, thinning is handled during the bundle asset assemble build target.
      break;
    case 'embed':
      embedFlutterFrameworks();
    case 'embed_and_thin':
      // Thinning is handled during the bundle asset assemble build target, so just embed.
      embedFlutterFrameworks();
    case 'test_vm_service_bonjour_service':
      // Exposed for integration testing only.
      addVmServiceBonjourService();
  }
}

这里调用了embedFlutterFrameworks函数:

  void embedFlutterFrameworks() {
    // Embed App.framework from Flutter into the app (after creating the Frameworks directory
    // if it doesn't already exist).
    final String xcodeFrameworksDir =
        '${environment['TARGET_BUILD_DIR']}/${environment['FRAMEWORKS_FOLDER_PATH']}';
    runSync('mkdir', <String>['-p', '--', xcodeFrameworksDir]);
    runRsync(
      delete: true,
      '${environment['BUILT_PRODUCTS_DIR']}/App.framework',
      xcodeFrameworksDir,
    );

    // Embed the actual Flutter.framework that the Flutter app expects to run against,
    // which could be a local build or an arch/type specific build.
    runRsync(
      delete: true,
      '${environment['BUILT_PRODUCTS_DIR']}/Flutter.framework',
      '$xcodeFrameworksDir/',
    );

    // Copy the native assets. These do not have to be codesigned here because,
    // they are already codesigned in buildNativeAssetsMacOS.
    final String sourceRoot = environment['SOURCE_ROOT'] ?? '';
    String projectPath = '$sourceRoot/..';
    if (environment['FLUTTER_APPLICATION_PATH'] != null) {
      projectPath = environment['FLUTTER_APPLICATION_PATH']!;
    }
    final String flutterBuildDir = environment['FLUTTER_BUILD_DIR']!;
    final String nativeAssetsPath = '$projectPath/$flutterBuildDir/native_assets/ios/';
    final bool verbose = (environment['VERBOSE_SCRIPT_LOGGING'] ?? '').isNotEmpty;
    if (Directory(nativeAssetsPath).existsSync()) {
      if (verbose) {
        print('♦ Copying native assets from $nativeAssetsPath.');
      }
      runRsync(
        extraArgs: <String>['--filter', '- native_assets.yaml', '--filter', '- native_assets.json'],
        nativeAssetsPath,
        xcodeFrameworksDir,
      );
    } else if (verbose) {
      print("♦ No native assets to bundle. $nativeAssetsPath doesn't exist.");
    }

    addVmServiceBonjourService();
  }

至此flutter module的编译完成

3. 解决debug包提测问题

3.1. 问题:

  • 有些业务需要再debug模式下提测,但提交给测试工程师的debug包flutter初始化会失败。

3.2. 原因:

3.2.1. 分析

  • Debug 模式的 Flutter Add-to-App 项目中,flutter module 会被编译为debug包。FlutterEngine 启动时会尝试 连接开发主机上的 flutter attach 服务,用于:
    • 调试功能(热重载、日志打印、DevTools)
    • 加载 Dart VM snapshot 等临时调试资源
  • 而这些功能只有在 Xcode 启动时会设置好相关参数路径(通过环境变量或启动参数传入)

3.2.2. 所以:

  • ✅ Xcode 启动时,会自动设置 --observatory-port 等参数,FlutterEngine 知道去哪找 Dart runtime
  • ❌ 图标点击启动时,这些参数没有传入,FlutterEngine 找不到调试环境、资源路径,导致 run 返回 NO(Dart isolate 无法启动)

3.3. 如何解决

  • 思路:在debug App包中中使用flutter module的profile或release包。profile或release包不依赖xcode提供的环境。但也不能hot reload dart代码。
  • 实现:可以通过设置上面提到的“environment['FLUTTER_BUILD_MODE']”环境变量,控制flutter module的编译模式
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/yidao/Documents/env/1/flutter"
export "FLUTTER_APPLICATION_PATH=/Users/yidao/Documents/code/flutter/my_flutter"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

# debug profile release
export "FLUTTER_BUILD_MODE=release"

swiftui使用String Catalog文件实现语言本地化

自定义view中的字符串不能自动加入Localizable.xcstrings文件中,自定义代码:

import SwiftUI

struct FeatureCard: View {
    var title: String
    var systemImage: String
    var color: Color
    
    var body: some View {
        VStack {
            Image(systemName: systemImage)
                .font(.system(size: 30))
                .foregroundColor(color)
                .padding(.bottom, 8)
            
            Text(title)
                .font(.system(size: 14, weight: .medium))
                .multilineTextAlignment(.center)
        }
        .frame(width: 100, height: 100)
        .background(.ultraThinMaterial)
        .cornerRadius(15)
        .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
    }
}

你当前定义中: 

 var title: String 这正是导致 Xcode 无法自动将这些字符串提取到 Localizable.xcstrings 的核心原因。

 🧠 原理再强调一下: 

 Xcode 的 String Catalog 只能自动收集 SwiftUI 中使用 Text("字符串字面量") 或 LocalizedStringKey 的字符串。 

 你现在传进去的是 String 类型(虽然你传的是字面量),但因为类型是 String,SwiftUI 会自动调用 Text(verbatim:) 初始化器,而这个是不参与本地化提取的。 

 ✅ 正确写法:

改成 LocalizedStringKey 请将 title: String 改成: 

 var title: LocalizedStringKey 

 这是 SwiftUI 专门为本地化设计的类型。 

 改完后你的 FeatureCard 组件如下:

struct FeatureCard: View {
    var title: LocalizedStringKey  // ✅ 改这里
    var systemImage: String
    var color: Color

    var body: some View {
        VStack {
            Image(systemName: systemImage)
                .font(.system(size: 30))
                .foregroundColor(color)
                .padding(.bottom, 8)

            Text(title)  // ✅ 这样才会被 Xcode 编译器识别为可本地化的 key
                .font(.system(size: 14, weight: .medium))
                .multilineTextAlignment(.center)
        }
        .frame(width: 100, height: 100)
        .background(.ultraThinMaterial)
        .cornerRadius(15)
        .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
    }
}

调用的时候什么都不需要改,你依然可以这样写:

 FeatureCard(title: "提取资源", systemImage: "square.and.arrow.down", color: .blue) 

🧪 检查是否生效的方法:

 1. ✅ 清理工程(Product → Clean Build Folder)

 2. ✅ 重新构建项目(Cmd + B)

 3. ✅ 在 Xcode 的左侧导航栏中找到 Localizable.xcstrings,检查是否已经自动添加了 "提取资源"、"海外素材" 等字符串。

 4. ❗注意不要使用字符串拼接、格式化等方式传参(这会打破静态分析)。 

✨ 额外建议(增强维护性): 

 为了更好地维护和集中管理你这些本地化 Key,建议你可以额外定义一个 enum 或 struct: 

struct LocalizedKeys {
    static let extractResources = LocalizedStringKey("提取资源")
    static let overseasMaterial = LocalizedStringKey("海外素材")
    static let xhsLivePhoto = LocalizedStringKey("红薯实况图")
    // ...其他key
}

然后调用时使用: 

 FeatureCard(title: LocalizedKeys.extractResources, systemImage: "square.and.arrow.down", color: .blue) 

 更易于管理和本地化多语言版本。

iOS26适配指南之动画

介绍

新增了一种新的动画选项flushUpdates,它可以自动根据 @Observable Object 或者 AutoLayout 约束的更改添加动画效果,而无需手动调用layoutIfNeeded()方法。

使用

  • 代码。
import UIKit

@Observable class Model {
    var backgroundColor: UIColor = .systemGray
}

class ViewController: UIViewController {
    lazy var redView: UIView = {
        let view = UIView(frame: CGRect(x: 0, y: 50, width: 100, height: 100))
        view.backgroundColor = .systemRed
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    var widthConstraint: NSLayoutConstraint!
    var heightConstraint: NSLayoutConstraint!
    let model = Model()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(redView)

        widthConstraint = redView.widthAnchor.constraint(equalToConstant: 100)
        heightConstraint = redView.heightAnchor.constraint(equalToConstant: 100)
        widthConstraint.isActive = true
        heightConstraint.isActive = true
        redView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        redView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
    }

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        view.backgroundColor = model.backgroundColor
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // UIView动画
        UIView.animate(withDuration: 2.0, delay: 0, options: .flushUpdates) {
            self.model.backgroundColor = .systemBlue
        } completion: { _ in
            // UIViewPropertyAnimator动画
            _ = UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 2.0,
                                                               delay: 0,
                                                               options: .flushUpdates) {
                self.widthConstraint.constant = 300
                self.heightConstraint.constant = 300
            } completion: { _ in
                print("动画完成")
            }
        }
    }
}
  • 效果。

动画.gif

F1:电影很好看,赛事很挣钱 - 肘子的 Swift 周报 #94

上周我去影院看了苹果出品的电影《F1》,体验真的超乎预期。虽然剧情略显简单,但影片营造出的赛车沉浸感和观赛体验已经远超票价。当布拉德·皮特驾驶着 APXGP 赛车在银石赛道上疾驰时,那引擎的咆哮通过 IMAX 音响系统传来的震撼感,让我仿佛回到了 20 多年前,在电视机前为舒马赫和阿隆索激烈对决而热血沸腾的日子。

老司机 iOS 周报 #343 | 2025-07-21

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

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

新手推荐

🐎 Meet the Inspector view in SwiftUI

@阿权:文章介绍了 SwiftUI 中检查器视图(Inspector View)的用法:

  1. 基本概念与使用场景:
    1. 检查器视图用于展示主内容的附加信息,默认在右侧展示(macOS/iPadOS),在紧凑尺寸设备(如竖屏 iPhone)上以 sheet 形式呈现。
    2. 需要 macOS 14+ 和 iOS 17+。
  2. 基本用法:通过 inspector(isPresented:content:) 控制检查视图的显隐。
  3. 自定义宽度:支持固定宽度 inspectorColumnWidth(_:),和用户拖拽控制宽度 inspectorColumnWidth(min:ideal:max:)
  4. 检查器入口按钮与导航工具栏的适配,问题提供了三种适配方案。
  5. 在紧凑尺寸中的展示效果及其适配方案。

在苹果提供中的 App 中,检查视图是用户早已习惯的交互形式,使用检查视图可能提升 App 的专业性并提升用户体验。

文章

🌟 🐢 An open source tool to speed up iOS app launch

@ChengzhiHuang:Emerge Tools 提供了一个轻量级的收集 App 启动阶段内执行到的函数的收集工具,而不用修改编译选项(开启插桩),以此可以用来投喂给 Xcode 进行二进制重排,进而通过减少启动过程中的 Page In 数量来加快启动速度。技术方案是通过 Linkmap 或者 trick 的方式 收集函数起始的地址。然后对所有函数起始地址替换汇编指令为触发 ARM64_BREAK_INSTRUCTION 。在捕获断点后,记录下此方法,然后跳转原始逻辑继续执行。其中有非常多的细节,推荐对此感兴趣的同学阅读。

同时可以对此工具稍加改造,以此适配收集 App 任意阶段内的执行函数(如某个巨大模块),然后执行预热,以加快某个模块的冷启动首次进入耗时;同时其中涉及到与 SimpleDebugger 的联动,可以进行修改优化,目前是每个函数都执行一次 StopTheWorld + 替换单个函数汇编方便断点记录,可以修改为 批量执行一次 StopTheWorld + 替换所有需要函数。

🐕 Everything You Should Know About Spacer

@AidenRao:SwiftUI 提供了四种 Spacer 类型实现灵活布局:通用 Spacer 在可用空间内沿主轴扩展;_TextBaselineRelativeSpacer 支持文本基线对齐;_HSpacer/_VSpacer 严格限定方向。它们通过 PrimitiveSpacer 协议共享核心属性(minLengthaxisrequireTextBaselineSpacing

🐕 Swift 6.2 Java 互操作性实践

@含笑饮砒霜:这篇文章展示了如何通过 Swift 6.2 的 swift-java 包实现 Swift 与 Java 的高效互操作。整个过程通过标准工具链(SwiftPM + Makefile)自动化,从 Java 编译到 Swift 调用无缝衔接,大大简化了跨语言开发的复杂度。最终实现了 Swift 程序可以优雅地调用 Java 方法的目标。

🐕 What you need to know before migrating to Swift Testing

@JonyFang: 一篇 Swift Testing 迁移指南摘要,介绍了从传统 XCTest 框架迁移到 Swift Testing 框架前需要了解的关键信息。如:

  • Swift Testing 框架的主要优势和新特性
  • 迁移过程中可能遇到的常见兼容性问题
  • Swift Testing 与 XCTest 的语法和结构差异
  • 一些迁移策略建议和最佳实践

对于正在考虑升级测试框架的 iOS 开发者来说,这是一篇实用的参考指南。

代码

部分使用了 Data.bytes 的代码无法正常编译

@Smallfly:iOS 26 SDK 中 Foundation.Data 扩展了新的属性 var bytes: RawSpan { get },如果之前的实现里面使用了 CryptoSwift 对 Data 添加的 extension 中的 var bytes: Array<UInt8> 且未明确 import CryptoSwift 就会遇到编译器无法正确匹配实现的问题。

使用到的位置主动 import CryptoSwift 明确实现来源。

CryptoSwift 已将 extension 重命名为 byteArray ,PR

CrazyFanFan 提供信息

内推

重新开始更新「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 PlatformView实战:嵌入原生 iOS 视图

flutter.webp


Flutter 的跨平台能力非常出色,但有时你需要使用一个平台特有的、在 Flutter 中没有对应实现的 UI 组件。你可能需要集成一个复杂的、经过实战检验的原生 SDK,或者你只是想复用一个已有的原生视图。这时候,PlatformView 就派上用场了。

PlatformView 允许你将原生的 UIView (在 iOS 上) 和 View (在 Android 上) 直接嵌入到你的 Flutter widget 树中。它是一个强大的功能,充当了你的 Flutter UI 和原生平台之间的桥梁。

在本文我们将通过一个完整的示例,演示如何使用 SwiftUI 将一个原生的 iOS MapKit 地图视图嵌入到 Flutter 应用中。

1. 在 Flutter 中显示原生视图

在 Flutter 中使用一个特殊的 widget 来承载原生视图。对于 iOS,这个 widget 是 UiKitView

lib/map_view.dart 文件中,定义了一个 MapView widget。这个 widget 的核心就是 UiKitView

// ... existing code ...
class _MapViewState extends State<MapView> {
  // ... existing code ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: UiKitView(
        // 一个唯一的标识符,用于将此 widget 连接到原生工厂。
        viewType: "map_view",
        layoutDirection: TextDirection.ltr,
        // 在创建时传递给原生视图的数据。
        creationParams: <String, dynamic>{
          "latitude": 23.12911,
          "longitude": 113.264385,
        },
        // 用于对 creationParams 进行编解码的编解码器。
        creationParamsCodec: const StandardMessageCodec(),
        onPlatformViewCreated: (id) {
          print("MapView created with id: $id");
        },
      ),
    );
  }
}

让我们分解一下 UiKitView 的关键属性:

  • viewType: 这是一个至关重要的 String 标识符。Flutter 使用它来查找相应的原生“工厂”,这个工厂知道如何创建我们想要的原生视图。
  • creationParams: 一个 Map 类型的动态数据,你希望在初始化时从 Flutter 发送到原生端。在我们的例子中,我们传递了地图的初始坐标。
  • creationParamsCodec: 这指定了 creationParams 应该如何在 Dart 和原生平台之间进行编码和解码。StandardMessageCodec 是一个通用的选择,支持常见的数据类型。

2. 注册原生视图工厂 (iOS)

在原生 iOS 端,我们需要告诉我们的 Flutter 应用如何构建与 viewType ("map_view") 关联的视图。这是通过注册一个 FlutterPlatformViewFactory 来完成的。

注册过程发生在 ios/Runner/AppDelegate.swift 中。

// ... existing code ...
@available(iOS 17.0, *)
@main
@objc class AppDelegate: FlutterAppDelegate {
    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)

        // ... MethodChannel 注册 ...

        /// 注册平台视图
        guard let registrar = self.registrar(forPlugin: "map_view") else {
            fatalError("Failed to get registrar")
        }
        // 实例化我们的工厂。
        let factory = MapViewFactory(messenger: registrar.messenger())
        // 使用唯一ID "map_view" 注册工厂。
        // 这必须与 UiKitView 中的 `viewType` 匹配。
        registrar.register(factory, withId: "map_view", gestureRecognizersBlockingPolicy: FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded)

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

在这里,我们创建了一个 MapViewFactory 的实例,并用 ID "map_view" 注册了它。现在,每当 Flutter 中构建一个带有此 viewTypeUiKitView 时,Flutter 就会请求我们的 MapViewFactory 来创建相应的原生视图。

3. 实现工厂和平台视图 (iOS)

现在我们来看看工厂本身以及它所创建的平台视图。

3.1. 工厂 (MapViewFactory.swift)

工厂的工作很简单:创建我们的平台视图的一个实例。

// ... existing code ...
@available(iOS 17.0, *)
class MapViewFactory: NSObject, FlutterPlatformViewFactory {
    private var messenger: FlutterBinaryMessenger?

    init(messenger: FlutterBinaryMessenger) {
        self.messenger = messenger
        super.init()
    }

    // ...

    // Flutter 调用此方法来创建原生视图。
    func create(
        withFrame frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?
    ) -> any FlutterPlatformView {

        // 它返回我们的平台视图类的一个实例。
        return MapView(
            frame: frame,
            viewIdentifier: viewId,
            arguments: args,
            binaryMessenger: messenger
        )
    }
}

3.2. 平台视图 (MapView.swift)

这是主要的桥接类。它遵守 FlutterPlatformView 协议,并负责创建和管理实际 UIView 的生命周期。

// ... existing code ...
@available(iOS 17.0, *)
class MapView: NSObject, FlutterPlatformView {
    // 这持有将要被嵌入的实际 UIView。
    private var _mapView: UIView

    private var hostingController: UIHostingController<MapContentView>?

    init(
        frame: CGRect,
        viewIdentifier viewId: Int64,
        arguments args: Any?, // 这些是来自 Flutter 的 creationParams。
        binaryMessenger messenger: FlutterBinaryMessenger?
    ) {
        self._mapView = UIView()
        super.init()
        // 我们调用一个辅助方法来设置 SwiftUI 视图。
        createMapView(view: _mapView, args: args)
    }

    // 这个方法必须返回 Flutter 将要显示的 UIView。
    func view() -> UIView {
        return _mapView
    }

    func createMapView(view: UIView, args: Any?) {
        // 1. 解码来自 Flutter 的参数。
        guard let args = args as? [String: Any] else { return }
        guard let latitude = args["latitude"] as? Double,
        let longitude = args["longitude"] as? Double else { return }

        // 2. 用数据创建我们的 SwiftUI 视图。
        let mapContentView = MapContentView(latitude: latitude, longitude: longitude)

        // 3. 将 SwiftUI 视图托管在 UIHostingController 中。
        hostingController = UIHostingController(rootView: mapContentView)
        guard let hostingController = hostingController else { return }

        // 4. 将托管控制器的视图添加为子视图并设置约束。
        _mapView.addSubview(hostingController.view)
        // ... AutoLayout 约束 ...
    }
}

在这个类中:

  1. init 方法接收我们从 Dart 作为 creationParams 传递过来的 arguments
  2. 我们解析这些参数以获取纬度和经度。
  3. 我们初始化我们的 MapContentView (一个 SwiftUI 视图),并将其包装在一个 UIHostingController 中,以便它可以作为标准的 UIView 使用。
  4. view() 方法返回这个 UIView,然后 Flutter 将其渲染出来。

4. 使用 MethodChannel 进行双向通信

显示视图很棒,但是交互呢?我们需要一种方法让原生视图能够回过头来与 Flutter 通信。为此,我们使用 MethodChannel

4.1. 设置通道

通道必须在 Flutter 和原生两端用相同的名称进行初始化。

  1. Flutter ( lib/map_view.dart ):
    我们创建通道并设置一个处理器来监听来自原生的方法调用。
class _MapViewState extends State<MapView> {
  // 1. 使用与原生代码中相同的名称创建通道。
  final MethodChannel _channel = MethodChannel("map_view");

  @override
  void initState() {
    super.initState();
    // 2. 设置一个处理器来处理来自原生端的消息。
    _channel.setMethodCallHandler(_handle);
  }

  // 3. 处理器函数。
  Future<void> _handle(MethodCall call) async {
    switch (call.method) {
        // 如果原生端调用 "backFlutterView"...
      case "backFlutterView":
        // ...则弹出当前路由以返回。
        Navigator.pop(context);
        break;
    }
  }
  // ...
}
  1. iOS ( ios/Runner/AppDelegate.swift ):
    我们注册相同的通道,并且为了方便起见,将它存储在一个全局单例中,以便我们原生代码的其他部分可以轻松访问它。
/// 一个简单的单例来持有对通道的引用。
public class ChannelManager {
    static let shared = ChannelManager()
    var methodChannel: FlutterMethodChannel?
    private init() {}
}

@available(iOS 17.0, *)
@main
@objc class AppDelegate: FlutterAppDelegate {
    override func application( /* ... */ ) -> Bool {
        // ...
        /// 注册通道
        guard let flutterViewController = window?.rootViewController as? FlutterViewController else {
            fatalError("RootViewController is not FlutterViewController")
        }
        let channel = FlutterMethodChannel(name: "map_view", binaryMessenger: flutterViewController.binaryMessenger)
        // 将通道存储在我们的单例中。
        ChannelManager.shared.methodChannel = channel
        // ...
        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
}

4.2. 从原生调用 Flutter

现在,我们的原生视图可以使用这个通道来发送消息。在我们的 MapContentView 中,我们有一个原生的返回按钮。当点击它时,它会在通道上调用 "backFlutterView" 方法。

// ...
struct MapContentView: View {
    // ...
    var body: some View {
        ZStack {
            // ... 地图和其他 UI ...
            VStack {
                HStack {
                    Image(systemName: "chevron.left")
                    // ... 样式 ...
                    .onTapGesture(perform: onBackTap) // 点击时调用 onBackTap。
                    Spacer()
                    // ... 菜单 ...
                }
                // ...
            }
        }
        .ignoresSafeArea()
    }

    // 这个函数向 Flutter 发送消息。
    private func onBackTap() {
        DispatchQueue.main.async {
            // 使用共享的通道来调用一个方法。
            ChannelManager.shared.methodChannel?.invokeMethod(
                "backFlutterView", // 要调用的方法名。
                arguments: nil
            )
        }
    }
}

当这段代码运行时,它会通过 "map_view" 通道发送一条消息。我们的 Flutter _handle 函数接收到此消息,看到方法名是 "backFlutterView",然后执行 Navigator.pop(context),从而关闭地图屏幕并返回到主 Flutter 页面。

5. 总结

PlatformView 是与原生平台进行深度集成不可或缺的工具。虽然它可能会有性能方面的影响(尤其是在旧版 Android 上),但它提供了一种强大的方式来在你的 Flutter 应用中利用完整的原生生态系统。

6. 演示

录屏 2025年7月19日.gif

Flutter 中使用NestedScrollView + SliverAppBar 打造关注/粉丝列表(并结合 Bloc 状态管理)

Flutter 中使用 NestedScrollView + SliverAppBar 打造关注/粉丝列表(并结合 Bloc 状态管理)

Demo 仓库github.com/wutao23yzd/…


前言

在日常社区类 App 开发中,「用户主页」往往需要展示 关注 / 粉丝 等多 Tab 列表,并且顶部通常伴随着可吸顶的用户信息栏。要做到:

  1. 顶部信息栏(SliverAppBar)通过 pinned: true 始终固定在顶部;
  2. TabBarTabBarView 滑动手势联动;
  3. 列表切换时保持滚动位置 & 避免重复构建;
  4. 状态统一交由 Bloc 管理;

本文将基于一段完整的 Demo 代码,拆解实现思路与关键细节。


目录结构概览

lib/
├─ main.dart                 // 入口 & Provider 注入
├─ app_scaffold.dart         // 通用 Scaffold 封装
└─ follow/
   ├─ bloc/                  // ✨ Bloc
   |  ├─ user_profile_bloc.dart
   |  ├─ user_profile_event.dart
   |  └─ user_profile_state.dart
   └─ view/
      ├─ user_profile_statistics.dart  // 页面主体
      ├─ user_profile_followers.dart   // 粉丝列表
      ├─ user_profile_following.dart   // 关注列表
      ├─ user_profile_list_tile.dart   // 列表项
      ├─ user_profile_button.dart      // 通用按钮
      └─ tappable.dart                 // 自定义点击反馈

命名约定:所有与「用户资料」相关的文件统一前缀 user_profile_,查看起来更直观。


关键依赖

Package 说明
flutter_bloc Bloc/Cubit 状态管理
equatable Immutable state 对比

UI 结构拆解

1. 入口 & BlocProvider

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.black)),
      home: BlocProvider(
        create: (_) => UserProfileBloc(),
        child: const UserProfileStatistics(tabIndex: 0),
      ),
    );
  }
}

通过 顶层 BlocProvider 注入 UserProfileBloc,后续所有子组件可直接 context.select(...) 读取状态。

2. UserProfileStatistics 页面核心

class UserProfileStatistics extends StatefulWidget {
  const UserProfileStatistics({super.key, required this.tabIndex});
  final int tabIndex;
  ...
}

class _UserProfileStatisticsState extends State<UserProfileStatistics>
    with TickerProviderStateMixin {
  late final TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
    // 根据外部传入的 tabIndex 初始定位
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _tabController.animateTo(widget.tabIndex);
    });
  }

  @override
  Widget build(BuildContext context) {
    return AppScaffold(
      body: NestedScrollView(
        headerSliverBuilder: (_, __) => [
          SliverOverlapAbsorber(
            handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
            sliver: UserProfileStatisticsAppBar(controller: _tabController),
          ),
        ],
        body: TabBarView(
          controller: _tabController,
          children: const [
            UserProfileFollowers(),
            UserProfileFollowing(),
          ],
        ),
      ),
    );
  }
}
  • SliverOverlapAbsorber/Injector 解决 Tab 内部滚动冲突
  • AutomaticKeepAliveClientMixin 保证 TabBarView 内部列表 不被销毁

3. 顶部 SliverAppBar + TabBar

class UserProfileStatisticsAppBar extends StatelessWidget {
  const UserProfileStatisticsAppBar({super.key, required this.controller});
  final TabController controller;

  @override
  Widget build(BuildContext context) {
    final followers = context.select((UserProfileBloc b) => b.state.followersCount);
    final followings = context.select((UserProfileBloc b) => b.state.followingsCount);
    final user = context.select((UserProfileBloc b) => b.state.user);

    return SliverAppBar(
      pinned: true,
      title: Text(user.username ?? ''),
      bottom: TabBar(
        controller: controller,
        indicatorWeight: 1,
        labelColor: context.adaptiveColor,
        tabs: [
          Tab(text: '$followers 粉丝'),
          Tab(text: '$followings 关注'),
        ],
      ),
    );
  }
}

pinned: trueSliverAppBar 在滚动到顶后保持可见;两侧 Tab 文案通过 Bloc 实时反映 关注/粉丝数量。

4. 列表项 UserProfileListTile

class UserProfileListTile extends StatelessWidget {
  const UserProfileListTile({super.key, required this.user, required this.follower});
  final User user; // 当前渲染的用户
  final bool follower; // 是否粉丝列表

  @override
  Widget build(BuildContext context) {
    return Tappable.faded(
      onTap: () {},
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      child: Row(
        children: [
          CircleAvatar(foregroundImage: NetworkImage(user.avatarUrl ?? ''), radius: 26),
          const SizedBox(width: 12),
          Expanded(child: _UserInfo(follower: follower, user: user)),
        ],
      ),
    );
  }
}
  • 自定义 Tappable.faded 组件通过 FadeTransition 实现 点击渐变动效
  • UserActionButton 根据当前登录用户/目标用户身份动态切换「关注 / 粉丝 / 移除」等按钮文案。

Bloc 状态管理

1. State

class UserProfileState extends Equatable {
  const UserProfileState({
    required this.user,
    required this.followings,
    required this.followers,
  });

  // 省略初始 mock 数据...
  @override
  List<Object> get props => [user, followings, followers];
}

2. Event

目前 Demo 仅展示静态数据,正式接入接口后可以新增:

sealed class UserProfileEvent extends Equatable {
  const UserProfileEvent();
}

class FollowToggled extends UserProfileEvent {
  final User target;
  const FollowToggled(this.target);
}

3. Bloc

class UserProfileBloc extends Bloc<UserProfileEvent, UserProfileState> {
  UserProfileBloc() : super(UserProfileState.initial()) {
    on<FollowToggled>(_onFollowToggled);
  }

  void _onFollowToggled(
    FollowToggled event,
    Emitter<UserProfileState> emit,
  ) {
    // 根据 event.target.id 更新 followings / followers
    // emit(newState);
  }
}

推荐再配合 `` 将用户资料进行本地持久化,优化首屏体验。


交互与细节

细节 方案
点击反馈 Tappable.faded 使用 AnimationController + FadeTransition,避免手写 GestureDetector 重复代码
iOS / Android 状态栏主题 SystemUiOverlayStyle 封装,自适应亮/暗模式
垂直方向锁定 SystemUiOverlayTheme.setPortraitOrientation() 限制为竖屏

性能优化点

  1. 缓存列表cacheExtent: 2760SliverList 预缓存更多 item,提升滚动流畅度;
  2. 避免重建:两列表分别混入 AutomaticKeepAliveClientMixin,切 Tab 不重跑 build
  3. Selector 粒度context.select 精准订阅,只有关心字段变化时才重建 widget;

小结

  • 解决「顶部信息随滚动吸顶」与「内部 Tab 列表滑动」的场景。
  • 通过 Bloc 将 UI 与业务完全解耦,状态单向流动,易于测试与维护。
  • 一些常见痛点(滚动冲突、点击反馈、亮暗适配)都可以通过封装组件复用。

希望本文能帮助你在实际项目中快速落地关注/粉丝页,或者提供一些架构上的启发。🎉

慢跑

我这两年攀岩时总是体力不够用,出去野攀如果需要先爬山接近的话,往往爬到岩壁下就累个半死不想动了。而我那帮 50 多岁的岩友一个个都比我有活力的多。所以我想通过有氧运动改善一下心肺功能。岩友建议我试试慢跑。

去年底痛风发作 后也考虑过减少一些体重,据说有利于降低尿酸。但有点懒就一直没有开始跑。

我的身体状态是这样的:

目前身高 187 ,大学毕业时大约 183 ,后来 20 多年陆续又长了几厘米。大学刚毕业时体重只有 71 kg ,非常瘦。在 2002 年左右开始去健身房撸铁增肌,最高长到过 78kg 。后来去杭州没那么勤快了,又掉下来不少。到 2011 年回到广州时只剩下 74kg 不到。当时身高 185 - 186 之间,后来这 15 年又长了点身高,体重却在孩子出生后暴增,最高到过 90 kg 以上 。

前几年有一段时间,我自己在家做 HIIT 希望可以减重。2020 年时,因为尿路结石看了急症 。之后改做跳绳(希望可以排石),最后体重降到了 84 kg 。

最近一年因为不再上班工作了,除了偶尔(一周两到三次)出门去岩馆攀岩,几乎都在家里。体重在 3 个月前又升到了 91kg 。


大约在两个半月前,我下决心增加一些运动量。除了每周三次的攀岩外,另外四天每天做半个小时以上的慢跑。听取岩友建议,买了双软底的跑步鞋(体重较大,应重点保护膝盖)。选择在家旁边的公园,有质地比较软的跑步道。根据网上信息的测算,根据我的年龄,应该在慢跑时把心率控制在 140 以下。配速不重要,重要的是心率以及每次的时长(不低于 30 分钟),并避免受伤。

两个多月之前,我第一次尝试时,跑到 600 米左右,心率就超过了 150 ,赶紧停下来走路休息。

到现在坚持了两个多月,已经成为习惯。今天刮完台风,特别凉快。跑步时状态很好。第一公里用时 7 分钟,最后心率升到 140 。如果连续再跑下去还会上升,所以我选择走路休息到心率下降到 120 再继续。如此把心率维持在 120~140 之间,半个小时大约可以跑 3.5km 。

跑完再快走 5 分钟左右回家,不太觉得累。相比刚开始跑步时,到家就想躺下休息。这段时间在岩馆更也有动力爬。有岩友称,你终于有点老岩友的样子了。

至于体重,最近三天都在 86kg ,从数字上看已经减少了 5kg 。

控制尿酸方面:过去尿酸在 600 以上(体检报告记录)。现在戒掉了平时爱喝的含糖饮料,只在攀岩时喝一些运动饮料补充体力。日常喝苏打汽水(碱性),虽然以前也没有过多吃海鲜,现在是几乎不碰了。没有吃降尿酸的药。最近尿酸日常在 450 ~ 550 之间(每两天自测一次)。高低感觉和休息状态有关。如果白天过于劳累,晚上又没有好好休息的话,尿酸值也会明显升高。

脚没有再疼过,但总有点隐隐的感觉,可能是心理作用罢了。如果明年还不能降到 400 以下,考虑吃点药。


我知道跑步锻炼是一个漫长的过程,无法立竿见影。等半年以后再追加记录。

siwftui代码,.sheet不能跳转

错误描述:

siwftui代码,在GroupImageView中展示了16个ImageItemView,点击每个ImageItemView都能跳转到ImageDetailView。但是出现了bug: 

前 8 张图点击能跳转到 ImageDetailView,后面的图点击却不能跳转;但点击后面的图的下载按钮却会跳转并下载。 

 从你提供的完整代码中,我已经定位到了问题的根本原因:

 ✅ 问题分析:

ImageItemView 的 index 是固定为 0~15,而 images[index] 实际可能超过 16 张 真正的逻辑错误出现在这个语句:

.sheet(isPresented: $isPresentingDetail) {
    NavigationView {
        ImageDetailView(image: image, index: index)
    }
}

你在点击下载按钮中保存图片再跳转时,是用系统权限请求的方式,所以 sheet 不生效问题就被掩盖了。

🔍 真正的关键问题:SwiftUI 的 .sheet() 绑定在了局部状态变量上,状态无法持久于多个 ImageItemView 实例

ImageItemView 是 GroupImageView 的一个子视图,每个都持有自己的 @State var isPresentingDetail。

当你点击第 9 张(或以后)图片时,虽然你设置了 isPresentingDetail = true,但 SwiftUI 很可能不会触发 .sheet,因为系统无法正确判断哪个视图弹窗状态被改变了。

这在 SwiftUI 的 .sheet 使用中是一个经典坑:

.sheet 应该绑定在父视图中唯一的变量上,而不是每个子视图都各自持有 @State 控制弹窗。

✅ 解决方案(核心改动)

将 .sheet 逻辑移到父视图 GroupImageView 中统一管理,不要在 ImageItemView 内部持有自己的 isPresentingDetail。

🔧 修改步骤如下: 

1. 在 GroupImageView 中添加两个状态变量: 

@State private var selectedImage: UIImage? = nil
@State private var selectedIndex: Int = 0
@State private var isShowingDetail = false

 2. 修改 ImageItemView,添加一个点击回调:

struct ImageItemView: View {
    var image: UIImage
    var index: Int
    var onTap: () -> Void  // <-- 添加这个

    ...
    .onTapGesture {
        print("点击了图片: \(index)")
        onTap()  // <-- 调用外部的回调
    }
    ...
}

3. 在 GroupImageView 的 ForEach 中这样写:

ForEach(0..<images.count, id: \.self) { index in
    ImageItemView(image: images[index], index: index) {
        self.selectedImage = images[index]
        self.selectedIndex = index
        self.isShowingDetail = true
    }
    .aspectRatio(1, contentMode: .fit)
}

 4. 在 GroupImageView 的 .sheet 添加如下:

.sheet(isPresented: $isShowingDetail) {
    if let image = selectedImage {
        NavigationView {
            ImageDetailView(image: image, index: selectedIndex)
        }
    }
}

🧠 为什么前 8 张能跳转、后面的不能?

 因为前 8 张视图刚好布局在屏幕可见区域内,SwiftUI 对这些视图的状态处理更稳定;而后面图片滑动进来时,视图重建了,状态失效,所以 .sheet 无法响应。 这正是 SwiftUI 的 .sheet 在 List 或 LazyVGrid 等懒加载容器中常见的问题。

SwiftUI Bug记录:.sheet首次点击弹出空白视图,第二次才正常显示

在使用 SwiftUI 开发图片展示应用时,我遇到了一个令人困惑的问题:在进入 GroupImageView 后点击任意图片,.sheet 会弹出一个空白视图,没有内容显示,也没有打印任何跳转相关的调试信息。但令人惊奇的是,第二次点击同一张或另一张图片时,一切又都恢复正常。

 这是一次典型的 SwiftUI 状态绑定陷阱,本文将记录这个 Bug 的现象、源码分析及最终修复方案,帮助他人(包括未来的我)避免类似问题。 

📍 问题现象

进入 GroupImageView 后,点击图片列表中的任何一张图片:

 • 控制台正确输出 点击了图片: 15

 • 但没有输出 jump--to--ImageDetailView--index:15 

 • .sheet 弹出的是一个空白页面 当我再次点击其他图片或同一张图片时:

 • 控制台输出: 

 点击了图片: 14

 jump--to--ImageDetailView--index:14

 • .sheet 正常弹出并展示 ImageDetailView 内容

❌ 错误源码 

 以下是触发这个问题的相关简化代码片段:

@State private var selectedImageIndex: Int? = nil
@State private var showDetailSheet = false

var body: some View {
    ScrollView {
        LazyVGrid(columns: columns) {
            ForEach(images.indices, id: \.self) { index in
                let image = images[index]
                Button {
                    print("点击了图片: \(index)")
                    selectedImageIndex = index
                    showDetailSheet = true
                } label: {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFit()
                }
            }
        }
    }
    .sheet(isPresented: $showDetailSheet) {
        if let index = selectedImageIndex {
            print("jump--to--ImageDetailView--index:\(index)")
            ImageDetailView(image: images[index])
        }
    }
}

🔍 问题分析 

 乍一看逻辑是合理的,但这个 .sheet 弹空白的问题正是因为 selectedImageIndex 的值更新尚未完成,showDetailSheet 就已经变成 true 触发了 sheet 弹出。

 SwiftUI 的 .sheet 会在 showDetailSheet = true 这一刻立即尝试渲染内容,如果此时 selectedImageIndex 仍是 nil 或尚未被 SwiftUI 识别为已更新,.sheet 里的条件 if let index = selectedImageIndex 就无法满足,因此内容是空白。

 而第二次点击时,状态已经更新稳定,显示就一切正常了。

✅ 正确写法:绑定 Enum 或 Identifiable 对象

要解决这个问题,可以使用 sheet(item:) 绑定一个遵循 Identifiable 的对象,这样 SwiftUI 会等待该对象不为 nil 才呈现弹窗内容。代码改写如下:  

定义绑定项:

struct ImageIndexWrapper: Identifiable {
    var id: Int { index }
    let index: Int
}

修改状态:

@State private var selectedImage: ImageIndexWrapper? = nil

使用 .sheet(item:):

.sheet(item: $selectedImage) { wrapper in
    let index = wrapper.index
    print("jump--to--ImageDetailView--index:\(index)")
    ImageDetailView(image: images[index])
}

 更新点击事件:

Button {
    print("点击了图片: \(index)")
    selectedImage = ImageIndexWrapper(index: index)
} label: {
    Image(uiImage: image)
        .resizable()
        .scaledToFit()
}

🎉 效果验证

修复后: 

 • 第一次点击图片就能正确跳转;

 • 控制台完整打印跳转日志;

 • .sheet 内容始终正确展示;

 • 不再需要手动管理 showDetailSheet 状态。 

🧠 总结 

 SwiftUI 是声明式框架,对状态的依赖非常敏感。这个 .sheet 弹出空白视图的问题,本质上是由于 多个 @State 变量更新顺序与 SwiftUI 渲染机制的时机不匹配。使用 .sheet(item:) 可以让绑定行为更加可靠。

经验教训:

 • 避免同时依赖多个 @State 控制 .sheet;

 • 若弹窗内容依赖于某个值,尽量将该值直接作为绑定项;

 • .sheet(item:) 是更安全的做法。

将一个 现有 iOS Xcode 项目, 快速改造为 可以用 Android Studio 运行和调试 的项目

环境

  1. 本文不需要 任何关于 KMP 代码相关 的修改

  2. Android Studio Narwhal 及其以上 + macOS

  3. 安装插件
    plugins.jetbrains.com/plugin/inde…

  4. 重启 Android Studio.

  5. 配置 IDE
    开启 Settings | Advanced Settings 中的
    Enable experimental Multiplatform IDE features,
    可以在 Swift 代码上 打断点 等.

  6. 关闭 Android Studio.

项目目录路径

  • 项目根目录: MyApplication
  • iOS Module: MyApplication/iosApp
  • iOS Project: MyApplication/iosApp/iosApp.xcodeproj

建议 完全关闭 Android Studio 再修改.
不然感觉 关闭 Android Studio 的时候,
内存缓存 会覆盖 MyApplication/.idea.

必需文件

MyApplication/.idea/xcode.xml

<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="XcodeMetaData"
    PROJECT_DIR="$PROJECT_DIR$/iosApp"
    PROJECT_FILE="$PROJECT_DIR$/iosApp/iosApp.xcodeproj/project.xcworkspace" />
</project>

可选文件 (用于 控制 AS 中的 目录名 和 目录结构)

MyApplication/.idea/.name

这个文件 里头的内容, 会覆盖 AS 展示的名字, 酌情删除

MyApplication/.idea/iosApp.iml

这个文件的文件名 可以改, 不过 不建议 乱改.
Android Studio 目录结构 可能会 变得很奇怪.

<?xml version="1.0" encoding="UTF-8"?>
<module classpath="AppCode" type="CIDR_MODULE" version="4" />

MyApplication/.idea/modules.xml

注意与 与上面 .iml 文件名相同.

<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="ProjectModuleManager">
    <modules>
      <module fileurl="file://$PROJECT_DIR$/.idea/iosApp.iml"
              filepath="$PROJECT_DIR$/.idea/iosApp.iml" />
    </modules>
  </component>
</project>

gRPC 与传统 REST API 的区别与实践详解


gRPC 与传统 REST API 的区别与实践详解

一、前言

在现代分布式系统和微服务架构中,服务间通信协议的选择极其关键。RESTful API 是 Web 时代的标准,而 gRPC 作为 Google 推出的高性能远程调用框架,近年来在高性能、微服务场景下日益流行。很多开发者在选型时都会有疑问:gRPC 和 REST 有什么区别?什么时候用哪个更好?

本文将从最基础的 RPC 概念讲起,对比 gRPC 与 REST 的核心区别、原理、工程用法、以及实际选型建议。


二、什么是 RPC?

1. 概念解释

RPC(Remote Procedure Call,远程过程调用) 是一种让开发者像调用本地函数一样,去调用远程服务器上的方法的技术。

  • 本地调用 add(1, 2),在本机执行。
  • 用 RPC 调用 add(1, 2),底层会自动发请求到远程服务器,服务器处理后返回结果,本地像用普通函数一样得到结果。

2. RPC 的本质

  • 让网络调用变得像本地函数一样简单。
  • 不需要关心网络传输、序列化、协议细节。
  • 典型用途:微服务、分布式系统、服务化架构。

3. RPC 的发展

  • 早期:Java RMI、XML-RPC、SOAP、CORBA
  • 现代:gRPC(Google)、Thrift(Apache)、Dubbo(阿里巴巴)

三、REST API 基础回顾

REST(Representational State Transfer) 是一种面向资源的 Web API 设计风格。

  • 每个 URL 代表一个资源,用 HTTP 动词(GET/POST/PUT/DELETE)进行操作。
  • 以 JSON(或 XML)为主要数据格式,通用性好。
  • 天然适配浏览器、HTTP 客户端,易于调试和测试。

例子:

GET /users/123         # 获取用户
POST /users            # 创建用户
PUT /users/123         # 修改用户
DELETE /users/123      # 删除用户

四、gRPC 原理与特性

1. 基本原理

  • gRPC(Google Remote Procedure Call)是一种高性能、开源、支持多语言的 RPC 框架。
  • 基于 HTTP/2 协议进行通信,数据使用 Protocol Buffers(Protobuf)二进制序列化,效率高。
  • 接口定义用 .proto 文件,前后端根据接口定义自动生成代码。

2. gRPC 的主要特性

  • 高性能:HTTP/2、多路复用、头部压缩、二进制数据
  • 强类型:接口和数据结构由 .proto 文件约束,避免低级错误
  • 多语言支持:官方支持 10+ 语言(Java、Go、Python、C#、Node.js、Dart 等)
  • 流式通信:原生支持单向流、双向流(如实时推送、IM、音视频)
  • 自动生成代码:接口定义一次,多端生成,开发效率高

接口定义示例(.proto 文件):

syntax = "proto3";

service UserService {
  rpc GetUser (UserRequest) returns (UserReply) {}
}

message UserRequest {
  string user_id = 1;
}

message UserReply {
  string user_id = 1;
  string username = 2;
}

五、gRPC 和 REST API 的核心区别

方面 gRPC REST(HTTP API)
通信协议 HTTP/2 HTTP/1.1(主流)
序列化格式 Protobuf(二进制) JSON(文本)
调用方式 面向“方法/过程”,强类型 面向“资源”,松耦合
性能 高,低延迟,节省带宽 通用但体积大,延迟稍高
流式通信 原生支持双向/单向流 仅支持短连接/轮询/推送
浏览器支持 原生不支持,需 gRPC-Web 适配 天然支持
代码生成 支持多语言自动生成 一般用 Swagger/OpenAPI 生成文档
适用场景 微服务、服务间通信、实时场景 Web/Mobile API,对外服务

1. 通信协议和性能

  • gRPC 用 HTTP/2,支持连接复用、头压缩和流式,性能大幅领先 REST 的 HTTP/1.1。
  • REST 一般用 HTTP/1.1,每个请求/响应独立,效率较低。

2. 数据格式

  • gRPC 用 Protobuf,数据量小、序列化/反序列化快,但不易人读。
  • REST 用 JSON,通用易读,带宽/解析开销更高。

3. 调用方式与开发体验

  • gRPC:所有接口和数据结构在 .proto 中强类型定义,客户端像本地函数一样调用服务端(如 userService.getUser(req)),开发体验极好,易维护。
  • REST:用 HTTP 动词+URL,请求体/响应体无类型约束,出错风险高,开发需手动拼装参数。

4. 浏览器支持

  • REST:天然支持 Web/移动端各种客户端,前端调用无障碍。
  • gRPC:原生不能直接被浏览器 JS 调用,但可以用 gRPC-Web 适配(中间件转译)。

5. 代码生成

  • gRPC:后端和多端(Android/iOS/Go/Python 等)一套 .proto,自动生成所有模型和调用代码,极大减少手写/同步工作。
  • REST:只能用 Swagger/OpenAPI 生成接口文档和部分 SDK,类型安全性差,开发协同难度大。

六、代码对比示例

REST API 例子(Dart/Flutter 假代码)

final response = await http.get(Uri.parse('https://api.example.com/users/123'));
final data = json.decode(response.body);
final user = UserModel.fromJson(data);

gRPC 例子(Dart/Flutter 假代码)

final req = UserRequest(userId: '123');
final resp = await userServiceClient.getUser(req);
print(resp.username);

可以看到,gRPC 是强类型、面向对象的开发体验,更像直接调用本地方法。


七、实际选型建议

1. 推荐用 gRPC 的场景

  • 微服务体系内部服务间通信
  • 后端与后端、服务与服务之间的数据交互
  • 实时通信、高性能、大流量场景(IM、音视频、推流、物联网等)
  • 多端 SDK 自动生成,团队开发分工协作多

2. 推荐用 REST 的场景

  • 对外开放的 API、Web/Mobile 客户端
  • 快速原型开发、前后端协作以浏览器为主
  • 需要易于调试、易于集成第三方服务时

3. 混合用法

  • 很多大公司/中大型项目都采用“内部 gRPC、外部 REST”的双轨架构:

    • 微服务内部高效通信用 gRPC
    • 对外服务、前端调用仍暴露 RESTful API

八、常见问题

Q1: gRPC 为什么不能直接用于浏览器?
A1: 因为浏览器不直接支持 HTTP/2 的特性和 Protobuf 编解码,所以需要 gRPC-Web 适配。

Q2: gRPC 真的比 REST 快吗?
A2: 在高并发、流式通信和大数据量场景下,gRPC 性能明显优于 REST。但简单接口差距不大。

Q3: gRPC 支持哪些语言?
A3: Java、Go、C#、C++、Python、Dart、Kotlin、Swift、Node.js、Ruby 等,生态极其丰富。

Q4: REST 会被淘汰吗?
A4: 不会。REST 易于集成、调试、文档丰富,在公开 API 领域长期有生命力。


九、总结

  • gRPC 和 REST 本质区别在于通信协议、数据格式和开发方式。
  • gRPC 性能高、强类型、自动代码生成,适合服务间通信和高性能场景。
  • REST 通用易用、无平台壁垒,适合公开 API、Web/Mobile 客户端。
  • 实际开发中,按需选型/混合使用才是最优解

十、参考资料

ios 集成阿里云的AI智能体报错CocoaPods could not find compatible versions for pod "AUIAICal

Analyzing dependencies [!] CocoaPods could not find compatible versions for pod "AUIAICall/Chatbot":   In Podfile:     AUIAICall/Chatbot (from ./App/Lib/AUIAICall/) Specs satisfying the AUIAICall/Chatbot (from ./App/Lib/AUIAICall/) dependency were found, but they required a higher minimum deployment target.

解决方案:

说明你的项目目前的最低版本要比SDK的要求的最低版本要低导致的。直接点讲就是,集成的这个SDK最低要求系统版本是iOS 11.0可是你的实际项目这里是iOS 10.0导致的。所以把这里改成SDK要求的最低版本就好了。

Snip20250718_4.png

❌