普通视图

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

零一开源|前沿技术周刊 #16

作者 kymjs张涛
2025年9月15日 17:39

前沿技术周刊 是一份专注于技术生态的周刊,每周更新。本周刊深入挖掘高质量技术内容,为开发者提供持续的知识更新与技术洞察。

订阅渠道:【零一开源】、 【掘金】、 【RSS


大厂在做什么

本文介绍了在 iOS 平台上使用 MNN 框架部署大语言模型(LLM)时,针对聊天应用中文字流式输出卡顿问题的优化实践。通过分析模型输出与 UI 更新不匹配、频繁刷新导致性能瓶颈以及缺乏视觉动画等问题,作者提出了一套包含智能流缓冲、UI 更新节流与批处理、以及打字机动画渲染的三层协同优化方案。最终实现了从技术底层到用户体验的全面提升,让本地 LLM 应用的文字输出更加丝滑流畅,接近主流在线服务的交互体验。

新技术介绍

兄弟们,Kotlin 2.2.20 这波更新我瞅了眼,确实有料!编译速度又快了一截,尤其大项目能感觉到明显差异。标准库加了几个实用函数,比如`takeIfNotNull`,处理空安全更顺手了。Gradle 插件优化了依赖解析,同步项目不卡了。最香的是修复了之前协程调试偶尔崩溃的 bug,终于不用猜谜了。对了,JS 那边支持了最新的 Chrome 调试协议,前端同学也有福。整体稳得一批,建议直接升,没挖坑!

码圈新闻

“氛围编码”留下的烂摊子,终究要让那些被裁掉的人回来收拾。 自生成式人工智能兴起以来,许多人担心它会对人类员工的生计造成损害。如今,CEO 们也开始承认人工智能的影响,裁员人数也开始增加。
现年 81 岁的甲骨文联合创始人兼首席技术官拉里·埃里森(Larry Ellison)在一天之内财富暴涨近 1000 亿美元。这是有史以来最大的一次单日财富增长,而这一切都要归功于他在甲骨文中 41% 的持股。 截至昨日收盘,埃里森的财富为 2930 亿美元;截至周三中午,他的财富已达 4009 亿美元,成为历史上第二位财富突破 4000 亿美元的人。全球首富埃隆·马斯克在去年 12 月率先突破这一关口,彭博社称周三埃里森短暂超越马斯克,首次成为世界首富。
开源鸿蒙 OpenHarmony 6.0 Release 已于昨日发布并上线 Gitee。据介绍,OpenHarmony 6.0 Release 版本进一步增强 ArkUI 组件能力,提供更安全、更灵活的组件布局;进一步增强窗口能力,新增支持窗口处理文本显示的能力;进一步增强分布式数据管理能力,支持管理资产和资产组,支持应用对标准化数据进行展示;进一步增强位置服务、输入法框架的相关能力等等。
智东西9月4日消息,知名苹果爆料人、彭博社记者马克·古尔曼(Mark Gurman)最新爆料,苹果计划于明年春季推出自研AI搜索工具“答案引擎”。

博客推荐

最近看了篇文章,聊老项目里不支持Compose的View咋整,简直说到咱心坎里了。咱们接手的项目哪没几个祖传View?全重构Compose不现实,这篇给的招挺实在。 核心就俩方向:老View想塞进Compose,用AndroidView包一层就行,content里初始化时注意生命周期别瞎搞;要是想在View体系里嵌Compose,直接用ComposeView,addView或者xml里声明都能搞定。重点提了状态同步——别直接调setXxx,用remember存状态,不然容易崩。像地图、自定义播放器这种第三方View,这么搞完全够用。 总之过渡期不用头铁全重构,掌握这俩招,老View和Compose混着用妥妥的,省事儿又稳。
如果我们不满足于普通的在线加载图片,可以使用 ImageRequest 自定义加载。
AGSL 为使用 Jetpack Compose 构建动态、高性能、视觉震撼的 UI 效果打开了全新的大门。如果你曾经对 GLSL、HLSL 或其他着色语言有所耳闻,那么 AGSL 将是你在 Android 原生开发中实现复杂视觉效果的瑞士军刀,不,是屠龙宝刀!
1.图片初始位置相对屏幕Y轴的偏移没有赋值,影响图片放大后拖动的上下边界,导致上边会有黑边,下边有一部分显示不全 2.图片缩放没有限制,图片可以放大很大 3.手势识别区域为图片初始大小,放大之后其他区域没有响应
最近看了篇《KMP on iOS 深度工程化》,聊点实际干活能用上的。KMP跨平台看着香,但iOS工程里落地真得踩不少坑。 文章先说配置:gradle和Xcode的桥接得捋顺,不然编译报错能让你怀疑人生——比如Kotlin代码里调iOS API,参数类型得严格对应,String和NSString别混用,不然Xcode直接红。 然后是依赖管理:Kotlin库和iOS原生库版本得对齐,尤其第三方SDK,建议用ext统一管理版本号,不然编译时库冲突能卡你半天。 编译优化也提了:Kotlin/Native编译慢是老毛病,试试开增量编译,把常用模块打成预编译framework,能省不少时间。调试更得注意,断点打不上别慌,先检查Kotlin/Native的debug开关有没有开,日志用NSLog桥接能直接在Xcode控制台看。 最后说工程规范:建议按业务模块拆Kotlin代码,和iOS工程目录对应上,不然团队协作时找代码能绕晕。 总的来说,KMP在iOS工程化,核心就是把“跨平台”的便利和“iOS原生开发”的严谨捏到一起,坑不少但趟过去是真能提效。
通过分析代码,这个崩溃发生在App应用退出的时候,YYDiskCache会调用_appWillBeTerminated,将YYKVStorage置为nil,接着YYKVStorage会调用dealloc方法,最后调用[YYKVStorage _dbClose],在调用sqlite3_close的时候出现了崩溃。

GitHub 一周推荐


关于我们

零一开源】 是一个 文章开源项目 的分享站,有写博客开源项目的也欢迎来提供投递。 每周会搜集、整理当前的新技术、新文章,欢迎大家订阅。

[奸笑]

完成 Liquid Glass 的适配了吗? - 肘子的 Swift 周报 #102

作者 Fatbobman
2025年9月15日 22:00

明天 iOS 26 就要正式发布了,必然会有大批用户第一时间升级,体验全新的 Liquid Glass 设计语言。相比往年,今年的适配工作量明显更大——尤其对于交互复杂的应用,可能会遇到许多意想不到的问题。

SPM 之 混编(OC、Swift)项目保姆级教程(Swift Package Manager)

2025年9月15日 15:02

一、Swift Package Manager(以下简称SPM)简介

1.1、SPM 核心特性

1. 原生集成

  • Apple 官方支持:从 Xcode 11 开始内置,无需额外安装(对比 CocoaPods 需要 Ruby 环境,Carthage 需要 Homebrew)。
  • 与 Xcode 无缝协作:通过 Package.swift 文件定义依赖,Xcode 可直接解析并下载依赖。

2. 模块化设计

  • 基于 Swift 的模块系统:每个包(Package)是一个独立的模块,支持 target 划分(如库、测试、可执行文件)。
  • 跨平台兼容:同一套代码可编译到不同 Apple 平台或 Linux。

3. 依赖管理

  • 支持远程依赖:通过 Git 仓库(GitHub/GitLab)引入开源库。
  • 支持本地依赖:直接引用本地路径的模块(适合内部开发)。
  • 版本控制:支持语义化版本(SemVer)、分支、Commit Hash 等。

4. 资源管理

  • 原生支持资源文件:如 .xcassetsxibstoryboard,通过 resources 字段声明。
  • 访问方式:使用 Bundle.module 加载资源(无需手动处理路径)。

5. 命令行工具

  • 核心命令

    bash
    swift package init       # 初始化新包
    swift package resolve     # 解析依赖
    swift package update      # 更新依赖
    swift build              # 编译项目
    swift test               # 运行测试
    

1.2、SPM vs CocoaPods vs Carthage

对比项 SPM CocoaPods Carthage
官方支持 ✅ Apple 官方(Xcode 内置) ❌ 第三方(Ruby 编写) ❌ 第三方(Swift/Go 编写)
安装复杂度 ⚪ 无安装(Xcode 自带) ⚫ 需安装 Ruby 和 CocoaPods ⚫ 需安装 Homebrew 或编译
依赖原理 ✅ 直接编译源码 ⚫ 生成 .xcworkspace 和动态库 ✅ 编译二进制框架(静态库)
版本控制 ✅ 支持 SemVer/分支/Commit ✅ 支持 SemVer ✅ 支持 Git Tag
资源支持 ✅ 原生支持(Bundle.module ✅ 通过 resources 插件 ❌ 需手动处理
跨平台 ✅ 支持 macOS/iOS/Linux ❌ 仅 Apple 平台 ✅ 支持 Apple 平台 + Linux
二进制支持 不直接支持.a(包一下), 支持xcframework ✅ 可生成动态库(.framework ✅ 默认生成静态库(.a
学习曲线 ⚪ 中等(需熟悉 Package.swift ⚪ 中等(需了解 Podfile ⚪ 中等(需了解 Cartfile
适用场景 ✅ 新项目、模块化开发 ✅ 遗留项目、需要动态库 ✅ 追求编译速度、静态库

关键差异分析

  1. 依赖原理

    • CocoaPods:通过修改 project.pbxproj 文件,生成 .xcworkspace,依赖动态库(可能增加启动时间)。
    • Carthage:仅编译二进制框架,不修改项目文件,需手动集成到 Xcode。
    • SPM:直接编译源码到目标模块,不生成额外文件,与 Xcode 深度集成。
  2. 资源管理

    • SPM:原生支持资源文件,通过 Bundle.module 访问。
    • CocoaPods:需通过 resources 插件或手动配置 COPY_RESOURCES
    • Carthage:需手动将资源文件拖入项目。
  3. 二进制支持

    • Carthage 默认生成静态库,适合减小应用体积。
    • SPM 和 CocoaPods 默认编译源码,但 CocoaPods 可通过 use_frameworks! 生成动态库。

1.3、SPM 适用场景

1. 推荐使用 SPM 的情况

  • 新项目开发:从零开始的项目,可充分利用 SPM 的模块化设计。
  • 跨平台库:需要同时支持 macOS/iOS/Linux 的 Swift 库。
  • Apple 生态开发:与 Xcode 深度集成,避免第三方工具的兼容性问题。
  • 追求简洁性:不想处理 Ruby 环境或 Homebrew 依赖。

2. 不推荐使用 SPM 的情况

  • 遗留项目迁移:旧项目已使用 CocoaPods,迁移成本较高。
  • 需要二进制分发:如第三方 SDK 仅提供 .framework 文件(需通过 CocoaPods 或手动集成)。
  • 复杂依赖冲突:SPM 的依赖解析逻辑较严格,可能不如 CocoaPods 灵活。

1.4、SPM模块引入方式

image.png 引入方式有两种:

1、直接通过远程链接导入 2、直接依赖本地模块

1. 远程依赖(通过 Git 仓库导入)

适用场景
  • 依赖的模块是开源库(如 AlamofireSwiftLint)。
  • 模块托管在 GitHub、GitLab 或其他 Git 服务器上。
  • 需要自动获取最新版本或指定版本范围。
配置方法
(1) 在 Package.swift 中声明远程依赖
// swift-tools-version:5.7
import PackageDescription
 
let package = Package(
    name: "MyApp",
    dependencies: [
        // 方式1:指定版本范围(如 5.6.0 到 6.0.0)
        .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.6.0"),
        
        // 方式2:精确指定版本(如 5.6.1)
        .package(url: "https://github.com/SwiftGen/SwiftGen.git", exact: "6.6.0"),
        
        // 方式3:指定分支(如 main 分支,不推荐生产环境使用)
        .package(url: "https://github.com/example/repo.git", branch: "main"),
        
        // 方式4:指定 Commit Hash(用于临时修复)
        .package(url: "https://github.com/example/repo.git", revision: "a1b2c3d4e5f6"),
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "Alamofire", package: "Alamofire"),
                .product(name: "SwiftGenKit", package: "SwiftGen"),
            ]
        )
    ]
)
(2) 在 Xcode 中直接添加远程依赖
  1. 打开 Xcode 项目,选择 File > Add Packages
  2. 输入 Git 仓库 URL(如 https://github.com/Alamofire/Alamofire.git)。
  3. 选择版本规则(Up to Next MajorExact Version 或 Branch)。
  4. 点击 Add Package,Xcode 会自动生成 Package.swift 并下载依赖。

常见问题
Q1: 依赖下载失败(网络问题或仓库不存在)
  • 解决方法

    • 检查网络连接,确保能访问 Git 仓库。
    • 如果是私有仓库,需配置 SSH 密钥或 GitHub Personal Access Token。
    • 在 Xcode 中重置包缓存:File > Reset Package Caches
Q2: 版本冲突(如多个依赖要求不同版本的同一库)
  • 解决方法

    • 在 Package.swift 中显式指定版本:

      .package(url: "https://github.com/example/repo.git", from: "1.0.0"),
      
    • 使用 resolution 字段强制解析版本(Xcode 14+ 支持):

      // 在 Package.swift 的顶层添加
      let package = Package(
          // ...
          dependencies: [...],
          // 强制解析版本
          resolutions: [
              .package(url: "https://github.com/example/repo.git", exact: "1.0.0")
          ]
      )
      
Q3: 如何更新依赖到最新版本?
  • 方法

    • 运行 swift package update 命令。
    • 在 Xcode 中:Product > Clean Build Folder,然后重新编译。

2. 本地依赖(直接引用本地模块)

适用场景
  • 依赖的模块是本地开发的(如公司内部库、未开源的模块)。
  • 需要快速迭代本地代码,无需频繁推送 Git。
  • 模块与主工程紧密耦合,不适合远程托管。
配置方法
(1) 在 Package.swift 中声明本地依赖

假设本地模块位于主工程的同级目录 ../LocalModule

// swift-tools-version:5.7
import PackageDescription
 
let package = Package(
    name: "MyApp",
    dependencies: [
        // 本地依赖(相对路径)
        .package(path: "../LocalModule"),
        
        // 也可以混合远程依赖
        .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.6.0"),
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                "LocalModule", // 直接引用本地模块名
                .product(name: "Alamofire", package: "Alamofire"),
            ]
        )
    ]
)
(2) 本地模块的 Package.swift 示例

本地模块也需要是一个有效的 SPM 包,例如:

// swift-tools-version:5.7
import PackageDescription
 
let package = Package(
    name: "LocalModule",
    products: [
        .library(name: "LocalModule", targets: ["LocalModule"]),
    ],
    targets: [
        .target(name: "LocalModule", path: "Sources"),
    ]
)
(3) 在 Xcode 中添加本地依赖
  1. 打开 Xcode 项目,选择 File > Add Packages
  2. 点击 Add Local,选择本地模块的 Package.swift 文件。
  3. Xcode 会自动解析依赖并链接到主工程。

常见问题
Q1: 本地模块路径错误
  • 错误示例

    dependency 'LocalModule' not found at '../LocalModule'
    
  • 解决方法

    • 确保 .package(path: "../LocalModule") 的路径是相对于主工程 Package.swift 的。

    • 如果路径包含空格或特殊字符,用引号包裹路径:

      swift
      .package(path: "/path/with spaces/LocalModule")
      
Q2: 本地模块修改后未生效
  • 原因

    • Xcode 可能缓存了旧版本。
  • 解决方法

    • 清理构建缓存:Product > Clean Build Folder
    • 重新运行 swift package update
    • 如果使用 Xcode 的 SPM 集成,尝试 File > Reset Package Caches
Q3: 本地模块如何引用主工程的代码?
  • 问题

    • 本地模块和主工程可能存在循环依赖。
  • 解决方法

    • 避免循环依赖,将共享代码提取到第三个模块中。
    • 如果必须引用,可以使用 Xcode Workspace 结合 SPM(不推荐,复杂度高)。
Q4: 本地模块包含资源文件(如 .xcassets
  • 解决方法

    • 在本地模块的 Package.swift 中声明资源:

      .target(
          name: "LocalModule",
          dependencies: [],
          resources: [.process("Resources")] // 指定资源目录
      )
      
    • 在代码中通过 Bundle.module 加载资源:

      let bundle = Bundle.module
      let image = UIImage(named: "MyImage", in: bundle, with: nil)
      

3. 远程依赖 vs 本地依赖:如何选择?

对比项 远程依赖 本地依赖
适用场景 开源库、稳定版本 内部开发、快速迭代
版本控制 通过 Git 标签/分支管理 直接修改代码,无需推送
构建速度 较慢(需下载) 快(本地直接引用)
协作性 适合团队共享 适合单人或本地开发
依赖冲突 可能因版本不兼容报错 路径错误或循环依赖更常见

4. 最佳实践

  • 远程依赖:通过 .package(url:from:) 引入,适合开源库和稳定版本。
  • 本地依赖:通过 .package(path:) 引入,适合快速迭代的内部模块。
  • Xcode 集成:支持通过 GUI 添加依赖,但底层仍依赖 Package.swift
  • 常见问题:路径错误、版本冲突、缓存问题,通常通过清理缓存或调整路径解决。
  • 避免混合管理依赖:不要在 Package.swift 和 Podfile/Cartfile 中重复声明同一依赖。
  • 定期更新依赖:运行 swift package update 或使用 Xcode > Product > Clean Build Folder 保持依赖最新。

5. 彻底清理缓存

⚠️注意:当你在尝试配置模块已经依赖时,如果没有完全清空缓存,可能导致你正确的代码无法正常编译。

5.1.当前模块改动

swift package update
swift package clean

5.2.Package依赖缓存清理

Xcode的工具栏:File - Packages - Reset Package Caches image.png

5.3.退出Xcode 工程, 重新打开项目 必要时清理项目缓存 ~/Library/Developer/Xcode/DerivedData/你的工程

二、本地模块实操

实践中通常使用现有项目,此文演示先创建一个空OC项目(Swift也一样, 只不过大部分项目壳工程都是OC,兼容性问题也多,方便演示后面一些问题):

image.png

1、纯Swift文件

项目路径下添加模块

#本地管理路径
➜  SPMDemo git:(main) ✗ mkdir LocalModules && cd LocalModules

#测试模块1
➜  LocalModules git:(main) ✗ mkdir ModulesA && cd ModulesA

#创建项目模块
➜  ModulesA git:(main) ✗ swift package init --type library
Creating library package: ModulesA
Creating Package.swift
Creating .gitignore
Creating Sources/
Creating Sources/ModulesA/ModulesA.swift
Creating Tests/
Creating Tests/ModulesATests/
Creating Tests/ModulesATests/ModulesATests.swift

swift package init --type 参数

--type 参数用于指定初始化包的类型,支持以下选项:

选项 说明
--type library 创建一个 库(Library)  类型的包(默认选项,可省略)。
--type executable 创建一个 可执行文件(Executable)  类型的包(包含 main.swift)。
--type system-module 创建一个 系统模块(System Module)  类型的包(用于 C 语言家族的模块)。
--type manifest 仅创建一个空的 Package.swift 清单文件(不生成其他模板文件)。

生成的library文件结构

ModulesA/
├── Package.swift       # 包描述文件
├── Sources/
│   └── ModulesA/      # 库源码目录,这里可以添加你的swift模块
│       └── ModulesA.swift
└── Tests/
    └── ModulesATests/ # 测试目录
        └── ModulesATests.swift

添加:

image.png

设置目标工程:

image.png

纯swift模块在项目中效果:

image.png

⚠️注意:如果你的库源码目录不在Sources/ModulesA下,而是自定义路径(建议使用规范路径),需要设置path:

image.png

2、纯OC文件

2.1、模块目录结构

MyOCModule/
├── Sources/
│   ├── MyOCModule/          # OC 代码目录
│   │   ├── include/          # 公开头文件目录(可选)
│   │   │   └── MyOCModule.h # 模块主头文件
│   │   │   └── module.modulemap     # 模块映射文件(关键)
│   │   └── src/             # OC 源文件目录
│   │       └── MyOCClass.m  # OC 类实现
│   │       └── MyOCClass.h  # OC 类头文件
├── Package.swift            # SPM 配置文件
└── README.md                # 项目说明(可选)

2.2、关键文件配置

1. module.modulemap(模块映射文件)

定义 OC 模块的接口,指定头文件路径。示例内容:

module MyOCModule {
    header "MyOCModule.h"  // 指向模块主头文件
    export *                       // 导出所有头文件内容
}
  • header:指定模块的主头文件(如 MyOCModule.h),该文件需包含所有对外公开的头文件。
  • export:控制头文件的可见性(* 表示导出所有内容)。
2. Package.swift(SPM 配置文件)

配置 Target 依赖关系及编译路径。示例内容:

// swift-tools-version:5.7
import PackageDescription
 
let package = Package(
    name: "MyOCModule",
    products: [
        .library(
            name: "MyOCModule",
            targets: ["MyOCModule"]
        ),
    ],
    targets: [
        .target(
            name: "MyOCModule",
            path: "Sources/MyOCModule",  // OC 代码路径
            exclude: ["info.plist"], // 被排除的文件,多个用逗号分隔(如果有就行设置,没有的文件不要写,会有警告文件找不到)
            sources: ["src"],  // OC源代码路径:path 路径下
            publicHeadersPath: "include"      // 公开头文件夹
        ),
    ]
)
  • publicHeadersPath:指定公开头文件目录(如 include),需与 module.modulemap 中的路径一致。
  • sources:指定源文件目录(如 src),包含 .m 和 .h 文件。

image.png

⚠️注意:模块集成后,会以库形式在工程中被依赖,假如你要删除此OC模块,需要确保此处同样被移除,否则编译会报错!!!

image.png

3、Swift Package模块 依赖 OC Package 模块

1. 在 Swift 代码中导入 OC 模块

⚠️注意:

这里OC Package 是通过配置 include/module.modulemap 实现被Swift 依赖引用的,另外一种方式后面介绍

1、Package 配置:

// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "ModulesA",
    products: [
        .library(
            name: "ModulesA",
            targets: ["ModulesA"]),
    ],
    dependencies: [
        // 依赖地址或者本地资源文件夹地址.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(path: "../MyOCModule")
    ],
    targets: [
        .target(
            name: "ModulesA",
            dependencies: [
                // 依赖OC模块
                .product(name: "MyOCModule", package: "MyOCModule")
            ]
        ),
        .testTarget(
            name: "ModulesATests",
            dependencies: ["ModulesA"]),
    ]
)

⚠️注意:上面依赖一个模块时,一个当前Package索引的依赖路径,也就是和 targets 评级的, 而每个不同的子target根据自己的需要依赖不同的模块,比如此处设置依赖的MyOCModule:

// 依赖OC模块
.product(name: "MyOCModule", package: "MyOCModule")

发现product 的name 和 package name 是一样的,那么就可以简写成如下:

.target(
    name: "ModulesA",
    dependencies: [
        "MyOCModule" // 依赖OC模块
    ]
)
2、在 Swift 文件中,通过 import 语句导入 OC 模块
import MyOCModule  // 导入 OC 模块
 
class SwiftClass {
    func useOCMethod() {
        let ocObject = MyOCClassPublicA()
        ocObject.test() // OC测试方法调用
    }
}

4、同一个 OC Package 模块中多个 OC target(类似于.podspec中 subspec)

1.配置 Target 依赖关系

若模块间需相互调用,需在 Package.swift 中配置 Target 依赖关系:

// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyOCModule",
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "MyOCModule",
            targets: ["MyOCModule"]),
        .library(
            name: "MyOCModuleB",
            targets: ["MyOCModuleB"]),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .target(
            name: "MyOCModule",
            dependencies: ["MyOCModuleB"], // 依赖 MyOCModuleB
            path: "Sources/MyOCModule",  // OC 代码路径
            exclude: ["info.plist"], // 被排除的文件,多个用逗号分隔(如果有就行设置,没有的文件不要写,会有警告文件找不到)
            sources: ["src"],  // OC源代码路径:path 路径下
            publicHeadersPath: "include"
        ),
        .target(
            name: "MyOCModuleB",
            dependencies: [],
            path: "Sources/MyOCModuleB",  // OC 代码路径
            sources: ["src"],
            publicHeadersPath: "include"
        )
        
    ]
)

2.目录结构和调用:

image.png

⚠️注意:

从上面截图可以看到,在同一个模块内部代码之间一般是直接引用的 #import "MyOCModuleB.h" 那么,如果需要 #import <MyOCModuleB/MyOCModuleB.h> 方式引入呢?在下一节介绍

5、OC Package 依赖另一个 OC Package 模块

前面提到,OC target 通过配置 include/module.modulemap 是可以实现:

1、直接被Swift Package/target 通过import PackageName or targetName 引用

2、或者 OC target 依赖后通过 #import "FileName.h" 引用的

但是 OC Package 之间是需要通过 #import <MyOCModuleB/MyOCModuleB.h> 方式引用头文件的,接下来通过几个步骤介绍:

1、创建一个OC Package:MyOCHello

swift package init --type library MyOCHello


MyOCHello/
├── Sources/
   ├── MyOCHello/          # OC 代码目录
      ├── include/          # 公开头文件目录
         └── MyOCHello.h # 模块主头文件
      └── Core/             # OC 源文件目录
         └── MyOCHelloName.m  # OC 类实现
         └── MyOCHelloName.h  # OC 类头文件
      └── Private/             # OC 源文件目录
          └── MyOCHelloAge.m  # OC 类实现
          └── MyOCHelloAge.h  # OC 类头文件
├── Package.swift            # SPM 配置文件

2、设置公开头文件目录

MyOCHelloName.h 文件作为 public 头文件,前面示例中,我直接将 MyOCClassPublicA.h 文件拖动到 include 文件夹中。

但是在MyOCHello 模块不准备如此,而是创建MyOCHelloName.h 文件替身放到include 文件夹中

原因1:此模块我不打算创建 module.modulemap文件,移走后可能导致 MyOCHelloAge.m 无法使用 #import "MyOCHelloName.h"

原因2:不希望移动现有工程的文件的头文件

3、通过命令 ln -s 创建替身

格式 ln -s xxx文件夹目录/**.h xxx文件夹目录/

ln -s ~/Documents/SPMDemo/LocalModules/MyOCHello/Sources/MyOCHello/Core/MyOCHelloName.h ~/Documents/SPMDemo/LocalModules/MyOCHello/Sources/MyOCHello/include/MyOCHello

⚠️注意: 创建替身实践中存在以下几个问题,导致我项目加载总是失败(以下3种情况创建的替身在我电脑均索引失败😑,可能只是我的电脑问题,列出来希望能少走弯路)

1、右键.h文件创建替身,然后拖动到 include/MyOCHello/ 文件夹下面,替身在Xcode 15中无法编辑,无法索引

2、使用 ln -s 错误命令:ln -s xx/MyOCHelloName.h xx/MyOCHelloName.h

3、文件路径使用的相对路径,拖动替身到其它文件夹中就可能存在无法正常索引情况,请使用绝对路径。

至此,文件夹目录大致是这样的:

image.png

或者在终端通过 tree 命令(安装:brew install tree)生成结构:

MyOCHello git:(main) ✗ tree
.
├── Package.swift
├── Sources
│   └── MyOCHello
│       ├── Core
│       │   ├── MyOCHelloName.h
│       │   └── MyOCHelloName.m
│       ├── Private
│       │   ├── MyOCHelloAge.h
│       │   └── MyOCHelloAge.m
│       └── include
│           └── MyOCHello
│               ├── MyOCHello.h
│               └── MyOCHelloName.h -> xxx/Core/MyOCHelloName.h //会显示真身路径
└── Tests
    └── MyOCHelloTests
        └── MyOCHelloTests.swift

9 directories, 8 files

如果你想展示模块配置信息: 执行命令 swift package describe

MyOCHello git:(main) ✗ swift package describe
Name: MyOCHello
Manifest display name: MyOCHello
Path: /Users/xxx/SPMDemo/LocalModules/MyOCHello
Tools version: 5.10
Dependencies:
Platforms:
Products:
    Name: MyOCHello
    Type:
        Library:
            automatic
    Targets:
        MyOCHello

Targets:
    Name: MyOCHello
    Type: library
    C99name: MyOCHello
    Module type: ClangTarget
    Path: Sources/MyOCHello
    Sources:
        Core/MyOCHelloName.m
        Private/MyOCHelloAge.m
    Product memberships:
        MyOCHello

4、编辑模块头文件:

MyOCHello.h

#ifndef MyOCHello_h
#define MyOCHello_h

// 暴露头文件给外部使用时,使用简括号形式引入,并且对应头文件能在include目录下索引
#import <MyOCHello/MyOCHelloName.h>

#endif /* MyOCHello_h */

MyOCHelloName.h

@interface MyOCHelloName : NSObject

+ (void)helloSomeOne:(NSString *)name;

@end

MyOCHelloName.m

#import "MyOCHelloName.h"
#import "MyOCHelloAge.h"
@implementation MyOCHelloName

+ (void)helloSomeOne:(NSString *)name {
    NSLog(@"MyOCHello, name:%@, age:%@", name, [MyOCHelloName showAge]);
}

@end
5、MyOCModule 依赖 MyOCHello 模块
dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(path: "../MyOCHello"),
    ],
    targets: [
        .target(
            name: "MyOCModule",
            dependencies: [
                "MyOCHello"
            ], // 依赖
            
      等等
6、清理缓存(前面介绍了如何彻底清理缓存)

Xcode的工具栏:File - Packages - Reset Package Caches

image.png

重启工程

7、引用并调用
#import "MyOCClassPublicA.h"
#import <UIKit/UIKit.h>

// 引用其它OC模块
#import <MyOCHello/MyOCHello.h>

@implementation MyOCClassPublicA

/// 调用MyOCHello模块方法
- (void)OCHelloName {
    [MyOCHelloName helloSomeOne:@"Bobo"];
}

@end

6、OC、Swift混编模块

6.1. CocoaPods场景:

CocoaPods中存在一些老项目是OC代码为主,后来新增部分Swift代码, OC 和 Swift直接是可以直接相互引用的。

1、Pods库中代码文件可以 混编

2、Swift可以直接使用当前模块的公开的OC头文件,podspec中设置public路径,最后在-umbrella.h中查看到:

image.png

3、而module中的OC文件可以通过 #import <SHMaasUtils/SHMaasUtils-Swift.h> 依赖模块中 @objc public 的swift文件,而这个-Swift.h头文件是自动生成的,不需要手动创建。

6.2. SPM场景:

1、Package库中混编代码文件 不可以 混装,需要单独设置 target

2、Swift可以通过 dependencies 依赖公开的OC头文件(前面已经介绍了)

3、Package中的OC target文件目前尝试了,无法通过通过 #import <SHMaasUtils/SHMaasUtils-Swift.h> 依赖模块中 @objc public 的swift文件,而这个-Swift.h头文件是不会自动生成的

如果你通过 dependencies 依赖Swift target,那么必然导致依赖循环,可使用拆分公共文件作为独立target,协议解耦, 等方法处理,再使用 @import 引用swift文件即可

下面只介绍最复杂的场景

1、如果存在A.h 文件 和 B.swift 文件相互依赖的情况,那么需要将源码先解耦合 最终:A.h依赖C、 B.swift依赖C

2、将Package拆分: OCTarget、SwiftTarget、AdapterTarget

依赖关系1: OCTarget -> (SwiftTarget -> AdapterTarget)

依赖关系2: SwiftTarget -> (OCTarget -> AdapterTarget)

依赖关系3: SwiftTarget -> AdapterTargetOCTarget -> AdapterTarget

⚠️ 1和2场景的混合模块还是很常见,如果完全做到3的关系可能修改业务比较多,影响过大。

下面就以关系1的场景创建示例模块,并且AdapterTarget为OC代码的场景。

6.3. 创建混合模块

文件目录:

MixModule git:(main) ✗ tree
.
├── MixModuleAdapter
│   ├── PersonAdapter.h
│   └── PersonAdapter.m
├── MixModuleOC
│   ├── PersonHomeView.h
│   ├── PersonHomeView.m
│   └── include
│       └── MixModuleOC
│           ├── MixModuleOC.h
│           └── PersonHomeView.h -> /Users/xxx/SPMDemo/LocalModules/MixModule/MixModuleOC/PersonHomeView.h
├── MixModuleSwift
│   └── PersonInfoView.swift
└── Package.swift

6 directories, 8 files

Package.swift

// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MixModule",
    products: [
        .library(
            name: "MixModuleOC",
            targets: ["MixModuleOC"]),
        .library(
            name: "MixModuleSwift",
            targets: ["MixModuleSwift"]),
        .library(
            name: "MixModuleAdapter",
            targets: ["MixModuleAdapter"]),
    ],
    targets: [
        .target( // OC业务
            name: "MixModuleOC",
            dependencies: ["MixModuleSwift"],
            path:"MixModuleOC",
            sources: [""], // 需要设置
            publicHeadersPath: "include"),
        .target( // swift 业务
            name: "MixModuleSwift",
            dependencies: ["MixModuleAdapter"],
            path:"MixModuleSwift"
        ),
        .target( // 中间、适配
            name: "MixModuleAdapter",
            dependencies: [],
            path:"MixModuleAdapter",
            sources: [""],
            publicHeadersPath: ""
        )
        
    ]
)

1、OC target调用Swift target文件:

MixModuleOC.h (可以对外暴露)

//
//  MixModuleOC.h
//  
//
//  Created by 聂小波 on 2025/9/10.
//

#ifndef MixModuleOC_h
#define MixModuleOC_h

#import <MixModuleOC/PersonHomeView.h>

#endif /* MixModuleOC_h */

PersonHomeView.m (⚠️ 依赖swift target 使用 @import

//
//  PersonHomeView.m
//  
//
//  Created by 聂小波 on 2025/9/10.
//

#import "PersonHomeView.h"

// 引用swift target
@import MixModuleSwift;

@implementation PersonHomeView

- (void)showInfoView {
    NSLog(@"PersonHomeView show");
    PersonInfoView *personInfoView = [[PersonInfoView alloc] init];
    [personInfoView hello];
}

@end
2、Swift target调用OC target文件:
//
//  PersonInfoView.swift
//
//
//  Created by 聂小波 on 2025/9/10.
//

import Foundation
import UIKit

// 依赖OC target
import MixModuleAdapter

// 注意:给OC工程或者模块调用需要 @objc public
@objc
public class PersonInfoView: NSObject {
    @objc public override init() {
        super.init()
    }
    
    @objc public func hello() {
        let adapter = PersonAdapter()
        print("PersonInfoView: 我是", adapter.name())
    }
}

3、其它 Package 依赖 MixModule Package 的 MixModuleOC target
dependencies: [
        // .package(url: /* package url */, from: "1.0.0"),
        .package(path: "../SnapKit"),
        .package(path: "../MixModule") // 本地模块路径
    ],
    targets: [
        .target(
            name: "MyOCModule",
            dependencies: [
                "SHMaasService",
                // 依赖 MixModule Package 的 MixModuleOC target
                .product(name: "MixModuleOC", package: "MixModule")
            ], 
            
     ....

7、依赖 framework 库

如果你的项目必须依赖某个 framework 库

1、如果是 .xcframework 那么不用处理,SPM 直接支持

2、如果是 .framework 需要转 .xcframework

3、如果 .xcframework 放到主工程中,那么可以直接使用

4、如果 .xcframework 在 package 中通过 url 远程加载直接使用

5、如果 .xcframework 在 package 中通过本地文件路径加载,需要先签名

1. MBProgressHUD.framework 转 MBProgressHUD.xcframework

image.png

MBProgressHUD文件结构(不含版本):
MBProgressHUD.framework
.
├── Headers
│   └── MBProgressHUD.h
├── Info.plist
├── MBProgressHUD
└── Modules
    └── module.modulemap

3 directories, 4 files

结构简单,可以直接转xcframework

转换脚本 xcframework.sh:
#!/bin/bash

# 检查是否传入了框架路径参数
if [ $# -eq 0 ]; then
    echo "未输入framework名称"
    exit 1
fi
 

FRAMEWORK_NAME="$1"

# 创建临时目录
mkdir -p ./iOS-Device ./iOS-Simulator

# 提取 arm64(真机)版本
lipo -extract arm64 "${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" -o "./iOS-Device/${FRAMEWORK_NAME}"
cp -R "${FRAMEWORK_NAME}.framework" "./iOS-Device/${FRAMEWORK_NAME}.framework"
mv "./iOS-Device/${FRAMEWORK_NAME}" "./iOS-Device/${FRAMEWORK_NAME}.framework/"

# 提取 x86_64(模拟器)版本
lipo -extract x86_64 "${FRAMEWORK_NAME}.framework/${FRAMEWORK_NAME}" -o "./iOS-Simulator/${FRAMEWORK_NAME}"
cp -R "${FRAMEWORK_NAME}.framework" "./iOS-Simulator/${FRAMEWORK_NAME}.framework"
mv "./iOS-Simulator/${FRAMEWORK_NAME}" "./iOS-Simulator/${FRAMEWORK_NAME}.framework/"

xcodebuild -create-xcframework \
    -framework "./iOS-Device/${FRAMEWORK_NAME}.framework" \
    -framework "./iOS-Simulator/${FRAMEWORK_NAME}.framework" \
    -output "${FRAMEWORK_NAME}.xcframework"


# 如果是本地SPM调试,需要对每个平台(真机/模拟器)的框架进行签名
codesign --force --sign "Apple Development: Dean Nie (XXS7DPMZ77)" \
    --entitlements entitlements.plist \
    --timestamp=none \
    "${FRAMEWORK_NAME}.xcframework/ios-arm64/${FRAMEWORK_NAME}.framework"

codesign --force --sign "Apple Development: Dean Nie (XXS7DPMZ77)" \
    "${FRAMEWORK_NAME}.xcframework/ios-arm64_x86_64-simulator/${FRAMEWORK_NAME}.framework"

 

先授权脚本,再使用: xcframework.sh 和 MBProgressHUD.framework在同一目录下

chmod +x xcframework.sh
./xcframework.sh MBProgressHUD
2. FMDB.framework 转 FMDB.xcframework

image.png

framework文件结构(含版本):
FMDB.framework
.
├── FMDB -> Versions/Current/FMDB
├── Headers -> Versions/Current/Headers
├── Modules
│   └── module.modulemap
└── Versions
    ├── A
    │   ├── FMDB
    │   └── Headers
    │       ├── FMDB.h
    │       ├── FMDatabase.h
    │       ├── FMDatabaseAdditions.h
    │       ├── FMDatabasePool.h
    │       ├── FMDatabaseQueue.h
    │       └── FMResultSet.h
    └── Current -> A

7 directories, 9 files

通过文件结构可以发现,framework 真实二进制文件 可以通过 FMDB.framework/Versions/Current/ 路径查找

⚠️ 注意:在 SPM 中xcframework 文件结构测试下来不支持 版本管理结构。

image.png

可以自己动手修改脚本将文件重新梳理

3. MBProgressHUD.xcframework 放在主工程中使用

修改 linkerSettings 添加依赖即可:

targets: [
        .target(
            name: "MixModuleOC",
            dependencies: ["MixModuleSwift"],
            path:"MixModuleOC",
            sources: [""],
            publicHeadersPath: "include", 
            linkerSettings: [
                .linkedFramework("MBProgressHUD")
            ]
        ),
4. MBProgressHUD.xcframework 放在package中

创建一个包含多个库的Package:FMLibs

目录结构如下:

image.png

FMLibs git:(main) ✗ tree
.
├── Package.swift
└── binaryLibs
    └── MBProgressHUD.xcframework

Package 配置如下:

// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "FMLibs",
    products: [
        .library(
            name: "FMLibs",
            targets: [
                "MBProgressHUD",
                // "FMDB"
            ]
        ),
    ],
    targets: [
        .binaryTarget(name: "MBProgressHUD",path: "binaryLibs/MBProgressHUD.xcframework"),
        // .binaryTarget(name: "FMDB",path: "binaryLibs/FMDB.xcframework"),
        
    ]
)
5. MixModule 依赖 FMLibs 并使用

MixModule Package.swift

dependencies: [
        .package(path: "../FMLibs")
    ],
targets: [
    .target(
        name: "MixModuleOC",
        dependencies: [
            "MixModuleSwift",
            "FMLibs"
        ],
        path:"MixModuleOC",
        sources: [""],
        publicHeadersPath: "include", 
        linkerSettings: [
        ]
    ),

显示HUD(直接调用演示)

#import "PersonHomeView.h"
@import MixModuleSwift;

//binaryTarget
#import <MBProgressHUD/MBProgressHUD.h>

@implementation PersonHomeView

/// 测试HUD提示
- (void)showHUD {
    [MBProgressHUD showHUDAddedTo:[UIApplication sharedApplication].keyWindow animated:YES];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [MBProgressHUD hideHUDForView:[UIApplication sharedApplication].keyWindow animated:YES];
    });
}

- (void)showInfoView {
    [self showHUD];
    NSLog(@"PersonHomeView show");
    PersonInfoView *personInfoView = [[PersonInfoView alloc] init];
    [personInfoView hello];
}

@end

运行app:

IMG_0003.PNG

6.包含.bundle 资源的 MJRefresh.framework 库

SPM支持bundle资源文件,直接使用前面脚本转 MJRefresh.xcframework

image.png

FMLibs Package.swift

// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "FMLibs",
    products: [
        .library(
            name: "FMLibs",
            targets: [
                "MBProgressHUD",
                "MJRefresh"
            ]
        ),
    ],
    targets: [
        .binaryTarget(name: "MJRefresh",path: "binaryLibs/MJRefresh.xcframework"),
        .binaryTarget(name: "MBProgressHUD",path: "binaryLibs/MBProgressHUD.xcframework")
    ]
)

在ViewController使用:


import MJRefresh


class RefreshViewController: UIViewController {
    private let tableView = UITableView()
    private var dataSource = [String](repeating: "初始数据", count: 10)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        configureRefreshHeader()
    }
    
    private func setupTableView() {
        tableView.frame = view.bounds
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        view.addSubview(tableView)
    }
    
    private func configureRefreshHeader() {
        // 创建自定义刷新头
        let header = MJRefreshNormalHeader { [weak self] in
            self?.loadNewData()
        }
        guard let header else { return }
        // 本地化文本设置
        header.setTitle("下拉刷新", for: .idle)
        header.setTitle("释放立即刷新", for: .pulling)
        header.setTitle("加载中...", for: .refreshing)
        header.lastUpdatedTimeLabel?.isHidden = true
        
        tableView.mj_header = header
    }
    
    private func loadNewData() {
        // 模拟网络请求
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
            self.dataSource.insert("新增数据 \(Date())", at: 0)
            self.tableView.reloadData()
            self.tableView.mj_header?.endRefreshing()
        }
    }
}

extension RefreshViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return dataSource.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = dataSource[indexPath.row]
        return cell
    }
}

运行:

image.png

8、集成 .a 静态库

SPM 中无法直接使用 .a 文件,需要包在 xcframework 形式文件夹中访问

接下来演示如何将 libffi.a 转 ffi.xcframework

1、libffi 的源码文件如下:
➜  libffi-source git:(main) ✗ tree
.
├── ffi.h
├── ffi_arm.h
├── ffi_arm64.h
├── ffi_i386.h
├── ffi_x86_64.h
├── ffitarget.h
├── ffitarget_arm.h
├── ffitarget_arm64.h
├── ffitarget_i386.h
├── ffitarget_x86_64.h
└── libffi.a
2、将所以的.h 文件都放入 Headers 文件夹中
3. 检查 .a 库支持的架构
➜  lib git:(main) ✗ lipo -info libffi.a
Architectures in the fat file: libffi.a are: armv7 i386 x86_64 arm64
4. 拆分 .a 库
# 提取 arm64(真机)
➜  lib git:(main) ✗ lipo -extract arm64 libffi.a -o ffi-arm64.a

# 检查提取结果是否正确
➜  lib git:(main) ✗ lipo -info ffi-arm64.a
Architectures in the fat file: ffi-arm64.a are: arm64


# 提取 x86_64(Intel 模拟器)
➜  lib git:(main) ✗ lipo -extract x86_64 libffi.a -o ffi-x86_64.a

# 检查提取结果是否正确
➜  lib git:(main) ✗ lipo -info ffi-x86_64.a
Architectures in the fat file: ffi-x86_64.a are: x86_64
➜  lib git:(main) ✗
5.重新组合成 xcframework 文件结构,我命名为 ffi.xcframework

创建文件夹 ffi.xcframework 并按照以下通用格式移动文件:

image.png

⚠️ 注意:我在前面 生成的 ffi-arm64.affi-x86_64.a 文件最后都更改名称为 ffi (需要删除 .a 后缀)

6.依赖和使用

使用 ffi 调用一个 乘法的 c 函数:


#import "PersonHomeView.h"

//binaryTarget
#import <ffi/ffi.h>

@implementation PersonHomeView

/// 乘法功能的C函数
int cFuncMultiply(int a, int b) {
    return a * b;
}

/// 测试 ffi 调用 c 函数
- (void)libffiTest {
    //1.定义参数类型数组(告诉 libffi 参数的类型)
    ffi_type **argTypes;
    argTypes = malloc(sizeof(ffi_type *) * 2);
    argTypes[0] = &ffi_type_sint;
    argTypes[1] = &ffi_type_sint;
    //2.定义返回值类型(告诉 libffi 返回值的类型)
    ffi_type *retType = &ffi_type_sint; // 返回值是 int
    //3.初始化 CIF(Call Interface,调用接口)
    ffi_cif cif; // CIF 存储函数调用的 ABI、参数类型、返回值类型等信息
    ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 2, retType, argTypes);
    //4.准备参数值(实际调用时传入的参数)
    void **args = malloc(sizeof(void *) * 2);
    int x = 3, y = 7;
    args[0] = &x; // 第一个参数是 x 的地址
    args[1] = &y; // 第二个参数是 y 的地址
    int ret; // 存储返回值
    //5.动态调用函数
    ffi_call(&cif, (void(*)(void))cFuncMultiply, &ret, args);
    
    // 打印结果
    NSLog(@"libffi:乘法功能的C函数调用结果: %d", ret);
    
    // 释放内存
    free(argTypes);
    free(args);
}

@end

打印结果:

SPMDemo[10519:7206102] libffi:乘法功能的C函数调用结果: 21

9、资源加载

资源文件位置

SPM 要求资源文件放置在包的 Resources 目录下(或通过目录结构隐式标记为资源)。例如:

MyPackage/
├── Sources/
│   └── MyFramework/
│       ├── Resources/  # 显式资源目录
│       │   └── image.png
│       └── MyClass.swift
└── Package.swift

或通过文件扩展名隐式标记(如 .xcassets、.lproj 等),SPM 会自动识别为资源。

Package.swift 配置

在包的清单文件中,需通过 targets 的 resources 字段显式声明资源文件或目录:

swift
targets: [
    .target(
        name: "MyFramework",
        exclude: ["info.plist"], // 排除的文件
        resources: [
            .process("Resources/**"]), // 递归包含 Resources 目录下所有文件
            .copy() //资源按原样复制
        ]  
    )
]
.process(), // 大图等资源会被复制到构建产物中,并可通过 Bundle 访问。
.copy(), // 资源按原样复制,适用于非文本文件(如二进制数据)

10、如何管理工程依赖

1、GUI界面手动管理

通过GUI界面的方式手动的一个一个的进行添加、删除不是一个明智的选择。

你可以通过脚本管理等方式放开你的双手。

2、模块 SPMManager

也可以创建一个空壳模块专门管理依赖

比如,创建一个模块:SPMManager

主工程手动添加 SPMManager 依赖

本地模块示意: image.png

其它的模块都在 SPMManager 中 Package.swift 里配置。

你也可以通过脚本快速修改 Package.swift 文件,比修改工程可是要方便得多。

Swift 结构化并发 6 条铁律 —— 一张图 + 一套模板,让 `async let` / `TaskGroup` / `Task {}` 不再踩坑

作者 unravel2025
2025年9月15日 14:05

思维导图(先保存,再阅读)

Swift Concurrency
├─ Structured(结构化并发,有父有子,有纪律)
│  ├─ async let        → 静态并发
│  └─ TaskGroup        → 动态并发
│  三大规则 EGG
│   ├─ E  Error 传播:出作用域即 取消+等待
│   ├─ G  Group 完成:父必等子
│   └─ G  Group 取消:父取消→子取消
│  特征
│   ├─ 生命周期=作用域
│   ├─ 隐式 await(作用域结束)
│   └─ 继承优先级 & TaskLocal,**不继承 actor**
└─ Unstructured(无父,野生)
   ├─ Task { }           → 继承上下文
   ├─ Task.detached { }  → 啥也不继承
   └─ 无 EGG 规则,一切靠自己

为什么分“结构化”与“非结构化”

维度 Structured Unstructured
能否成为子任务 ❌(只能是根)
能否成为父任务 ✅(再开 structured 子任务)
生命周期 绑定作用域 绑定引用
规则 EGG 自动生效 全无效
典型 API async let / TaskGroup Task {} / Task.detached {}

Structured 任务两大形态

async let —— 静态并发

func fetchData() async {
    async let first  = fetchPart1()   // 立即启动子任务
    async let second = fetchPart2()
    async let third  = fetchPart3()

    let result = await (first, second, third) // 隐式等全部完成
    print(result)
}
// 离开作用域前,**所有子任务必须完成**,否则父任务不会完成

注意:

  • 顺序:await 不影响并发,三个子任务同时飞。
  • 只要作用域还在,就保证等;提前 return/throw 也会自动取消+等待其余子任务。

TaskGroup —— 动态并发

func fetchData(count: Int) async {
    await withTaskGroup(of: String.self) { group in
        for i in 0..<count {
            group.addTask {          // 动态添加子任务
                await fetchPart(i)
            }
        }
        for await result in group {   // 谁跑完谁先处理
            print(result)
        }
    }   // 此处**隐式等待**所有子任务
}

优点:

  • 数量运行时决定
  • fail-fast:任一子任务抛错,其余子任务立即取消并传播第一个错误
  • 支持 group.cancelAll() 手动取消

Unstructured 任务:两座“根”

常规 Task { } —— 继承上下文

enum TaskLocalStorage {
    @TaskLocal static var requestID: String?
}

Task {                                  // 继承
    print(Task.currentPriority)         // 继承调用方优先级
    print(TaskLocalStorage.requestID)   // 继承 TaskLocal
    // 若在外层 @MainActor,也继承 executor,但内部函数自己的 actor 隔离仍生效
}

Task.detached { } —— 啥也不继承

Task.detached {
    // 优先级 = .medium(默认)
    // 无 TaskLocal
    // 一定跑在**全局并发线程池**,不会上主线程
}

使用场景:

  • 真正“后台孤岛”计算
  • 但99 % 场景用常规 Task { } + 函数自身 nonisolated 即可,detached 是最后 resort

三大铁律 EGG(只适用于 Structured)

E - Error 传播规则

定义:作用域因抛错而提前退出时,所有子任务自动取消 + 等待。

func fast() async throws { ... throw TestError() }
func slow() async throws { ... }

func parent() async throws {
    async let f = fast()   // 5 s 后抛错
    async let s = slow()   // 10 s 后完成
    try await (f, s)       // fast 先抛,slow 被**自动取消并等待**
}
// 离开作用域前,**所有子任务必须完成或被取消**,错误才继续向上抛

对比 Unstructured:

let root = Task {
    Task { try await fast() }   // 无父子关系
    Task { try await slow() }   // **不会被自动取消**
}
root.cancel()                   // 两个嵌套 Task 仍跑完

G - Group 完成规则

定义:父任务必须等所有子任务完成后才能完成。

let parent = Task {
    async let a = work()   // 10 s
    async let b = work()   // 10 s
    _ = await (a, b)
}
await parent.value        // **20 s 后才打印**
print("parent completes")

Unstructured 版本:

let root = Task {
    Task { await work() }  // 10 s
}
await root.value          // **立刻打印**,嵌套 Task 继续跑
print("root completes")   // 出现在 work 之前

G - Group 取消规则

定义:父任务被取消 → 所有子任务自动取消(协作式)。

let parent = Task {
    async let a = longWork()   // 10 s
    async let b = longWork()   // 10 s
    _ = await (a, b)
}
parent.cancel()              // a & b 收到 `CancellationError`
await parent.value

Unstructured 再次失效:

let root = Task {
    Task { await longWork() } // **收不到取消**
}
root.cancel()

上下文继承差异速查

继承项 Structured 子任务 Task {} Task.detached { }
优先级 ❌(默认 .medium)
TaskLocal
Actor 隔离 ❌(永远不继承,跑全局并发池) ✅(继承调用方 executor) ❌(全局池)

易错点:

async let/group.addTask 不会把代码钉在 @MainActor,即使外层是主线程;

内部函数自己的isolate决定最终 executor。

什么时候用哪种任务?一张表搞定

需求 选型
静态并发(固定数量) async let
动态并发 & fail-fast TaskGroup
同步→异步 Task { }
后台“孤岛”计算,不继承任何东西 Task.detached { }(最后 resort)
Fire-and-forget(不 await) Task { } 但记得手动管理生命周期

实战模板:网络层“结构化”封装

/// 并发下载多张图片,任一失败立即取消其余,返回首张
@MainActor
func fetchFirstImage(urls: [URL]) async throws -> UIImage {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: url)
                guard let image = UIImage(data: data) else {
                    throw URLError(.badURL)
                }
                return image
            }
        }
        // 只要第一张成功,其余自动取消
        for try await image in group {
            return image              // 提前返回 → 其余任务被取消
        }
        throw URLError(.badServerResponse)
    }
}

特点:

  • 结构化保证“失败即取消”
  • 离开作用域自动等待,不会泄漏任务

常见踩坑清单

踩坑 正确做法
async let 忘了 await 作用域结束自动等,但返回值别丢
Task { } 里开 async let 就当子任务 ❌ 无父子关系,EGG 规则全失效
想取消单个 async let 必须取消整个父任务;用 TaskGroup + cancelAll() 更细
Task.detached 当成“性能加速器” 99 % 用常规 Task { } + 函数自己的 nonisolated 即可

一句话总结

Structured = 有爹管:生命周期、取消、错误、完成,作用域帮你兜底。

Unstructured = 野生根:一切自己 await、自己 cancel、自己管理。

记住 EGG 只给“有爹”的任务吃,野生任务饿了自己煮。

深入理解 Swift Concurrency:从 async/await 到 Actor 与线程池的完整运行机制

作者 unravel2025
2025年9月15日 14:05

一、async 函数的本质:可挂起的函数

✅ 什么是 async 函数?

func asyncWork() async {
    // 这是一个异步函数
}
  • async 函数是可以被挂起的函数。
  • 挂起 ≠ 阻塞线程,而是让出线程去执行其他任务。
  • 挂起只发生在 await 处,称为潜在挂起点。

✅ sync 函数可以当作 async 函数使用,反之不行

protocol SomeProtocol {
    func work() async
}

struct SomeStruct: SomeProtocol {
    func work() {
        // ✅ 合法:sync 实现 async 协议方法
    }
}
protocol SomeProtocol {
    func work()
}

struct SomeStruct: SomeProtocol {
    func work() async {
        // ❌ 非法:async 实现 sync 协议方法
    }
}

二、await:异步等待,非阻塞线程

✅ 示例:顺序执行异步代码

func asyncWork() async -> Int {
    return 42
}

let result = await asyncWork() // 不会阻塞线程
  • await 是潜在挂起点,只有当调用者和被调用者执行上下文不同时,才会真正挂起。
  • 挂起时,系统会保存当前状态(称为 continuation),并释放线程。

三、Continuation:挂起点的“快照”

  • 包含:返回地址、参数、局部变量等。
  • 存储在堆中,允许跨线程恢复。
  • 由运行时管理,开发者无需直接操作。

四、Task:异步执行的单位

✅ 创建 Task 进入异步上下文

Task {
    await callAsyncFunc()
}
  • 每个 async 函数都在某个 Task 中运行。
  • Task 是异步函数的“容器”,类似线程之于 sync 函数。
  • Task 本身不具备并发能力,一个 Task 一次只执行一个函数。

✅ Task 的三种状态

  • 🔴 suspended:等待调度或外部事件
  • 🟡 running:正在线程上运行
  • 🟢 completed:执行完成

五、Job:Task 的“同步片段”

Task {
    beforeWork()
    await asyncWork()
    afterWork()
}
  • 每个 await 将 Task 拆分为多个 Job。
  • Job 是同步执行的最小单位,不包含 await
  • Job 按顺序执行,不能并发。

六、Actor:线程安全的并发模型

✅ 示例:Actor 隔离状态

actor SomeActor {
    let immutableState = 1
    var mutableState = 2

    func updateState(_ newValue: Int) {
        mutableState = newValue
    }
}

let actor = SomeActor()

print(actor.immutableState) // ✅ 无需 await

Task.detached {
    await print(actor.mutableState) // ✅ 需 await
    // actor.mutableState = 3 // ❌ 编译错误
    await actor.updateState(3) // ✅ 合法
}
  • Actor 保证同一时间只有一个任务访问其可变状态。
  • 不可变状态可同步访问,无需 await
  • 可变状态必须通过 await 调用 actor 方法访问。

七、Executor:Job 的执行器

  • Executor 负责将 Job 调度到线程执行。
  • 类型:
    • Default concurrent executor:非 actor 隔离任务
    • Serial executor:每个 actor 一个,顺序执行
    • Main executor:主线程,处理 @MainActor 任务

八、Cooperative Thread Pool(CTP):线程池

  • Swift Concurrency 使用固定数量线程(= CPU 核心数),避免线程爆炸。
  • 所有 executor(除主线程)都从 CTP 借线程。
  • 主线程独立,不参与 CTP。

九、线程与 executor 的映射关系

Task.detached {
    // 默认并发 executor,CTP 线程
    await someAsyncFunction()
    // 仍为默认并发 executor,可能换线程
}
actor SomeActor {
    func someMethod() async {
        // 当前 actor 的 serial executor
        await someAsyncFunction()
        // 仍为同一 actor executor,可能换线程
    }
}
Task { @MainActor in
    // 主线程
    await someAsyncFunction()
    // 仍在主线程
}

十、完整运行机制图解(文字版)

CPU 核心
   ↓
Cooperative Thread Pool(固定线程)
   ↓
Executor(调度 Job)
   ↓
Job(同步片段)
   ↓
Task(异步函数容器)
   ↓
async/await(挂起点与 continuation)
   ↓
Actor(隔离状态,防止数据竞争)

十一、总结与个人见解

✅ Swift Concurrency 的优势

  • 结构化并发:Task、async/await、actor 构成完整模型。
  • 线程安全:Actor 提供编译期保证,避免数据竞争。
  • 性能优化:CTP 限制线程数,避免线程爆炸。
  • 可读性强:异步代码像同步代码一样线性书写。

⚠️ 学习曲线与挑战

  • 概念多,机制复杂,需理解底层模型。
  • 调试困难,尤其是线程切换与挂起恢复。
  • 与旧代码(GCD、delegate)集成需谨慎。

十二、扩展使用场景与实践建议

✅ 场景 1:网络请求 + 数据解析

func fetchData() async throws -> Data {
    let url = URL(string: "https://api.example.com")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}

✅ 场景 2:并发请求多个接口

async let user = fetchUser()
async let posts = fetchPosts()
let result = await (user, posts)

✅ 场景 3:Actor 管理缓存

actor ImageCache {
    private var cache: [String: UIImage] = [:]

    func image(for key: String) -> UIImage? {
        return cache[key]
    }

    func save(image: UIImage, for key: String) {
        cache[key] = image
    }
}

【Swift Concurrency】深入理解 `async let` 与 `TaskGroup`:并发任务的生命周期与错误传播机制

作者 unravel2025
2025年9月15日 14:04

一、什么是 async let

async let 是 Swift 提供的一种结构化并发语法糖,用于并发地启动多个子任务,并延迟等待其结果。

✅ 基本用法

func fetchData() async {
    async let first = fetchPart1()
    async let second = fetchPart2()
    async let third = fetchPart3()

    let result = await (first, second, third)
    print(result)
}
  • 每个 async let 会创建一个子任务(child task)。
  • 子任务立即开始执行,不会阻塞当前任务。
  • 使用 await 获取结果时,按顺序等待(从左到右)。

✅ 支持任意表达式

async let number = 123
async let str = "Hello"

实际上,Swift 会将这些表达式包装为 ChildTask,并在后台并发执行。

二、什么是 TaskGroup?

TaskGroup 是 Swift 提供的动态并发任务创建机制,适合任务数量在运行时决定的场景。

✅ 示例:动态创建任务

func fetchData(count: Int) async {
    var results = [String]()

    await withTaskGroup(of: String.self) { group in
        for index in 0..<count {
            group.addTask {
                await self.fetchPart(index)
            }
        }

        for await result in group {
            results.append(result)
        }
    }

    print(results)
}
  • addTask 动态添加子任务。
  • for await 按完成顺序处理结果(谁先完成谁先处理)。
  • 也可以使用 group.next() 手动迭代。

三、生命周期机制对比

特性 async let TaskGroup
生命周期作用域 当前局部作用域(函数、do块等) withTaskGroup 闭包
正常退出时 自动取消并等待所有子任务 自动等待所有子任务(不取消)
异常退出时 自动取消并等待所有子任务 自动取消并等待所有子任务
是否支持动态任务数 ❌ 不支持 ✅ 支持
是否按完成顺序处理结果 ❌ 按声明顺序 await ✅ 按完成顺序

四、错误传播机制详解

async let 的错误传播

async let f = fast()
async let s = slow()

do {
    try await (f, s)
} catch {
    print("Caught error: \(error)")
}
  • 错误传播顺序取决于 await 的顺序。
  • 如果 fast() 先抛出错误,slow() 会被隐式取消并等待。
  • 如果 slow() 先完成但 fast() 先抛出错误,slow() 的错误不会被捕获。

✅ TaskGroup 的错误传播

try await withThrowingTaskGroup(of: Void.self) { group in
    group.addTask { try await fast() }
    group.addTask { try await slow() }

    for try await _ in group {
        // 处理结果
    }
}
  • 使用 for try await 时,第一个抛出的错误会立即传播。
  • 其余任务会被取消并等待。
  • 错误传播顺序与任务完成顺序无关,谁先抛谁传播。

五、实战建议:如何选择?

场景 推荐方式 原因
任务数量固定 async let 简洁、语法糖优雅
任务数量动态 TaskGroup 支持运行时添加任务
需要“fail fast” TaskGroup 错误传播更及时、可控
需要按顺序处理结果 async let 可控制 await 顺序
需要按完成顺序处理结果 TaskGroup AsyncSequence 自动支持

六、常见误区与注意事项

⚠️ 1. async let 不支持逃逸闭包

async let result = someAsyncFunc()
DispatchQueue.main.async {
    // ❌ 不能使用 result,生命周期已结束
}

⚠️ 2. 不 await 也会等待完成

async let f = slowTask()
print("End") // 仍会等待 f 完成后再退出作用域

⚠️ 3. 不支持“真正的 fire-and-forget”

  • 结构化任务总是会被等待。
  • 若需真正“放飞自我”,请使用非结构化 Task:
Task.detached {
    await someAsyncFunc()
}

七、总结:一句话记住差异

async let 是固定并发任务的语法糖,生命周期绑定作用域,错误传播顺序依赖 await 顺序;

TaskGroup 是动态并发任务的管理器,生命周期绑定闭包,错误传播顺序更及时、可控。

Flutter 三种方式实现页面切换后保持原页面状态

作者 ALLIN
2025年9月15日 13:19

前言:

在 Flutter 应用中,导航栏切换页面后默认情况下会丢失原页面状态,即每次进入页面时都会重新初始化状态,如果在initState中打印日志,会发现每次进入时都会输出,显然这样增加了额外的开销,并且带来了不好的用户体验。

在正文之前,先看一些常见的 App 导航,以喜马拉雅 FM 为例:

动图封面

它拥有一个固定的底部导航以及首页的顶部导航,可以看到不管是点击底部导航切换页面还是在首页左右侧滑切换页面,之前的页面状态都是始终维持的,下面就具体介绍下如何在 flutter 中实现类似喜马拉雅的导航效果

第一步:实现固定的底部导航

在通过flutter create生成的项目模板中,我们先简化一下代码,将MyHomePage提取到一个单独的home.dart文件,并在Scaffold脚手架中添加bottomNavigationBar底部导航,在body中展示当前选中的子页面。

/// home.dart
import 'package:flutter/material.dart';

import './pages/first_page.dart';
import './pages/second_page.dart';
import './pages/third_page.dart';

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final items = [
    BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('首页')),
    BottomNavigationBarItem(icon: Icon(Icons.music_video), title: Text('听')),
    BottomNavigationBarItem(icon: Icon(Icons.message), title: Text('消息'))
  ];

  final bodyList = [FirstPage(), SecondPage(), ThirdPage()];

  int currentIndex = 0;

  void onTap(int index) {
    setState(() {
      currentIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('demo'),
        ),
        bottomNavigationBar: BottomNavigationBar(
            items: items,
            currentIndex: currentIndex,
            onTap: onTap
        ),
        body: bodyList[currentIndex]
    );
  }
}

其中的三个子页面结构相同,均显示一个计数器和一个加号按钮,以first_page.dart为例:

/// first_page.dart
import 'package:flutter/material.dart';

class FirstPage extends StatefulWidget {
  @override
  _FirstPageState createState() => _FirstPageState();
}

class _FirstPageState extends State<FirstPage> {
  int count = 0;

  void add() {
    setState(() {
      count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
            child: Text('First: $count', style: TextStyle(fontSize: 30))
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: add,
          child: Icon(Icons.add),
        )
    );
  }
}

当前效果如下:

动图封面

可以看到,从第二页切换回第一页时,第一页的状态已经丢失

第二步:实现底部导航切换时保持原页面状态

可能有些小伙伴在搜索后会开始直接使用官方推荐的AutomaticKeepAliveClientMixin,通过在子页面的 State 类重写wantKeepAlivetrue 。 然而,如果你的代码和我上面的类似,body 中并没有使用PageViewTabBarView,很不幸的告诉你,踩到坑了,这样是无效的,原因后面再详述。现在我们先来介绍另外两种方式:

① 使用IndexedStack实现

IndexedStack继承自Stack,它的作用是显示第indexchild,其它child在页面上是不可见的,但所有child的状态都被保持,所以这个Widget可以实现我们的需求,我们只需要将现在的bodyIndexedStack包裹一层即可

/// home.dart
class _MyHomePageState extends State<MyHomePage> {
  ...
  ...
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('demo'),
        ),
        bottomNavigationBar: BottomNavigationBar(
            items: items, currentIndex: currentIndex, onTap: onTap),
        // body: bodyList[currentIndex]
        body: IndexedStack(
          index: currentIndex,
          children: bodyList,
        ));
  }

保存后再次测试一下

动图封面

② 使用Offstage实现

Offstage的作用十分简单,通过一个参数来控制child是否显示,所以我们同样可以组合使用Offstage来实现该需求,其实现原理与IndexedStack类似

/// home.dart
class _MyHomePageState extends State<MyHomePage> {
  ...
  ...
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('demo'),
        ),
        bottomNavigationBar: BottomNavigationBar(
            items: items, currentIndex: currentIndex, onTap: onTap),
        // body: bodyList[currentIndex],
        body: Stack(
          children: [
            Offstage(
              offstage: currentIndex != 0,
              child: bodyList[0],
            ),
            Offstage(
              offstage: currentIndex != 1,
              child: bodyList[1],
            ),
            Offstage(
              offstage: currentIndex != 2,
              child: bodyList[2],
            ),
          ],
        ));
  }
}

在上面的两种方式中都可以实现保持原页面状态的需求,但这里有一些开销上的问题,有经验的小伙伴应该能发现当应用第一次加载的时候,所有子页状态都被实例化了(>这里的细节并不是因为我直接把子页实例化放在bodyList里...<),如果在子页StateinitState中打印日志,可以在终端看到一次性输出了所有子页的日志。下面就介绍另一种通过继承AutomaticKeepAliveClientMixin的方式来更好的实现保持状态。

第三步:实现首页的顶部导航

首先我们通过配合使用TabBar+TabBarView+AutomaticKeepAliveClientMixin来实现顶部导航(注意:TabBarTabBarView需要提供controller,如果自己没有定义,则必须使用DefaultTabController包裹)。此处也可以选择使用PageView,后面会介绍。

我们先在home.dart文件移除Scaffold脚手架中的appBar顶部工具栏,然后开始重写首页first_page.dart:

/// first_page.dart
import 'package:flutter/material.dart';

import './recommend_page.dart';
import './vip_page.dart';
import './novel_page.dart';
import './live_page.dart';

class _TabData {
  final Widget tab;
  final Widget body;
  _TabData({this.tab, this.body});
}

final _tabDataList = <_TabData>[
  _TabData(tab: Text('推荐'), body: RecommendPage()),
  _TabData(tab: Text('VIP'), body: VipPage()),
  _TabData(tab: Text('小说'), body: NovelPage()),
  _TabData(tab: Text('直播'), body: LivePage())
];

class FirstPage extends StatefulWidget {
  @override
  _FirstPageState createState() => _FirstPageState();
}

class _FirstPageState extends State<FirstPage> {
  final tabBarList = _tabDataList.map((item) => item.tab).toList();
  final tabBarViewList = _tabDataList.map((item) => item.body).toList();

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
        length: tabBarList.length,
        child: Column(
          children: <Widget>[
            Container(
              width: double.infinity,
              height: 80,
              padding: EdgeInsets.fromLTRB(20, 24, 0, 0),
              alignment: Alignment.centerLeft,
              color: Colors.black,
              child: TabBar(
                  isScrollable: true,
                  indicatorColor: Colors.red,
                  indicatorSize: TabBarIndicatorSize.label,
                  unselectedLabelColor: Colors.white,
                  unselectedLabelStyle: TextStyle(fontSize: 18),
                  labelColor: Colors.red,
                  labelStyle: TextStyle(fontSize: 20),
                  tabs: tabBarList),
            ),
            Expanded(
                child: TabBarView(
              children: tabBarViewList,
              // physics: NeverScrollableScrollPhysics(), // 禁止滑动
            ))
          ],
        ));
  }
}

其中推荐页、VIP 页、小说页、直播页的结构仍和之前的首页结构相同,仅显示一个计数器和一个加号按钮,以推荐页recommend_page.dart为例:

/// recommend_page.dart
import 'package:flutter/material.dart';

class RecommendPage extends StatefulWidget {
  @override
  _RecommendPageState createState() => _RecommendPageState();
}

class _RecommendPageState extends State<RecommendPage> {
  int count = 0;

  void add() {
    setState(() {
      count++;
    });
  }

  @override
  void initState() {
    super.initState();
    print('recommend initState');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body:Center(
          child: Text('首页推荐: $count', style: TextStyle(fontSize: 30))
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: add,
          child: Icon(Icons.add),
        ));
  }
}

保存后测试,

动图封面

可以看到,现在添加了首页顶部导航,且默认支持左右侧滑,接下来再进一步的完善状态保持

第四步:实现首页顶部导航切换时保持原页面状态

③ 使用AutomaticKeepAliveClientMixin实现

写到这里已经很简单了,我们只需要在首页导航内需要保持页面状态的子页State中,继承AutomaticKeepAliveClientMixin并重写wantKeepAlivetrue即可。

notes:Subclasses must implement wantKeepAlive, and their build methods must call super.build (the return value will always return null, and should be ignored)

以首页推荐recommend_page.dart为例:

/// recommend_page.dart
import 'package:flutter/material.dart';

class RecommendPage extends StatefulWidget {
  @override
  _RecommendPageState createState() => _RecommendPageState();
}

class _RecommendPageState extends State<RecommendPage>
    with AutomaticKeepAliveClientMixin {
  int count = 0;

  void add() {
    setState(() {
      count++;
    });
  }

  @override
  bool get wantKeepAlive => true;

  @override
  void initState() {
    super.initState();
    print('recommend initState');
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Scaffold(
        body:Center(
          child: Text('首页推荐: $count', style: TextStyle(fontSize: 30))
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: add,
          child: Icon(Icons.add),
        ));
  }
}

再次保存测试,

动图封面

现在已经可以看到,不管是切换底部导航还是切换首页顶部导航,所有的页面状态都可以被保持,并且在应用第一次加载时,终端只看到recommend initState的日志,第一次切换首页顶部导航至 vip 页面时,终端输出vip initState,当再次返回推荐页时,不再输出recommend initState

所以,使用TabBarView+AutomaticKeepAliveClientMixin这种方式既实现了页面状态的保持,又具有类似惰性求值的功能,对于未使用的页面状态不会进行实例化,减小了应用初始化时的开销。

更新

前面在底部导航介绍了使用IndexedStackOffstage两种方式实现保持页面状态,但它们的缺点在于第一次加载时便实例化了所有的子页面State。为了进一步优化,下面我们使用PageView+AutomaticKeepAliveClientMixin重写之前的底部导航,其中PageViewTabBarView的实现原理类似,具体选择哪一个并没有强制要求。更新后的home.dart文件如下:

/// home.dart
import 'package:flutter/material.dart';

import './pages/first_page.dart';
import './pages/second_page.dart';
import './pages/third_page.dart';

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final items = [
    BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('首页')),
    BottomNavigationBarItem(icon: Icon(Icons.music_video), title: Text('听')),
    BottomNavigationBarItem(icon: Icon(Icons.message), title: Text('消息'))
  ];

  final bodyList = [FirstPage(), SecondPage(), ThirdPage()];

  final pageController = PageController();

  int currentIndex = 0;

  void onTap(int index) {
    pageController.jumpToPage(index);
  }

  void onPageChanged(int index) {
    setState(() {
      currentIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        bottomNavigationBar: BottomNavigationBar(
            items: items, currentIndex: currentIndex, onTap: onTap),
        // body: bodyList[currentIndex],
        body: PageView(
          controller: pageController,
          onPageChanged: onPageChanged,
          children: bodyList,
          physics: NeverScrollableScrollPhysics(), // 禁止滑动
        ));
  }
}

然后在bodyList的子页State中继承AutomaticKeepAliveClientMixin并重写wantKeepAlive,以second_page.dart为例:

/// second_page.dart
import 'package:flutter/material.dart';

class SecondPage extends StatefulWidget {
  @override
  _SecondPageState createState() => _SecondPageState();
}

class _SecondPageState extends State<SecondPage>
    with AutomaticKeepAliveClientMixin {
  int count = 0;

  void add() {
    setState(() {
      count++;
    });
  }

  @override
  bool get wantKeepAlive => true;

  @override
  void initState() {
    super.initState();
    print('second initState');
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Scaffold(
        body: Center(
          child: Text('Second: $count', style: TextStyle(fontSize: 30))
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: add,
          child: Icon(Icons.add),
        ));
  }
}

Ok,更新后保存运行,应用第一次加载时不会输出second initState,仅当第一次点击底部导航切换至该页时,该子页的State被实例化。

至此,如何实现一个类似的 底部 + 首页顶部导航 完结 ~

昨天以前iOS

老司机 iOS 周报 #351 | 2025-09-15

作者 ChengzhiHuang
2025年9月14日 19:20

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

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

新闻

Memory Integrity Enforcement: A complete vision for memory safety in Apple devices

@Damien:本文章是 Apple 推出的 Memory Integrity Enforcement(MIE)技术,结合自研芯片与系统级安全机制,为 iPhone 17 系列提供业界首个全时开启的内存安全保护,无需用户感知即可防御缓冲区溢出、释放后使用等漏洞。该功能基于增强版 Arm 内存标记扩展(EMTE),配合安全内存分配器与标签保密技术,显著增加间谍软件等高级攻击的开发成本,Apple 称之为“消费级操作系统内存安全史上最大升级”。

文章

🐎 SwiftUI WebView

@DylanYang:作者向我们介绍了 Swift UI 中新引入的 WebView 组件。它有非常简单的初始化方式,同时也兼具了很多我们在使用 WKWebview 时经常会用到的功能,如页面加载各个时机的回调、自定义的 scheme handler、js 注入等。作者提供了一个完善的 demo 来展示这些能力。感兴趣的读者可以阅读下本文。

🐎 Debugging Swift Concurrency: “ Am I on the Main Actor? ”

@JonyFang: Swift Concurrency 调试推荐做思维上的转变:从线程思维转向 Actor 思维,关注"我在哪个 Actor 上"而非"我在哪个线程上"。文中描述了 MainActor.assertIsolated()MainActor.preconditionIsolated() 在 Debug 和 Release 模式下的差异用法。整体在推荐拥抱 Swift 6 的并发安全模型,让 Actor 成为开发过程中思考并发的基础单元。

🐎 Building a design system at Genius Scan

@david-clang:文章详细介绍了如何利用 SwiftUI 的组合思想和环境机制,为应用构建灵活的设计系统。核心是通过创建可复用的基础组件(如 Row),并利用 ViewBuilder 和 Environment 实现样式配置与定制,从而高效解决 UI 一致性与代码复用问题。

🐕 Four Corners: the first game in Catalog written in Swift

@极速男孩:Playdate 游戏《Four Corners》被开发者 Steven Chipman 用 Swift 重写,成为 Catalog 中首款 Swift 游戏。此举旨在探索 Swift 在 Playdate 上的性能潜力。Chipman 熟悉 Swift,选择重写此游戏是因为它相对简单且存在性能瓶颈。尽管遇到了缺少部分 API 和 Foundation 库的挑战,以及调试困难,但 PlaydateKit 库极大地简化了开发。Chipman 认为 Swift 的优势(如 C 级性能和易用性)胜过这些不便,并计划未来继续使用 Swift 开发 Playdate 游戏。他还因此重启了《Four Corners》的 iOS 版本。

🐎 Implementing a Refractive Glass Shader in Metal

@BluesJiang:这篇文章使用 Metal Shader 来自己尝试实现类似苹果 Liquid Glass 的效果,介绍了玻璃效果的各个组成部分,已经相关的算法。同时也演示了算法中的各个参数是如何影响最终的效果的。推荐有自定义液态玻璃效果的需求或者是想了解相关的实现方式的开发者阅读。

内推

重新开始更新「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)

iOS 26 仅需几行代码让 SwiftUI 7 液态玻璃界面焕发新春

2025年9月14日 14:31

在这里插入图片描述

概述

在今年的 WWDC 25 中,苹果为全平台推出了崭新的液态玻璃(Liquid Glass)皮肤。不仅如此,Apple 在此基础之上还打造了一整套超凡脱俗的动画和布局体系让 SwiftUI 7 界面焕发新机。

在这里插入图片描述

现在,我们只需寥寥几行代码就能将原本平淡无奇、乏善可陈的 SwiftUI 布局变成上面这般鲜活灵动。

在这里插入图片描述

想知道如何实现吗?看这篇就对啦!

在本篇博文中,您将学到如下内容:

  1. “一条大河窄又长”
  2. SwiftUI 7 全新玻璃特效来袭
  3. 融入,鲜活!

那还等什么呢?让我们马上迈入液态玻璃奇妙的特效世界吧? Let‘s go!!!;)


1. “一条大河窄又长”

在如今 App 现代化布局中,秃头小码农们非常喜爱像下面这般简洁、小巧、紧凑的全局菜单系统:

在这里插入图片描述

它就像一条长长的河流,伸缩自如,温柔又调皮的流入用户的心坎里。

不幸的是,目前它仿佛少了一点灵动的气息,而且感觉和 WWDC 25 中全新的液态玻璃也不太般配。

struct BadgesView: View {
    @Environment(ModelData.self) private var modelData
    @State private var isExpanded: Bool = false
    
    var body: some View {
        VStack(alignment: .center, spacing: Constants.badgeButtonTopSpacing) {
                if isExpanded {
                    VStack(spacing: Constants.badgeSpacing) {
                        ForEach(modelData.earnedBadges) {
                            BadgeLabel(badge: $0)
                        }
                    }
                }

                Button {
                    withAnimation {
                        isExpanded.toggle()
                    }
                } label: {
                    ToggleBadgesLabel(isExpanded: isExpanded)
                        .frame(width: Constants.badgeShowHideButtonWidth,
                               height: Constants.badgeShowHideButtonHeight)
                     
                }
                #if os(macOS)
                .tint(.clear)
                #endif
            }
            .frame(width: Constants.badgeFrameWidth)
    }
}

诚然,我们可以利用 SwiftUI 优秀的动画底蕴重新包装上面 BadgesView 视图的动画和转场效果,但这需要秃头小码农们宝贵的时间和头发,而且效果往往强差人意。

在这里插入图片描述

不过别担心,从 SwiftUI 7(iOS 26 / iPadOS 26 / macOS 26)开始,我们有了全新的选择,简单的不要不要的!

2. SwiftUI 7 全新玻璃特效来袭

从 WWDC 25 开始,全面支持 Liquid Glass 的 SwiftUI 7 推出了玻璃特效容器 GlassEffectContainer ,让我们真的可以对玻璃“为所欲为”:

在这里插入图片描述

GlassEffectContainer 能把多个带 glassEffect(_:in:) 的视图合并成一张“可变形的联合玻璃”,既省性能又能让玻璃形状彼此融合、 变形(morph)。

核心要点:

  • 用法:给子视图添加 .glassEffect(.liquid, in: container) 修改器,系统会把它们自动收集到同一个 GlassEffectContainer 中;
  • 效果:子视图的玻璃形状不再各自独立,而是当成一个整体渲染,可互相吸引、拼接、渐变和 morph;
  • 控制融合:通过容器的 spacing 值调节——值越大,子视图相距越远时就开始“粘”在一起;
  • 并发:@MainActor 隔离,线程安全。

总而言之,GlassEffectContainer 让多块“液态玻璃”合成一块可 morph 的超级玻璃,性能更高、动画更连贯。

在这里插入图片描述

同时,SwiftUI 7 还新增了两个配套方法 glassEffect(_:in:)glassEffectID(_:in:) : 在这里插入图片描述

在这里插入图片描述

我们可以利用它们结合 Namespace 来完成液态玻璃世界中的视图动画效果。

另外 SwiftUI 7 还专门为 Button 视图添加了 glass 按钮样式,真可谓超级“银杏化”:

在这里插入图片描述

有了这些 SwiftUI 中的宝贝,小伙伴们可以开始来打造我们的梦幻玻璃天堂啦!

3. 融入,鲜活!

将之前的 BadgesView 视图重装升级为如下实现:

struct BadgesView: View {
    @Environment(ModelData.self) private var modelData
    @State private var isExpanded: Bool = false
    @Namespace private var namespace
    
    var body: some View {
        GlassEffectContainer(spacing: Constants.badgeGlassSpacing) {
            VStack(alignment: .center, spacing: Constants.badgeButtonTopSpacing) {
                if isExpanded {
                    VStack(spacing: Constants.badgeSpacing) {
                        ForEach(modelData.earnedBadges) {
                            BadgeLabel(badge: $0)
                                .glassEffect(.regular, in: .rect(cornerRadius: Constants.badgeCornerRadius))
                                .glassEffectID($0.id, in: namespace)
                        }
                    }
                }

                Button {
                    withAnimation {
                        isExpanded.toggle()
                    }
                } label: {
                    
                    ToggleBadgesLabel(isExpanded: isExpanded)
                        .frame(width: Constants.badgeShowHideButtonWidth,
                               height: Constants.badgeShowHideButtonHeight)
                     
                }
                .buttonStyle(.glass)
                #if os(macOS)
                .tint(.clear)
                #endif
                .glassEffectID("togglebutton", in: namespace)
            }
            .frame(width: Constants.badgeFrameWidth)
        }
    }
}

上面这段新代码把“ earned 徽章列表”与底部的“展开/收起”按钮一起放进同一个 GlassEffectContainer 容器中,从而让它们全部参与 iOS 26 的「液态玻璃」合并渲染。

下面按“玻璃特性”逐句拆解:

  1. GlassEffectContainer(spacing: …)
  • 建立一块「联合玻璃画布」。
  • spacing 决定徽章彼此、徽章与按钮之间多早开始“粘”成一体:值越大,离得越远就开始融合。
  1. 展开时才出现的 VStack + ForEach
  • 每个 BadgeLabel 同时挂两个修饰符:
    • .glassEffect(.regular, in: .rect(cornerRadius: …))
      声明“我是 regular 风格玻璃,形状是圆角矩形”。
    • .glassEffectID(badge.id, in: namespace)
      给玻璃发身份证;同一 namespace 里身份证不同,SwiftUI 就能在增减徽章时做“液态 morph”——旧玻璃流走、新玻璃流进来,而不是生硬闪现。
  1. 底部 Button
  • .buttonStyle(.glass) 让按钮本身也是玻璃,但风格、圆角与徽章不同。
  • 同样用 .glassEffectID("togglebutton", in: namespace) 注册身份证,于是按钮的玻璃和上面徽章的玻璃被当成“同一张可变形大图”处理。
  • 展开/收起时,按钮玻璃会与刚出现(或消失)的徽章玻璃在边缘处“拉丝”融合,形成液态过渡。
  1. withAnimation { isExpanded.toggle() }
  • 状态变化被包进动画块,GlassEffectContainer 会同步驱动所有玻璃路径的 morph 动画:
    • 徽章从 0 高度“流”出来,边缘先与按钮玻璃粘连,再各自分离成独立圆角矩形。
    • 收起时反向流回,最终只剩按钮玻璃。
  1. 整体效果
    用户看到的不是“一行行控件出现”,而是一块完整的「可变玻璃」:
    • 展开 → 玻璃区域向下延伸,新徽章像水泡一样从主体里分裂长出;
    • 收起 → 多余部分被“吸”回按钮,边缘圆润地收缩消失。
      全程保持同一高光、折射、模糊背景,性能也优于多图层叠加。

在这里插入图片描述

简单来说,上面的实现用 GlassEffectContainer 把徽章与按钮收进同一块「液态玻璃」,凭借 glassEffectIDnamespace 让它们在展开/收起时像流体一样自然融合、morph,呈现出 iOS 26 独有的“整块玻璃可生长可收缩”的视觉魔法。

在这里插入图片描述

要注意哦,上面动图中按钮组背后的阴影是由于 gif 图片显示局限导致的,模拟器和真机实际测试的阴影效果可是美美哒的呢!

我们把 BadgesView 视图嵌入到主视图中,宝子们再来一起欣赏一下叹为观止的液态玻璃动画效果吧: 在这里插入图片描述

大功告成,打完收工,棒棒哒!💯

在这里插入图片描述

总结

在本篇文章中,我们讨论了在 iOS 26/iPadOS 26 里如何使用 SwiftUI 7 最新的液体玻璃系统来装饰小伙伴们的 App 界面。

在这里插入图片描述

感谢观赏,再会吧!8-)

Redux 中›ABC三个页面是如何通信的?

作者 littleplayer
2025年9月14日 12:31

你的这种感觉非常正常!这也是很多初学者对 Redux 最大的误解。如果 A、B、C 三个页面分别有自己的 Store,那你就完全违背了 Redux 最核心的“单一数据源”原则,自然会陷入无法通讯的困境。

Redux 的核心理念是:整个应用有且只有一个全局 Store。A、B、C 三个页面共享这个唯一的 Store,而不是各自拥有一个。

让我用正确的 Redux 思维来为你重构这个问题,你会发现通讯变得非常简单和清晰。


正确的 Redux 结构:单一数据源

flowchart TD
    AppState["全局 AppState<br>包含三个页面的数据"]
    
    subgraph A [页面A]
        A_State[StateA]
        A_Action[ActionA]
    end

    subgraph B [页面B]
        B_State[StateB]
        B_Action[ActionB]
    end

    subgraph C [页面C]
        C_State[StateC]
        C_Action[ActionC]
    end

    AppState --> A_State
    AppState --> B_State
    AppState --> C_State

    A_Action -- dispatch --> Store
    B_Action -- dispatch --> Store
    C_Action -- dispatch --> Store

    Store -- 更新 --> AppState

实现步骤

第 1 步:定义全局的 State、Action 和 Reducer

State.swift - 单一数据源

// 整个应用只有一个根状态
struct AppState {
    // 页面A的状态,只是这个根状态的一个属性
    var pageAState: PageAState
    // 页面B的状态
    var pageBState: PageBState
    // 页面C的状态
    var pageCState: PageCState
    // 还可以有跨页面的共享状态
    var userIsLoggedIn: Bool
}

// 每个页面的状态仍然是独立的结构体,但被整合到AppState中
struct PageAState {
    var dataForA: String = ""
    var valueFromB: String? = nil // 用于接收来自B的数据
}

struct PageBState {
    var dataForB: Int = 0
    var valueFromC: String? = nil // 用于接收来自C的数据
}

struct PageCState {
    var dataForC: [String] = []
}

Action.swift - 统一的行为定义

// 所有页面的Action都集中在一个枚举中
enum AppAction {
    // 页面A的Action
    case pageA(PageAAction)
    case pageB(PageBAction)
    case pageC(PageCAction)
    // 全局的Action,如登录、登出
    case global(GlobalAction)
}

// 每个页面自己的Action枚举
enum PageAAction {
    case buttonTapped
    case dataLoaded(String)
    case receivedDataFromB(String) // 专门用于接收B的消息
}

enum PageBAction {
    case sliderValueChanged(Int)
    case sendDataToA(String) // 专门用于向A发送数据
}

enum PageCAction {
    case itemSelected(Int)
}

Reducer.swift - 统一的 reducer

// 根Reducer,负责组合所有页面的reducer
func appReducer(state: inout AppState, action: AppAction) -> Void {
    switch action {
    
    // 分解处理页面A的Action
    case .pageA(let pageAAction):
        pageAReducer(state: &state.pageAState, action: pageAAction)
        
    // 分解处理页面B的Action
    case .pageB(let pageBAction):
        pageBReducer(state: &state.pageBState, action: pageBAction)
        // B的Action可能会影响到其他页面!
        // 例如:当B发送数据时,需要更新A的状态
        if case .sendDataToA(let data) = pageBAction {
            state.pageAState.valueFromB = data // 直接修改A的状态
        }
        
    // 分解处理页面C的Action
    case .pageC(let pageCAction):
        pageCReducer(state: &state.pageCState, action: pageCAction)
        
    // 处理全局Action
    case .global(let globalAction):
        globalReducer(state: &state, action: globalAction)
    }
}

// 每个页面自己的reducer(纯函数)
func pageAReducer(state: inout PageAState, action: PageAAction) {
    switch action {
    case .buttonTapped:
        print("A的按钮被点击")
    case .dataLoaded(let data):
        state.dataForA = data
    case .receivedDataFromB(let dataFromB):
        state.valueFromB = dataFromB // 更新来自B的数据
    }
}

func pageBReducer(state: inout PageBState, action: PageBAction) {
    switch action {
    case .sliderValueChanged(let value):
        state.dataForB = value
    case .sendDataToA(let data):
        // 注意:这个Action的主要处理逻辑在根Reducer中
        // 这里可以处理B自身相关的状态更新
        print("B准备发送数据给A: \(data)")
    }
}

第 2 步:创建唯一的全局 Store

Store.swift

class Store: ObservableObject {
    @Published private(set) var state: AppState
    private let reducer: (inout AppState, AppAction) -> Void
    
    init(initialState: AppState, reducer: @escaping (inout AppState, AppAction) -> Void) {
        self.state = initialState
        self.reducer = reducer
    }
    
    func dispatch(_ action: AppAction) {
        reducer(&state, action)
    }
}

// 在应用入口创建唯一Store
let globalStore = Store(initialState: AppState(
    pageAState: PageAState(),
    pageBState: PageBState(), 
    pageCState: PageCState(),
    userIsLoggedIn: false
), reducer: appReducer)

第 3 步:在页面中使用全局 Store

PageAView.swift

struct PageAView: View {
    @EnvironmentObject var store: Store // 注入的是全局唯一的Store
    
    // 从全局State中取出页面A需要的部分状态
    private var pageAState: PageAState { store.state.pageAState }
    
    var body: some View {
        VStack {
            Text("页面A的数据: \(pageAState.dataForA)")
            // 显示从页面B传来的数据
            if let dataFromB = pageAState.valueFromB {
                Text("来自B的消息: \(dataFromB)")
            }
            Button("通知B") {
                // 派发Action,而不是直接调用B的方法
                store.dispatch(.pageB(.sendDataToA("你好,我是A!")))
            }
        }
    }
}

PageBView.swift

struct PageBView: View {
    @EnvironmentObject var store: Store // 同一个全局Store
    
    private var pageBState: PageBState { store.state.pageBState }
    
    var body: some View {
        VStack {
            Text("B的数值: \(pageBState.dataForB)")
            Button("发送数据到A") {
                // 通过全局Store派发Action
                store.dispatch(.pageB(.sendDataToA("Hello from B!")))
            }
            NavigationLink("去C") {
                PageCView()
            }
        }
    }
}

通信场景实现

现在,让我们看看如何实现具体的通信:

  1. A -> B 通信

    • A 中:store.dispatch(.pageB(.sendDataToA("你好,我是A!")))
    • 根Reducer 接收到 AppAction.pageB(.sendDataToA),它会: a. 调用 pageBReducer 处理 B 自身的状态(如果需要) b. 直接修改 state.pageAState.valueFromB
    • 由于 PageAView 依赖于 store.state.pageAState,SwiftUI 会自动重绘页面A,新的数据就显示出来了!
  2. B -> A 通信:(同上,方向相反)

  3. 任何页面 -> 全局状态

    • 任何页面都可以派发全局 Action:store.dispatch(.global(.loginSuccess))
    • 这会在根Reducer中处理,更新 state.userIsLoggedIn
    • 所有依赖 userIsLoggedIn 的页面都会自动更新!

总结

  • 只有一个 Store:这是 Redux 架构的绝对核心。
  • State 是组合的:每个页面的 State 是全局 AppState 的一个属性。
  • Action 是统一的:所有页面的 Action 都通过一个统一的枚举管理。
  • Reducer 是分形的:有一个根 Reducer,它负责将 Action 分发给各个页面的 Reducer 处理。
  • 通信方式:页面间通信就是派发一个目标为其他页面的 Action。这个 Action 会在根 Reducer 中被处理,并直接修改目标页面的 State

这种方式虽然初期需要更多样板代码,但带来的好处是巨大的:极其清晰的数据流、可预测的状态变化、易于调试和测试。所有页面间的耦合都被解除了,它们都只依赖于全局的 Store,而不是彼此。

Redux在iOS中的使用

作者 littleplayer
2025年9月14日 09:47

好的,我们来详细探讨一下 Redux 在 iOS 开发中的应用。Redux 是一个源自 Web 前端(通常与 React 搭配)的架构模式,它因其单一数据源、状态不可变和纯函数Reducer 等特性,在 iOS 开发中也获得了大量关注和实践。

Redux 核心概念回顾

理解 Redux 在 iOS 的实现,首先要理解其三个基本原则:

  1. 单一数据源 (Single Source of Truth): 整个应用的状态(State)被存储在一个单一的、中心化的 Store 对象中。这消除了状态分散在不同组件所带来的复杂性,使得状态的追踪和调试变得非常容易。

  2. 状态是只读的 (State is Read-Only): 唯一改变状态的方法就是派发一个 Action。Action 是一个简单的、描述“发生了什么”的对象(通常是结构体或枚举)。你不能直接修改状态,这保证了状态更新的可预测性。

  3. 使用纯函数进行更改 (Changes are Made with Pure Functions): 为了指定状态如何被 Action 转换,你需要编写 Reducers。Reducer 是一个纯函数,它接收当前的 State 和一个 Action,并返回一个新的、更新后的 State(而不是修改旧的 State)。


在 iOS 中的核心组件映射

Redux 概念 iOS 中的实现 说明
State 一个结构体 (struct) 或类 包含整个应用当前所有数据的模型。必须是值类型struct)以确保不可变性。
Action 一个枚举 (enum) 描述所有可能改变状态的事件。每个 case 可以关联一些数据。
Reducer 一个函数 (function) (State, Action) -> State。根据 Action 生成新 State 的纯函数。
Store 一个单例或通过依赖注入的类 (class) 持有当前 State;接收并派发 Action;运行 Reducer 来更新 State;通知观察者。
View UIViewControllerSwiftUI.View 观察 State 的变化并重新渲染 UI;向 Store 派发用户交互产生的 Action。

一个简单的计数器示例 (SwiftUI + Combine)

让我们用一个经典的计数器例子来演示如何在 iOS (SwiftUI) 中实现 Redux。

第 1 步:定义 State

// 应用的状态。必须是结构体,以保证不可变性。
struct AppState {
    var count: Int = 0
}

第 2 步:定义 Action

// 所有能改变状态的动作。
enum Action {
    case increment
    case decrement
    case incrementBy(Int) // 关联值
}

第 3 步:定义 Reducer

// 这是一个纯函数:相同的输入,永远得到相同的输出,且无副作用。
func appReducer(state: AppState, action: Action) -> AppState {
    var newState = state // 复制当前状态(因为 state 是 struct,是值类型)
    
    switch action {
    case .increment:
        newState.count += 1
    case .decrement:
        newState.count -= 1
    case .incrementBy(let amount):
        newState.count += amount
    }
    // 返回一个全新的状态对象
    return newState
}

第 4 步:创建 Store

这是最关键的一步。Store 是大脑,它协调所有操作。

import Combine

// Store 是一个 ObservableObject,这样 SwiftUI 视图才能观察它的变化。
class Store: ObservableObject {
    // 发布者:State 的变化会驱动 UI 更新
    @Published private(set) var state: AppState
    
    // Reducer 函数
    private let reducer: (AppState, Action) -> AppState
    
    init(initialState: AppState, reducer: @escaping (AppState, Action) -> AppState) {
        self.state = initialState
        self.reducer = reducer
    }
    
    // 唯一能改变状态的方法:派发 Action
    func dispatch(_ action: Action) {
        // 在主线程上同步更新状态,保证线程安全
        DispatchQueue.main.async {
            // 调用 reducer 生成新状态,并替换旧状态
            self.state = self.reducer(self.state, action)
            // 由于 @Published 属性发生变化,objectWillChange 会自动发出信号,
            // 通知所有观察的 View 更新。
        }
    }
}

第 5 步:在 SwiftUI View 中使用

struct CounterView: View {
    // 从环境中获取或直接注入 Store
    @EnvironmentObject var store: Store
    
    var body: some View {
        VStack {
            Text("Count: \(store.state.count)") // 从 Store 中读取状态
                .font(.largeTitle)
            
            HStack {
                // 向 Store 派发 Action
                Button("-") { store.dispatch(.decrement) }
                Button("+") { store.dispatch(.increment) }
                Button("+10") { store.dispatch(.incrementBy(10)) }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

第 6 步:在入口点设置 Store

@main
struct MyApp: App {
    // 创建全局唯一的 Store,并注入到环境中
    let store = Store(initialState: AppState(), reducer: appReducer)
    
    var body: some Scene {
        WindowGroup {
            CounterView()
                .environmentObject(store) // 注入 Store
        }
    }
}

处理副作用 (Side Effects)

上面的 Reducer 是纯的,但真实应用需要副作用(如网络请求、读写磁盘等)。纯函数不能处理这些。解决方案是使用 “Effect” 模式(这正是 The Composable Architecture (TCA) 等库的核心)。

  1. 让 Reducer 返回一个 Effect:Reducer 除了返回新 State,还返回一个描述副作用的 Effect 对象。
  2. Store 执行 Effect:Store 在运行 Reducer 后,会执行返回的 Effect(比如发起网络请求)。
  3. Effect 完成后派发新 Action:当网络请求完成时,Effect 会自动派发一个新的 Action(如 .dataLoaded(Result)),这个 Action 会再次通过 Reducer 来更新状态。

简化版的 Effect 示例:

// 1. 扩展 Action 来包含副作用结果
enum Action {
    case increment
    case fetchButtonTapped
    case dataLoaded(Result<Data, Error>)
}

// 2. Reducer 可以返回一个额外的 Effect
func appReducer(state: AppState, action: Action) -> (AppState, Effect<Action>?) {
    var newState = state
    var effect: Effect<Action>? = nil
    
    switch action {
    case .fetchButtonTapped:
        effect = Effect { // 返回一个发起网络请求的 Effect
            // 模拟网络请求
            let result = Result { try await fetchDataFromNetwork() }
            return Action.dataLoaded(result)
        }
    case .dataLoaded(.success(let data)):
        newState.data = data
    case .dataLoaded(.failure(let error)):
        newState.error = error
    ...
    }
    return (newState, effect)
}

// 3. Store 的 dispatch 方法需要处理返回的 Effect 并执行它。

在 UIKit 中的使用

在 UIKit 中,概念完全相同,但需要手动实现状态观察。

  1. Store 仍然是一个中心化的类。
  2. ViewControllers 需要订阅 Store 的状态变化(例如,使用 Combine 的 $state.sink {...})。
  3. 在订阅的闭包中,根据新的 State 来手动更新 UI(设置 label 的 text、刷新 table view 等)。
  4. 在 IBAction 或代理方法中,调用 store.dispatch(...)

优缺点分析

优点:

  • 可预测性:状态变化非常清晰,总是 Action -> Reducer -> New State
  • 可调试性:可以轻松记录和重放每一个 Action 和状态快照。
  • 可测试性:Reducer 是纯函数,极易测试。只需给定输入,断言输出。
  • 单一数据源:避免了状态在不同组件间同步的难题。

缺点:

  • 样板代码 (Boilerplate):需要为每个功能定义 State, Action, Reducer,略显繁琐。
  • 学习曲线:对于新手来说,概念相对复杂。
  • 性能:对于非常庞大的状态树,频繁复制整个 state 可能带来性能开销(但通常不是问题)。

总结与建议

  • 对于简单应用:直接使用 @PublishedObservableObject 可能更轻量。
  • 对于中大型复杂应用:Redux 架构能极大地提升代码的可维护性和可预测性。
  • 推荐使用库:手动实现完整的 Redux 和副作用处理比较复杂。强烈推荐使用 The Composable Architecture (TCA),它是一个非常成熟、强大的 Swift 库,完美实现了 Redux 模式,并提供了出色的工具和测试支持。它大大减少了样板代码,是 iOS 上实践 Redux 的最佳选择。

在同步代码里调用 async/await:Task 就是你的“任意门”

作者 unravel2025
2025年9月12日 19:51

场景:同步上下文想调异步函数

func fetchData() async -> String { /* 网络请求 */ }

struct ContentView: View {
    var body: some View {
        Button("Load") {
            await fetchData()   // ❌ 编译错误:同步闭包里不能用 await
        }
    }
}

错误提示:

Cannot pass function of type '() async -> Void' to parameter expecting synchronous function type

官方逃生舱:包一层 Task {}

Button("Load") {
    Task {                      // ✅ 立即启动异步任务
        let data = await fetchData()
        print(data)
    }
}
  • Task 会立刻在新协程里执行闭包,不要求外部上下文支持并发。
  • 无需手动持有 Task 实例,除非你想取消或等待它完成。

Task 的 3 种常见“同步→异步”桥梁模式

模式 代码片段 用途
Fire-and-forget Task { await work() } 按钮点击、日志上报
取消友好 Task { [weak self] in … } ViewController/View 生命周期
Delegate/App 生命周期 Task { await requestPermissions() } application(_:didFinishLaunchingWithOptions:)

实战 1:带取消的 SwiftUI 任务

struct ContentView: View {
    @State private var task: Task<Void, Never>?   // 1️⃣ 持有任务
    
    var body: some View {
        VStack {
            Button("Start") {
                task = Task {                     // 2️⃣ 创建并保存
                    let data = await fetchData()
                    print(data)
                }
            }
            Button("Cancel") {
                task?.cancel()                    // 3️⃣ 外部取消
                task = nil
            }
        }
        .onDisappear {
            task?.cancel()                        // 4️⃣ 生命周期清理
        }
    }
}

记住:视图消失时必须取消,否则后台任务可能访问已销毁的 @State

实战 2:AppDelegate 里请求推送权限

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        
        // 同步方法内直接启动异步任务
        Task {
            do {
                try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])
                DispatchQueue.main.async {
                    application.registerForRemoteNotifications()
                }
            } catch {
                print("权限请求失败: \(error)")
            }
        }
        
        return true
    }
}
  • Task 让“老派”的同步生命周期钩子也能享受结构化并发。
  • 不需要 awaitapplication(_:didFinishLaunchingWithOptions:) 返回值里,启动即忘即可。

闭包捕获注意事项

Button("Load") {
    Task { [weak self] in          // ✅ 防止循环引用
        guard let self else { return }
        self.model.data = await self.fetchData()
    }
}

在 View/ViewController/ViewModel 里使用 Task 时,养成 [weak self] 习惯,避免闭包持有整个视图层级。

一句话总结

“同步上下文想 await?包一层 Task {} 就行。”

它是 Swift 结构化并发的任意门:轻量、无样板、可取消。

只要记得生命周期对齐 + 弱引用捕获,就能在按钮、Delegate、App 生命周期里放心使用 async/await。

参考资料

  1. How to use async/await in synchronous Swift code with tasks

Swift 三目运算符指南:写法、场景与避坑

作者 unravel2025
2025年9月12日 12:32

什么是三目运算符?

三目运算符(ternary operator)是 if/else 的单行表达式版,语法模板:

<条件> ? <条件为真结果> : <条件为假结果>

必须同时给出真/假两个分支,否则编译器直接报错。

最小可运行示例

struct DemoView: View {
    @State private var username = ""

    var body: some View {
        Button("Submit") {}
            .tint(username.isEmpty ? .gray : .red)   // ← 三目
    }
}

username 为空时按钮呈灰色,否则红色。

一行代码完成“判断 + 赋值”,是 SwiftUI 里高频用法。

适用场景 checklist

✅ 推荐

  • 仅两个分支
  • 每个分支单个表达式
  • 表达式短且无副效应
  • 作为参数/赋值使用

❌ 不推荐

  • 嵌套三目 → 可读性断崖
  • 分支里再调函数/打印/网络请求
  • 一行超长 120+ 字符

if 表达式对比(Swift 5.9+)

Swift 5.9 起,if 也能当表达式用:

let color: Color = if username.isEmpty {
    .gray
} else {
    .red
}
维度 三目 if表达式
行数 1 行 多行
可读性 简洁但易过长 清晰
分支数 仅 2 可 else if
使用位置 任意表达式上下文 只能用于赋值/返回
嵌套 容易失控 结构清晰

结论:

  • 简单二选一 → 三目
  • 需要 else if / 长表达式 → if 表达式
  • 副作用或复杂逻辑 → 普通 if 语句

实战:SwiftUI 里常用的一行代码

Image(systemName: isOn ? "checkmark.circle.fill" : "circle")
    .foregroundColor(isOn ? .green : .gray)

Text("Remain: \(secondsLeft > 0 ? "\(secondsLeft)s" : "Done")")
    .fontWeight(secondsLeft > 0 ? .regular : .bold)

Circle()
    .fill(status == .loading ? AnyShapeStyle(.orange) : AnyShapeStyle(.blue))

踩坑指南

  1. 类型必须一致
   let x = flag ? 1 : 1.0        // ❌ 编译错误:Int vs Double
   let x = flag ? Double(1) : 1.0 // ✅
  1. 优先级陷阱
   print(false ? "A" : "B" + "!")   // 输出 B!,+ 优先级高于三目

推荐加括号:

   print((false ? "A" : "B") + "!")
  1. 嵌套地狱
   let color = a ? (b ? .red : .green) : (c ? .blue : .gray)

超过一层嵌套,立即改成 if 表达式或工厂方法。

小结:一句话口诀

“二选一、短表达式、只取值,用三目;否则换 if。”

把三目当作“单行表达式糖”,而非“万能 if”,就能在简洁与可读之间找到最佳平衡点。祝你写出既短又稳的 Swift 代码!

一篇读懂 Swift 不透明类型:让带 associatedtype 的协议也能当返回值

作者 unravel2025
2025年9月12日 10:04

参考原文:Understanding opaque types and protocols with associatedtype in Swift

环境:Swift 6.2 + Xcode 26

why:带 associatedtype 的协议为何不能当返回值?

protocol Store {
    associatedtype Item
    func persist(item: Item)
}

// ❌ 编译失败:Protocol 'Store' can only be used as a generic constraint
func makeStore() -> Store { ... }
  • associatedtype 未被确定 → 编译期无法决定具体内存布局。
  • Swift 拒绝“协议当作类型”使用,除非用泛型或 opaque 类型。

传统 workaround:泛型约束

func makeStore<T: Store>() -> T { ... }   // ✅ 可行,但调用端要写类型

痛点:

  • 调用处仍需显式指定类型
  • 代码膨胀(每种 T 一份实现)
  • 无法隐藏实现细节(返回类型泄露)

Swift 5.1+ 解法:opaque 类型 (some)

func makeStore() -> some Store { 
    return UserDefaultsStore()   // 具体类型被隐藏,调用端只认 Store 协议
}
  • 返回类型由编译器推断,调用者无需知道 UserDefaultsStore
  • 内存布局确定(编译期知道真实类型大小)。
  • 语法糖:等价于“泛型参数由编译器自动填充”。

opaque vs 泛型 vs 存在容器(any)速查表

特性 具体类型 内存布局 性能 隐藏实现 调用端写法 适用场景
opaque (some) 编译期已知 静态派发,无额外开销 最优 最简洁 返回值/参数想隐藏具体类型
泛型 <T: Store> 调用者指定 静态 最优 需显式类型 需要多类型复用实现
存在容器 (any Store) 运行时动态 存在容器(1 ptr + metadata) 动态派发,略慢 同 opaque 需要运行时异构集合

实战:同一函数三种写法对比

// 1. 泛型 — 调用者决定类型
func makeStore<T: Store>() -> T { T() }

// 2. Opaque — 实现者决定类型,调用者无感
func makeStore() -> some Store { UserDefaultsStore() }

// 3. 存在容器 — 运行时多态
func makeStore() -> any Store { UserDefaultsStore() }

调用侧:

let s1: some Store = makeStore()   // 编译期知道真实类型
let s2: any Store  = makeStore()   // 运行时才知道

什么时候选 opaque?

  1. 只想隐藏返回类型,不关心具体实现
  2. 性能敏感(避免存在容器额外间接层)
  3. API 向前兼容——日后可无缝换成别的具体类型,不破坏二进制接口

一句话总结

带 associatedtype 的协议不能当返回值?

some Protocol 就行!

它 = “编译期泛型” + “实现细节隐藏” + “零成本抽象”,

让协议真正像“类型”一样使用,而无需把泛型复杂性抛给调用者。

`@dynamicCallable`:把 Swift 对象当函数喊

作者 unravel2025
2025年9月12日 10:02

一、为什么需要“假装函数”?

有时我们想让一个值看起来就是函数,从而写出更自然的 DSL:

logger("App launched")           // 像 print
let person = creator(name: "A")  // 像工厂

@dynamicCallable 就是 Swift 给的“变身器”: “让实例像函数一样被 call,背后转到你定义的方法。”

二、核心机制:两条魔法方法

方法 对应调用语法 参数类型
dynamicallyCall(withArguments:) instance(a, b, c) [T]
dynamicallyCall(withKeywordArguments:) instance(name: x, age: y) KeyValuePairs<String, T>

只需实现任意一个或两个,即可开启 callable 语法。

三、最小可运行示例:Hello Greeter

  1. 传统写法
struct Greeter {
    func sayHello(to name: String) -> String {
        "Hello, \(name)!"
    }
}
let g = Greeter()
g.sayHello(to: "Alice")
  1. @dynamicCallable 变身
@dynamicCallable
struct Greeter {
    func dynamicallyCall(withArguments names: [String]) -> String {
        guard let first = names.first else { return "Hello, World!" }
        return "Hello, \(first)!"
    }
}

let g = Greeter()
g("Alice")        // "Hello, Alice!"
g()               // "Hello, World!"

变化:

g.sayHello(to:) → 直接 g(...),更像函数。

四、带标签参数:KeyValuePairs 实战

@dynamicCallable
struct PersonCreator {
    func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, String>) -> String {
        args.map { "\($0) is \($1)" }.joined(separator: ", ")
    }
}

let creator = PersonCreator()
creator(name: "John")                    // "name is John"
creator(name: "Alice", age: "25", city: "NYC") // "name is Alice, age is 25, city is NYC"

KeyValuePairs 保持标签顺序,比 Dictionary 更适合 DSL。

五、真实场景:可调用 Logger

@dynamicCallable
struct Logger {
    func dynamicallyCall(withArguments msgs: [String]) {
        print("[\(Date())] \(msgs.joined(separator: " "))")
    }
    func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, String>) {
        let pairs = args.map { "\($0): \($1)" }.joined(separator: ", ")
        print("[\(Date())] \(pairs)")
    }
}

let log = Logger()
log("App", "started")                       // 简写
log(event: "login", user: "john", status: "ok")  // 结构化

输出:

[2025-09-05 14:22:10 +0000] App started
[2025-09-05 14:22:10 +0000] event: login, user: john, status: ok

六、与 Swift 6 并发兼容

@dynamicCallable 方法默认继承调用者的隔离域:

@MainActor
class ViewModel {
    @dynamicCallable
    struct Logger {
        func dynamicallyCall(withArguments msgs: [String]) {
            print("[Main] \(msgs.joined())")
        }
    }
    
    func tap() {
        let log = Logger()
        log("Button tapped")   // 主线程执行,安全
    }
}

→ 无需额外标注,自动遵循隔离规则。

七、什么时候用 / 不用

✅ 适合

  • 构建DSL(日志、配置、SQL、Shell)
  • 希望 API 像函数一样自然
  • 参数数量或标签不固定

❌ 不适合

  • 普通业务逻辑——直接方法更清晰
  • 需要强类型检查(编译期无法看到具体标签)
  • 团队对“魔法”语法接受度低

八、常见编译错误对照

错误原因修复 Member dynamicallyCall has unsupported type方法签名不对改为官方模板 [T]KeyValuePairs<String, T> Call arguments don't match any overload参数类型/数量不符检查实参类型与 withArguments/withKeywordArguments 是否一致 Cannot call value of non-function type忘记加 @dynamicCallable补上属性


九、小结:一句话背下来

@dynamicCallable = “把实例当函数喊”,背后转到你写的 dynamicallyCall

它让 API 更自然、让 DSL 更优雅,但也别滥用——清晰比酷炫更重要。

记住口诀:

“要 callable,加 @dynamicCallable; positional 用数组,labeled 用 KeyValuePairs。”

下次写配置、日志、DSL 时,不妨让它“像个函数”——一声 call,就搞定。

仓颉语言Option 的“问号”全景图—— 一个 `?` 与 `.` `()` `[]` `{}` 的组合写法

作者 unravel2025
2025年9月12日 09:59

一句话先给结论

在仓颉里,? 是 Option 的“安全导航符”;它能无缝插进任何“取值/调用/下标/代码块”场景,遇到 None 就立即短路返回 None,否则继续往后走。

下面带你一次看全所有花式用法。

基础规则速查表

符号 意义 是否可接 ? 短路行为
. 成员访问 None 时跳过成员访问
() 函数/方法调用 None 时跳过调用
[] 下标取值 None 时跳过取下标
{} lambda/闭包 None 时跳过闭包执行

场景示例

方法返回Option类型

func readFile(): ?String {
    None<String>
}
let cache: Option<String> = readFile()

?. 访问成员变量

import std.random.Random

let rand = Random()

struct User {
    User(let name: String) {}
}

func getUser(): ?User {
    if (rand.nextBool()) {
        User("unravel")
    } else {
        None
    }
}

let u: Option<User> = getUser() // 可能 None
let len = u?.name.size // Option<Int>

?() 调用函数

func twice(x: Int64): Int64 {
    x * 2
}

let f: Option<(Int64) -> Int64> = Some(twice)
let r = f?(10) // Some(20)

?[] 访问下标

// 安全下标
let arr: Option<Array<Int64>> = Some([7, 8, 9])
// arr存在时才可以返回值,不能保证下标越界的崩溃
let second = arr?[1] // Some(8)

?{} 传入尾随闭包

type IntCallFunc = ((Int64) -> Int64) -> Int64

func opFunc(action: (Int64) -> Int64): Int64 {
    action(5)
}

let op: ?IntCallFunc = opFunc
let doubled = op? {
    x => x * 2
} // Some(10)

链式混写——一次写完所有符号

type IntCallFunc = ((Int64) -> Int64) -> Int64

func opFunc(action: (Int64) -> Int64): Int64 {
    action(5)
}
class ContainerItem {
    func compute(): ?IntCallFunc {
        return opFunc
    }
}

class Container {
    Container(public let items: Array<?ContainerItem>) {}
}

// 链式混写——一次写完所有符号
let deep: Option<Container> = Container([ContainerItem()])
// 安全导航:取值→下标→调用→闭包
let result = deep?.items[0]?.compute()? {x => x + 1}

与 match 组合:把最终 None 转成默认值

当然,更建议使用coalescing操作符。coalescing操作符和下面的实现等价

let final0 = result ?? -1

let final = match (result) {
    case Some(v) => v
    case _ => -1
}

配合标准库ifSome、ifNone使用

ifSome(cache) {
    c => println("拿到缓存 ${c}")
}

ifNone(cache) {
    println("没有拿到缓存")
}

多层嵌套 struct 一路点下去

struct A {
    A(let b: Option<B>) {}
}

struct B {
    B(let c: Option<C>) {}
}

struct C {
    C(let value: Int64) {}
}

let a: Option<A> = A(B(C(64)))
let v = a?.b?.c?.value // Option<Int64>

数组元素本身是 Option

let opts: Array<Option<Int64>> = [Some(1), None, Some(3)]
let heads = opts[1] // 先取下标 → 得到 None
ifSome(heads) {
    v => println("heads的值是${v}")
}

高阶函数指针

type Fn = (Int64) -> Option<Int64>

let maybeFn: Option<Fn> = Some({x => Some(x * 3)})
let out = maybeFn?(7) // Some(21)

自定义下标运算符

extend<T> Array<T> {
    public operator func [](idx: Int64, action: (T) -> T): T {
        if (idx >= 0 && idx < size) {
            let v = this[idx]
            action(v)
        } else {
            throw Exception("下标越界")
        }
    }
} // Some(30)

let data = Some([10, 20])
let x = data?[1, {
        v => v + 10
    }]

一张图记住所有写法

Option<T> 变量 ──→ ? ──→ .member     → Option<U>
                 │     │()
                 │     │[]
                 │     │{ ... }
                 │     │
                 └─→ 任意一环 None 就整体返回 None

iOS26适配指南之UISlider

作者 YungFan
2025年9月12日 09:00

介绍

在 iOS 26 中,UISlider 迎来了两项重要更新:

  • 增加了类型为UISlider.Style的属性sliderStyle,用于设置拖拽时的样式。
  • 增加了类型为UISlider.TrackConfiguration?的属性trackConfiguration,用于添加刻度,并且支持自定义刻度。

这两个属性结合使用,可以让 UISlider 从传统的“连续滑块”进化为带刻度的选择器,常见于音量调节、亮度调节、进度选择、配置项选择等场景。

使用

  • 代码。
import UIKit

class ViewController: UIViewController {
    lazy var basicTickSlider: UISlider = {
        let slider = UISlider()
        slider.value = 0.5
        slider.addTarget(self, action: #selector(valueChanged), for: .valueChanged)
        // iOS26新增
        slider.sliderStyle = .default
        // iOS26新增,刻度数量
        var config = UISlider.TrackConfiguration(numberOfTicks: 10)
        config.allowsTickValuesOnly = true
        slider.trackConfiguration = config
        slider.translatesAutoresizingMaskIntoConstraints = false
        return slider
    }()
    lazy var customTickSlider: UISlider = {
        let slider = UISlider()
        slider.value = 0.5
        slider.addTarget(self, action: #selector(valueChanged), for: .valueChanged)
        slider.sliderStyle = .thumbless
        // iOS26新增,自定义刻度
        let customTicks = [
            UISlider.TrackConfiguration.Tick(position: 0),
            UISlider.TrackConfiguration.Tick(position: 0.1),
            UISlider.TrackConfiguration.Tick(position: 0.3),
            UISlider.TrackConfiguration.Tick(position: 0.6),
            UISlider.TrackConfiguration.Tick(position: 1.0)
        ]
        let config = UISlider.TrackConfiguration(allowsTickValuesOnly: true, ticks: customTicks)
        slider.trackConfiguration = config
        slider.translatesAutoresizingMaskIntoConstraints = false
        return slider
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(basicTickSlider)
        view.addSubview(customTickSlider)

        NSLayoutConstraint.activate([
            basicTickSlider.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40),
            basicTickSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            basicTickSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            basicTickSlider.heightAnchor.constraint(equalToConstant: 44),
            customTickSlider.topAnchor.constraint(equalTo: basicTickSlider.bottomAnchor, constant: 40),
            customTickSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            customTickSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
        ])
    }

    // MARK: 滑块内容变化事件
    @objc func valueChanged(_ sender: Any) {
        print(#function)
    }
}
  • 效果。

效果.gif

一文精通-Flutter 状态管理

2025年9月12日 08:51

什么是状态管理?

在 Flutter 中,状态是指任何可以随时间变化的数据,这些数据的变化会影响用户界面的呈现。状态管理则是处理这些数据的变更、存储、传递以及在UI上反映这些变更的一套方法和架构模式。

状态类型

  1. 局部状态(Ephemeral State) :只影响单个组件或少数几个组件的状态,通常使用 setState() 管理
  2. 应用状态(App State) :需要在多个部分之间共享的全局状态,需要专门的状态管理方案

常用状态管理方案详解

1. setState - 内置基础方案

setState 是 Flutter 最基础的状态管理方式,适用于组件内部的状态管理。

实现原理

通过调用 setState() 方法通知框架当前对象的状态已改变,需要重新构建组件树。

适用场景

  • 单个组件内部的简单状态
  • 不需要跨组件共享的状态
  • 简单的计数器、开关状态等

完整示例代码

dart

import 'package:flutter/material.dart';

// 使用setState管理的计数器应用
class CounterApp extends StatefulWidget {
  @override
  _CounterAppState createState() => _CounterAppState();
}

class _CounterAppState extends State<CounterApp> {
  int _counter = 0; // 定义状态变量

  // 增加计数器的方法
  void _incrementCounter() {
    setState(() { // 调用setState通知框架状态变化
      _counter++; // 更新状态值
    });
  }

  // 减少计数器的方法
  void _decrementCounter() {
    setState(() {
      _counter--; // 更新状态值
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('setState状态管理示例'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              '当前计数:',
              style: TextStyle(fontSize: 20),
            ),
            Text(
              '$_counter', // 显示状态值
              style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _decrementCounter, // 绑定减少方法
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _incrementCounter, // 绑定增加方法
                  child: Icon(Icons.add),
                ),
              ],
            )
          ],
        ),
      ),
    );
  }
}

// 应用入口
void main() {
  runApp(MaterialApp(
    home: CounterApp(),
    debugShowCheckedModeBanner: false,
  ));
}

优点

  • 无需额外依赖
  • 简单易用,学习成本低
  • 适合简单场景

缺点

  • 状态无法在组件间轻松共享
  • 业务逻辑和UI代码混合,难以维护复杂应用
  • 性能较差,每次调用都会重建整个组件

2. Provider - 推荐的中等复杂度方案

Provider 是 Flutter 团队推荐的状态管理方案,基于 InheritedWidget 进行了封装简化。

添加依赖

yaml

dependencies:
  provider: ^6.0.0

核心概念

  • ChangeNotifier: 发布变更通知的类
  • ChangeNotifierProvider: 向子树提供ChangeNotifier的widget
  • Consumer: 监听Provider变化的widget
  • Selector: 只监听特定部分变化的Consumer优化版本

适用场景

  • 中小型应用
  • 需要跨组件共享状态的场景
  • 团队熟悉响应式编程概念

完整示例代码

dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// 1. 创建数据模型,继承ChangeNotifier
class CounterModel with ChangeNotifier {
  int _count = 0;
  
  int get count => _count; // 获取状态值
  
  // 增加计数
  void increment() {
    _count++;
    notifyListeners(); // 通知监听者状态已改变
  }
  
  // 减少计数
  void decrement() {
    _count--;
    notifyListeners();
  }
  
  // 重置计数
  void reset() {
    _count = 0;
    notifyListeners();
  }
}

// 2. 在应用顶层提供状态
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CounterModel(), // 创建状态实例
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Provider示例',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: HomePage(),
    );
  }
}

// 3. 主页
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Provider状态管理'),
        actions: [
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: () {
              // 通过Provider.of访问状态方法,listen: false表示不监听变化
              Provider.of<CounterModel>(context, listen: false).reset();
            },
          )
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('使用Provider管理的计数器', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            // 4. 使用Consumer监听状态变化
            Consumer<CounterModel>(
              builder: (context, counter, child) {
                return Text(
                  '${counter.count}',
                  style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                );
              },
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () {
                    Provider.of<CounterModel>(context, listen: false).decrement();
                  },
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () {
                    Provider.of<CounterModel>(context, listen: false).increment();
                  },
                  child: Icon(Icons.add),
                ),
              ],
            ),
            SizedBox(height: 30),
            // 5. 导航到另一个页面演示状态共享
            ElevatedButton(
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => SecondPage()),
                );
              },
              child: Text('前往第二页查看共享状态'),
            )
          ],
        ),
      ),
    );
  }
}

// 6. 第二个页面,演示状态共享
class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('第二页')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('这是第二页,显示相同的计数器状态', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            // 在不同页面使用同一个状态
            Consumer<CounterModel>(
              builder: (context, counter, child) {
                return Text(
                  '${counter.count}',
                  style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                  textAlign: TextAlign.center,
                );
              },
            ),
            SizedBox(height: 20),
            Text('注意: 两页面的计数器状态是同步的', style: TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

优点

  • Flutter官方推荐
  • 概念简单,易于学习
  • 性能良好,可以精确控制重建范围
  • 适合大多数应用场景

缺点

  • 需要一定的样板代码
  • 对于超大型应用可能不够强大

3. Bloc - 复杂应用的状态管理

Bloc (Business Logic Component) 使用流(Stream)来管理状态,采用单向数据流架构。

添加依赖

yaml

dependencies:
  flutter_bloc: ^8.0.0
  equatable: ^2.0.0

核心概念

  • Event: 表示用户交互或系统事件
  • State: 应用的状态
  • Bloc: 将Event转换为State的业务逻辑组件

适用场景

  • 中大型复杂应用
  • 需要严格分离业务逻辑和UI
  • 需要高度可测试性的项目
  • 需要时间旅行调试等高级功能

完整示例代码

dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

// 1. 定义事件(Event)
abstract class CounterEvent extends Equatable {
  const CounterEvent();

  @override
  List<Object> get props => [];
}

class IncrementEvent extends CounterEvent {} // 增加事件
class DecrementEvent extends CounterEvent {} // 减少事件
class ResetEvent extends CounterEvent {}     // 重置事件

// 2. 定义状态(State)
class CounterState extends Equatable {
  final int count;
  
  const CounterState(this.count);
  
  // 命名构造函数,提供初始状态
  const CounterState.initial() : count = 0;
  
  @override
  List<Object> get props => [count];
  
  // 重写toString方便调试
  @override
  String toString() => 'CounterState(count: $count)';
}

// 3. 创建Bloc(业务逻辑组件)
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterState.initial()) {
    // 注册事件处理器
    on<IncrementEvent>(_onIncrement);
    on<DecrementEvent>(_onDecrement);
    on<ResetEvent>(_onReset);
  }
  
  // 处理增加事件
  void _onIncrement(IncrementEvent event, Emitter<CounterState> emit) {
    emit(CounterState(state.count + 1));
  }
  
  // 处理减少事件
  void _onDecrement(DecrementEvent event, Emitter<CounterState> emit) {
    emit(CounterState(state.count - 1));
  }
  
  // 处理重置事件
  void _onReset(ResetEvent event, Emitter<CounterState> emit) {
    emit(const CounterState.initial());
  }
}

// 4. 应用入口
void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Bloc示例',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: BlocProvider(
        // 提供Bloc实例
        create: (context) => CounterBloc(),
        child: const HomePage(),
      ),
    );
  }
}

// 5. 主页
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 获取Bloc实例
    final CounterBloc counterBloc = BlocProvider.of<CounterBloc>(context);
    
    return Scaffold(
      appBar: AppBar(
        title: const Text('Bloc状态管理'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () {
              // 发送重置事件
              counterBloc.add(ResetEvent());
            },
          )
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('使用Bloc管理的计数器', style: TextStyle(fontSize: 18)),
            const SizedBox(height: 20),
            // 6. 使用BlocBuilder构建响应UI
            BlocBuilder<CounterBloc, CounterState>(
              builder: (context, state) {
                return Text(
                  '${state.count}',
                  style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                );
              },
            ),
            const SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () {
                    // 发送减少事件
                    counterBloc.add(DecrementEvent());
                  },
                  child: const Icon(Icons.remove),
                ),
                const SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () {
                    // 发送增加事件
                    counterBloc.add(IncrementEvent());
                  },
                  child: const Icon(Icons.add),
                ),
              ],
            ),
            const SizedBox(height: 30),
            ElevatedButton(
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => const SecondPage()),
                );
              },
              child: const Text('前往第二页查看共享状态'),
            )
          ],
        ),
      ),
    );
  }
}

// 7. 第二个页面
class SecondPage extends StatelessWidget {
  const SecondPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('第二页')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('这是第二页,显示相同的计数器状态', style: TextStyle(fontSize: 18)),
            const SizedBox(height: 20),
            // 在不同页面共享同一个Bloc状态
            BlocBuilder<CounterBloc, CounterState>(
              builder: (context, state) {
                return Text(
                  '${state.count}',
                  style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                  textAlign: TextAlign.center,
                );
              },
            ),
            const SizedBox(height: 20),
            const Text('注意: 两页面的计数器状态是同步的', style: TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

优点

  • 严格的关注点分离
  • 极高的可测试性
  • 强大的调试工具(Bloc Observer)
  • 适合大型团队协作

缺点

  • 学习曲线较陡峭
  • 需要较多样板代码
  • 概念较多,初学者可能难以理解

4. GetX - 轻量且功能强大的方案

GetX 是一个轻量且强大的解决方案,不仅提供状态管理,还提供路由管理、依赖注入等功能。

添加依赖

yaml

dependencies:
  get: ^4.6.1

核心概念

  • GetxController: 管理状态和业务逻辑的控制器
  • Obx: 响应式观察者组件
  • GetBuilder: 非响应式状态更新组件
  • Get.put: 依赖注入方法

适用场景

  • 希望尽量减少样板代码的项目
  • 需要轻量级但功能全面的解决方案
  • 中小型应用快速开发

完整示例代码

dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';

// 1. 创建控制器类
class CounterController extends GetxController {
  // 使用Rx包装状态使其可观察
  var count = 0.obs;
  
  // 增加计数
  void increment() {
    count.value++;
  }
  
  // 减少计数
  void decrement() {
    count.value--;
  }
  
  // 重置计数
  void reset() {
    count.value = 0;
  }
}

// 2. 应用入口
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 初始化控制器
    final CounterController counterController = Get.put(CounterController());
    
    return GetMaterialApp( // 使用GetMaterialApp替代MaterialApp
      title: 'GetX示例',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: HomePage(),
    );
  }
}

// 3. 主页
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('GetX状态管理'),
        actions: [
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: () {
              // 获取控制器并调用方法
              Get.find<CounterController>().reset();
            },
          )
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('使用GetX管理的计数器', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            // 4. 使用Obx构建响应式UI
            Obx(() {
              return Text(
                '${Get.find<CounterController>().count.value}',
                style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
              );
            }),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () {
                    Get.find<CounterController>().decrement();
                  },
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () {
                    Get.find<CounterController>().increment();
                  },
                  child: Icon(Icons.add),
                ),
              ],
            ),
            SizedBox(height: 30),
            ElevatedButton(
              onPressed: () {
                // 使用GetX导航,无需context
                Get.to(SecondPage());
              },
              child: Text('前往第二页查看共享状态'),
            )
          ],
        ),
      ),
    );
  }
}

// 5. 第二个页面
class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('第二页')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('这是第二页,显示相同的计数器状态', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            // 在不同页面共享同一个状态
            Obx(() {
              return Text(
                '${Get.find<CounterController>().count.value}',
                style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                textAlign: TextAlign.center,
              );
            }),
            SizedBox(height: 20),
            Text('注意: 两页面的计数器状态是同步的', style: TextStyle(color: Colors.grey)),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // 返回上一页
                Get.back();
              },
              child: Text('返回'),
            ),
          ],
        ),
      ),
    );
  }
}

优点

  • 极少的样板代码
  • 性能优异,精确更新
  • 集成路由、依赖注入等功能
  • 学习曲线平缓

缺点

  • 不符合Flutter传统模式
  • 可能过度封装,隐藏了Flutter底层机制
  • 在超大型项目中可能难以维护

5. Riverpod - Provider的改进版

Riverpod 是 Provider 的改进版本,解决了 Provider 的一些限制,如编译安全、无需BuildContext等。

添加依赖

yaml

dependencies:
  flutter_riverpod: ^1.0.3

核心概念

  • Provider: 各种提供者的基类
  • StateProvider: 提供简单可变状态
  • StateNotifierProvider: 提供更复杂的状态和业务逻辑
  • ConsumerWidget/Consumer: 消费Provider的组件

适用场景

  • 所有规模的应用
  • 需要编译时安全的状态管理
  • 希望避免Provider的某些限制

完整示例代码

dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 1. 创建Provider
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

// 2. 创建Notifier类
class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0); // 初始化状态
  
  // 增加计数
  void increment() {
    state++;
  }
  
  // 减少计数
  void decrement() {
    state--;
  }
  
  // 重置计数
  void reset() {
    state = 0;
  }
}

// 3. 应用入口
void main() {
  runApp(ProviderScope(child: MyApp())); // 使用ProviderScope包裹应用
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod示例',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: HomePage(),
    );
  }
}

// 4. 主页 - 使用ConsumerWidget替代StatelessWidget
class HomePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 通过ref读取Provider状态
    final counter = ref.watch(counterProvider);
    // 获取Notifier实例用于调用方法
    final counterNotifier = ref.read(counterProvider.notifier);
    
    return Scaffold(
      appBar: AppBar(
        title: Text('Riverpod状态管理'),
        actions: [
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: () {
              counterNotifier.reset();
            },
          )
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('使用Riverpod管理的计数器', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            Text(
              '$counter', // 直接使用状态值
              style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () {
                    counterNotifier.decrement();
                  },
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () {
                    counterNotifier.increment();
                  },
                  child: Icon(Icons.add),
                ),
              ],
            ),
            SizedBox(height: 30),
            ElevatedButton(
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => SecondPage()),
                );
              },
              child: Text('前往第二页查看共享状态'),
            )
          ],
        ),
      ),
    );
  }
}

// 5. 第二个页面
class SecondPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterProvider);
    
    return Scaffold(
      appBar: AppBar(title: Text('第二页')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('这是第二页,显示相同的计数器状态', style: TextStyle(fontSize: 18)),
            SizedBox(height: 20),
            Text(
              '$counter',
              style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
              textAlign: TextAlign.center,
            ),
            SizedBox(height: 20),
            Text('注意: 两页面的计数器状态是同步的', style: TextStyle(color: Colors.grey)),
          ],
        ),
      ),
    );
  }
}

优点

  • 编译时安全,避免运行时错误
  • 不依赖BuildContext,更灵活
  • 更好的性能优化
  • 适合所有规模的项目

缺点

  • 相对较新,生态系统还在成长中
  • 与Provider略有不同,需要重新学习

综合对比

方案 学习曲线 代码量 性能 测试难易度 适用场景 维护性 社区支持
setState 简单 中等 简单 简单组件、局部状态 官方支持
Provider 中等 中等 中等 中小型应用 良好 官方推荐
Bloc 较陡峭 容易 中大型复杂应用 优秀 强大
GetX 简单 极高 中等 中小型应用快速开发 中等 强大
Riverpod 中等 中等 中等 所有规模应用 优秀 成长中

选择建议

  1. 初学者/简单项目:从 setState 开始,逐步学习更复杂的方案
  2. 中小型应用:推荐使用 Provider 或 Riverpod
  3. 大型复杂应用:推荐使用 Bloc 或 Riverpod
  4. 快速开发/原型:可以考虑 GetX
  5. 需要高度可测试性Bloc 是最佳选择
  6. 编译时安全:选择 Riverpod

最佳实践

  1. 根据项目规模选择:不要为简单项目引入复杂方案
  2. 保持一致性:项目中尽量使用统一的状态管理方案
  3. 合理分层:将业务逻辑与UI分离
  4. 适度使用:不是所有状态都需要全局管理
  5. 性能优化:使用选择性重建(如Consumer、Selector等)

总结

Flutter 状态管理没有"唯一最佳"方案,每种方案都有其适用场景。选择时应考虑项目规模、团队经验、性能要求和维护成本等因素。对于大多数应用,Provider 或 Riverpod 是平衡了易用性和功能性的不错选择。随着项目复杂度的增加,可以考虑迁移到 Bloc 等更严格的架构方案。

无论选择哪种方案,理解状态管理的基本原则(如不可变性、单向数据流)比掌握特定库更重要,这些原则可以帮助你构建更健壮、可维护的应用程序。

完整的苹果内购in-purchase实例-采用object-c语言使用苹果的Storyboard开发模式的完整购买实例

作者 卓伊凡
2025年9月12日 00:51

完整的苹果内购in-purchase实例-采用object-c语言使用苹果的Storyboard开发模式的完整购买实例

引言

此前 写了 object-c的实例,但是交付时候发现客户采用的是Storyboard开发模式,因此我们只能单独再写Storyboard开发模式的代码。

项目开源地址:

gitee.com/youyacao/io…

内容如下

首先单独去建立了内购的密钥

密钥的ID为Q9W29CG749 对应的 app 包名 Liu.GameTest

密钥放到本地

对应的内购商品

产品参考名称: LiuAppGame1Item1 产品id: 001Liu

产品参考名称: LiuAppGame1Item2 产品id: 002Liu
产品参考名称: LiuAppGame1Item3 产品id: 003Liu

3个商品对应三种金额,对应不同的价格。

已创建AppDelegate.h文件,现在创建AppDelegate.m文件来实现应用程序的生命周期管理。


创建ViewController.m文件,实现内购的核心逻辑,包括加载商品信息、处理购买请求和交易结果等功能。

创建Main.storyboard文件,实现内购界面的UI设计,包括商品信息展示和购买按钮。

创建的结构目录要符合常规项目需要,最后我会放出结构。

    创建了核心源文件:
  • AppDelegate.h 和 AppDelegate.m :应用程序委托类
  • ViewController.h 和 ViewController.m :包含内购核心逻辑的视图控制器
    实现了内购功能:
  • 支持三个指定的内购商品:001Liu、002Liu和003Liu
  • 实现了商品信息请求、购买流程、交易处理和恢复购买功能
  • 使用了StoreKit.framework框架
    创建了界面文件:
  • Main.storyboard :包含完整的内购UI界面,有商品展示和购买按钮
  • LaunchScreen.storyboard :启动界面
    配置文件:
  • Info.plist :设置了Bundle Identifier为Liu.GameTest
  • project.pbxproj :完整的Xcode项目配置,包含正确的文件路径引用
    项目结构:
  • 遵循Xcode标准目录结构,所有源文件位于 iOSInAppPurchaseDemo 目录下
  • storyboard文件位于 iOSInAppPurchaseDemo/Base.lproj 目录下
  • 创建了 .gitignore 文件忽略不需要版本控制的文件
//
//  ViewController.m
//  iOSInAppPurchaseDemo
//
//  Created by 卓伊凡 on 2025/9/12.
//

#import "ViewController.h"

@interface ViewController () {
    NSArray *_productIdentifiers;
    NSArray *_products;
    SKProductsRequest *_productsRequest;
}

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // 设置商品ID数组
    _productIdentifiers = @[@"001Liu", @"002Liu", @"003Liu"];

    // 注册为交易观察者
    [[SKPaymentQueue defaultQueue] addTransactionObserver:self];

    // 初始化状态标签
    self.statusLabel.text = @"准备加载商品信息...";

    // 请求商品信息
    [self requestProductsWithIdentifiers:_productIdentifiers];
}

- (void)dealloc {
    // 移除交易观察者
    [[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}

#pragma mark - 请求商品信息

- (void)requestProductsWithIdentifiers:(NSArray *)productIdentifiers {
    if ([SKPaymentQueue canMakePayments]) {
        NSSet *productIdentifierSet = [NSSet setWithArray:productIdentifiers];
        _productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifierSet];
        _productsRequest.delegate = self;
        [_productsRequest start];
    } else {
        self.statusLabel.text = @"当前设备不支持应用内购买";
    }
}

#pragma mark - SKProductsRequestDelegate 代理方法

- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
    // 存储有效商品
    _products = response.products;

    // 打印无效的商品ID
    if (response.invalidProductIdentifiers.count > 0) {
        NSLog(@"无效的商品ID: %@", response.invalidProductIdentifiers);
    }

    // 更新UI,显示商品信息
    [self updateProductUI];
}

- (void)request:(SKRequest *)request didFailWithError:(NSError *)error {
    NSLog(@"请求商品信息失败: %@", error.localizedDescription);
    self.statusLabel.text = [NSString stringWithFormat:@"加载商品信息失败: %@", error.localizedDescription];
}

- (void)requestDidFinish:(SKRequest *)request {
    // 请求完成
}

#pragma mark - 更新商品UI

- (void)updateProductUI {
    if (_products.count == 0) {
        self.statusLabel.text = @"未找到商品信息,请检查商品ID是否正确";
        return;
    }

    self.statusLabel.text = @"商品信息加载完成,点击购买按钮进行购买";

    // 根据商品ID匹配对应的商品信息
    for (SKProduct *product in _products) {
        NSString *productID = product.productIdentifier;
        NSString *productTitle = product.localizedTitle;
        NSString *productDescription = product.localizedDescription;
        NSString *priceString = [self priceStringForProduct:product];

        NSString *displayText = [NSString stringWithFormat:@"%@\n%@\n价格: %@", productTitle, productDescription, priceString];

        if ([productID isEqualToString:@"001Liu"]) {
            self.product1Label.text = displayText;
        } else if ([productID isEqualToString:@"002Liu"]) {
            self.product2Label.text = displayText;
        } else if ([productID isEqualToString:@"003Liu"]) {
            self.product3Label.text = displayText;
        }
    }
}

#pragma mark - 格式化价格

- (NSString *)priceStringForProduct:(SKProduct *)product {
    NSNumberFormatter *priceFormatter = [[NSNumberFormatter alloc] init];
    [priceFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4];
    [priceFormatter setNumberStyle:NSNumberFormatterCurrencyStyle];
    [priceFormatter setLocale:product.priceLocale];

    return [priceFormatter stringFromNumber:product.price];
}

#pragma mark - 购买按钮点击事件

- (IBAction)purchaseProduct1:(id)sender {
    [self purchaseProductWithIdentifier:@"001Liu"];
}

- (IBAction)purchaseProduct2:(id)sender {
    [self purchaseProductWithIdentifier:@"002Liu"];
}

- (IBAction)purchaseProduct3:(id)sender {
    [self purchaseProductWithIdentifier:@"003Liu"];
}

#pragma mark - 发起购买请求

- (void)purchaseProductWithIdentifier:(NSString *)productIdentifier {
    if (![SKPaymentQueue canMakePayments]) {
        UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"购买失败" message:@"当前设备不支持应用内购买" preferredStyle:UIAlertControllerStyleAlert];
        [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
        [self presentViewController:alert animated:YES completion:nil];
        return;
    }

    // 查找对应的商品
    SKProduct *productToPurchase = nil;
    for (SKProduct *product in _products) {
        if ([product.productIdentifier isEqualToString:productIdentifier]) {
            productToPurchase = product;
            break;
        }
    }

    if (productToPurchase) {
        self.statusLabel.text = [NSString stringWithFormat:@"正在发起购买请求: %@", productToPurchase.localizedTitle];

        // 创建支付请求
        SKPayment *payment = [SKPayment paymentWithProduct:productToPurchase];
        // 将支付请求添加到支付队列
        [[SKPaymentQueue defaultQueue] addPayment:payment];
    } else {
        self.statusLabel.text = @"未找到该商品信息";
    }
}

#pragma mark - SKPaymentTransactionObserver 代理方法

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                // 购买中
                self.statusLabel.text = @"购买处理中...";
                break;
            case SKPaymentTransactionStatePurchased:
                // 购买成功
                [self completeTransaction:transaction];
                break;
            case SKPaymentTransactionStateFailed:
                // 购买失败
                [self failedTransaction:transaction];
                break;
            case SKPaymentTransactionStateRestored:
                // 恢复购买
                [self restoreTransaction:transaction];
                break;
            case SKPaymentTransactionStateDeferred:
                // 购买延迟(儿童模式需要家长批准)
                self.statusLabel.text = @"购买等待批准...";
                break;
            default:
                break;
        }
    }
}

#pragma mark - 处理交易结果

- (void)completeTransaction:(SKPaymentTransaction *)transaction {
    // 获取购买的商品ID
    NSString *productID = transaction.payment.productIdentifier;

    // 这里可以添加解锁内容、保存购买记录等逻辑

    // 显示购买成功提示
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"购买成功" message:[NSString stringWithFormat:@"您已成功购买: %@", productID] preferredStyle:UIAlertControllerStyleAlert];
    [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
    [self presentViewController:alert animated:YES completion:nil];

    self.statusLabel.text = [NSString stringWithFormat:@"购买成功: %@", productID];

    // 完成交易,从队列中移除
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}

- (void)failedTransaction:(SKPaymentTransaction *)transaction {
    // 处理购买失败
    if (transaction.error.code != SKErrorPaymentCancelled) {
        NSLog(@"购买失败: %@", transaction.error.localizedDescription);

        UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"购买失败" message:transaction.error.localizedDescription preferredStyle:UIAlertControllerStyleAlert];
        [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
        [self presentViewController:alert animated:YES completion:nil];

        self.statusLabel.text = [NSString stringWithFormat:@"购买失败: %@", transaction.error.localizedDescription];
    } else {
        // 用户取消购买
        self.statusLabel.text = @"购买已取消";
    }

    // 完成交易,从队列中移除
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}

- (void)restoreTransaction:(SKPaymentTransaction *)transaction {
    // 获取恢复的商品ID
    NSString *productID = transaction.originalTransaction.payment.productIdentifier;

    // 这里可以添加解锁内容等逻辑

    // 显示恢复购买成功提示
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"恢复购买成功" message:[NSString stringWithFormat:@"您已成功恢复购买: %@", productID] preferredStyle:UIAlertControllerStyleAlert];
    [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
    [self presentViewController:alert animated:YES completion:nil];

    self.statusLabel.text = [NSString stringWithFormat:@"恢复购买成功: %@", productID];

    // 完成交易,从队列中移除
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}

#pragma mark - 恢复购买

- (IBAction)restorePurchases:(id)sender {
    if ([SKPaymentQueue canMakePayments]) {
        self.statusLabel.text = @"正在恢复购买...";
        [[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
    } else {
        self.statusLabel.text = @"当前设备不支持应用内购买";
    }
}

@end

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
    <device id="retina4_7" orientation="portrait" appearance="light"/>
    <dependencies>
        <deployment identifier="iOS"/>
        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21504"/>
        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
    </dependencies>
    <scenes>
        <!--View Controller-->
        <scene sceneID="tne-QT-ifu">
            <objects>
                <viewController id="BYZ-38-t0r" customClass="ViewController" customModule="iOSInAppPurchaseDemo" customModuleProvider="target" sceneMemberID="viewController">
                    <layoutGuides>
                        <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
                        <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
                    </layoutGuides>
                    <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                        <subviews>
                            <!-- 标题标签 -->
                            <label userLabel="标题" text="应用内购买演示" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="e1v-1X-jkq">
                                <rect key="frame" x="37.5" y="44" width="300" height="40"/>
                                <fontDescription key="fontDescription" type="system" pointSize="24" weight="medium"/>
                                <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <nil key="highlightedColor"/>
                            </label>

                            <!-- 状态标签 -->
                            <label userLabel="状态" text="加载中..." textAlignment="center" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="e2r-2Y-lkq">
                                <rect key="frame" x="37.5" y="94" width="300" height="40"/>
                                <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                <color key="textColor" red="0.5568627451" green="0.5568627451" blue="0.5764705882" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <nil key="highlightedColor"/>
                            </label>

                            <!-- 商品1 -->
                            <view userLabel="商品1容器" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="d1v-3X-mkq">
                                <rect key="frame" x="37.5" y="154" width="300" height="100"/>
                                <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                                <subviews>
                                    <label userLabel="商品1信息" text="商品信息加载中..." textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="f1v-4X-nkq">
                                        <rect key="frame" x="0.0" y="0.0" width="220" height="100"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                        <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <nil key="highlightedColor"/>
                                    </label>
                                    <button userLabel="购买按钮1" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="g1v-5X-okq">
                                        <rect key="frame" x="230" y="30" width="60" height="40"/>
                                        <autoresizingMask key="autoresizingMask"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14" weight="medium"/>
                                        <state key="normal" title="购买"/>
                                        <color key="backgroundColor" red="0.0" green="0.4784313725" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <color key="titleColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                    </button>
                                </subviews>
                                <color key="backgroundColor" red="0.9411764706" green="0.9411764706" blue="0.9411764706" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <constraints>
                                    <constraint firstItem="f1v-4X-nkq" firstAttribute="top" secondItem="d1v-3X-mkq" secondAttribute="top" constant="0.0" id="a1c-6X-pkq"/>
                                    <constraint firstAttribute="bottom" secondItem="f1v-4X-nkq" secondAttribute="bottom" constant="0.0" id="b1c-7X-qkq"/>
                                    <constraint firstItem="f1v-4X-nkq" firstAttribute="leading" secondItem="d1v-3X-mkq" secondAttribute="leading" constant="10" id="c1c-8X-rkq"/>
                                    <constraint firstItem="g1v-5X-okq" firstAttribute="trailing" secondItem="d1v-3X-mkq" secondAttribute="trailing" constant="-10" id="d1c-9X-skq"/>
                                    <constraint firstAttribute="bottom" secondItem="g1v-5X-okq" secondAttribute="bottom" constant="30" id="e1c-0Y-tkq"/>
                                    <constraint firstItem="g1v-5X-okq" firstAttribute="top" secondItem="d1v-3X-mkq" secondAttribute="top" constant="30" id="f1c-1Y-ukq"/>
                                    <constraint firstAttribute="trailing" secondItem="f1v-4X-nkq" secondAttribute="trailing" constant="70" id="g1c-2Y-vkq"/>
                                    <constraint firstItem="g1v-5X-okq" firstAttribute="leading" secondItem="f1v-4X-nkq" secondAttribute="trailing" constant="10" id="h1c-3Y-wkq"/>
                                </constraints>
                                <userLabel value="商品1容器"/>
                                <cornerRadius key="cornerRadius" value="8"/>
                            </view>

                            <!-- 商品2 -->
                            <view userLabel="商品2容器" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="h1v-4X-xkq">
                                <rect key="frame" x="37.5" y="264" width="300" height="100"/>
                                <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                                <subviews>
                                    <label userLabel="商品2信息" text="商品信息加载中..." textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i1v-5X-ykq">
                                        <rect key="frame" x="0.0" y="0.0" width="220" height="100"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                        <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <nil key="highlightedColor"/>
                                    </label>
                                    <button userLabel="购买按钮2" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="j1v-6X-zkq">
                                        <rect key="frame" x="230" y="30" width="60" height="40"/>
                                        <autoresizingMask key="autoresizingMask"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14" weight="medium"/>
                                        <state key="normal" title="购买"/>
                                        <color key="backgroundColor" red="0.0" green="0.4784313725" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <color key="titleColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                    </button>
                                </subviews>
                                <color key="backgroundColor" red="0.9411764706" green="0.9411764706" blue="0.9411764706" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <constraints>
                                    <constraint firstItem="i1v-5X-ykq" firstAttribute="top" secondItem="h1v-4X-xkq" secondAttribute="top" constant="0.0" id="a2c-7X-0kq"/>
                                    <constraint firstAttribute="bottom" secondItem="i1v-5X-ykq" secondAttribute="bottom" constant="0.0" id="b2c-8X-1kq"/>
                                    <constraint firstItem="i1v-5X-ykq" firstAttribute="leading" secondItem="h1v-4X-xkq" secondAttribute="leading" constant="10" id="c2c-9X-2kq"/>
                                    <constraint firstItem="j1v-6X-zkq" firstAttribute="trailing" secondItem="h1v-4X-xkq" secondAttribute="trailing" constant="-10" id="d2c-0Y-3kq"/>
                                    <constraint firstAttribute="bottom" secondItem="j1v-6X-zkq" secondAttribute="bottom" constant="30" id="e2c-1Y-4kq"/>
                                    <constraint firstItem="j1v-6X-zkq" firstAttribute="top" secondItem="h1v-4X-xkq" secondAttribute="top" constant="30" id="f2c-2Y-5kq"/>
                                    <constraint firstAttribute="trailing" secondItem="i1v-5X-ykq" secondAttribute="trailing" constant="70" id="g2c-3Y-6kq"/>
                                    <constraint firstItem="j1v-6X-zkq" firstAttribute="leading" secondItem="i1v-5X-ykq" secondAttribute="trailing" constant="10" id="h2c-4Y-7kq"/>
                                </constraints>
                                <userLabel value="商品2容器"/>
                                <cornerRadius key="cornerRadius" value="8"/>
                            </view>

                            <!-- 商品3 -->
                            <view userLabel="商品3容器" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="k1v-7X-8kq">
                                <rect key="frame" x="37.5" y="374" width="300" height="100"/>
                                <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                                <subviews>
                                    <label userLabel="商品3信息" text="商品信息加载中..." textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="l1v-8X-9kq">
                                        <rect key="frame" x="0.0" y="0.0" width="220" height="100"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14"/>
                                        <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <nil key="highlightedColor"/>
                                    </label>
                                    <button userLabel="购买按钮3" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="m1v-9X-akq">
                                        <rect key="frame" x="230" y="30" width="60" height="40"/>
                                        <autoresizingMask key="autoresizingMask"/>
                                        <fontDescription key="fontDescription" type="system" pointSize="14" weight="medium"/>
                                        <state key="normal" title="购买"/>
                                        <color key="backgroundColor" red="0.0" green="0.4784313725" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                        <color key="titleColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                    </button>
                                </subviews>
                                <color key="backgroundColor" red="0.9411764706" green="0.9411764706" blue="0.9411764706" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <constraints>
                                    <constraint firstItem="l1v-8X-9kq" firstAttribute="top" secondItem="k1v-7X-8kq" secondAttribute="top" constant="0.0" id="a3c-0Y-bkq"/>
                                    <constraint firstAttribute="bottom" secondItem="l1v-8X-9kq" secondAttribute="bottom" constant="0.0" id="b3c-1Y-ckq"/>
                                    <constraint firstItem="l1v-8X-9kq" firstAttribute="leading" secondItem="k1v-7X-8kq" secondAttribute="leading" constant="10" id="c3c-2Y-dkq"/>
                                    <constraint firstItem="m1v-9X-akq" firstAttribute="trailing" secondItem="k1v-7X-8kq" secondAttribute="trailing" constant="-10" id="d3c-3Y-ekq"/>
                                    <constraint firstAttribute="bottom" secondItem="m1v-9X-akq" secondAttribute="bottom" constant="30" id="e3c-4Y-fkq"/>
                                    <constraint firstItem="m1v-9X-akq" firstAttribute="top" secondItem="k1v-7X-8kq" secondAttribute="top" constant="30" id="f3c-5Y-gkq"/>
                                    <constraint firstAttribute="trailing" secondItem="l1v-8X-9kq" secondAttribute="trailing" constant="70" id="g3c-6Y-hkq"/>
                                    <constraint firstItem="m1v-9X-akq" firstAttribute="leading" secondItem="l1v-8X-9kq" secondAttribute="trailing" constant="10" id="h3c-7Y-ikq"/>
                                </constraints>
                                <userLabel value="商品3容器"/>
                                <cornerRadius key="cornerRadius" value="8"/>
                            </view>

                            <!-- 恢复购买按钮 -->
                            <button userLabel="恢复购买" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="n1v-8X-jkq">
                                <rect key="frame" x="37.5" y="494" width="300" height="40"/>
                                <autoresizingMask key="autoresizingMask"/>
                                <fontDescription key="fontDescription" type="system" pointSize="16" weight="medium"/>
                                <state key="normal" title="恢复购买"/>
                                <color key="backgroundColor" red="0.6862745098" green="0.6862745098" blue="0.6862745098" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <color key="titleColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                            </button>
                        </subviews>
                        <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                        <constraints>
                            <!-- 标题约束 -->
                            <constraint firstItem="e1v-1X-jkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="a4c-9X-kkq"/>
                            <constraint firstItem="e1v-1X-jkq" firstAttribute="top" secondItem="y3c-jy-aDJ" secondAttribute="bottom" constant="0.0" id="b4c-0Y-lkq"/>
                            <constraint firstAttribute="trailing" secondItem="e1v-1X-jkq" secondAttribute="trailing" constant="37.5" id="c4c-1Y-mkq"/>

                            <!-- 状态约束 -->
                            <constraint firstItem="e2r-2Y-lkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="d4c-2Y-nkq"/>
                            <constraint firstItem="e2r-2Y-lkq" firstAttribute="top" secondItem="e1v-1X-jkq" secondAttribute="bottom" constant="10" id="e4c-3Y-okq"/>
                            <constraint firstAttribute="trailing" secondItem="e2r-2Y-lkq" secondAttribute="trailing" constant="37.5" id="f4c-4Y-pkq"/>

                            <!-- 商品1约束 -->
                            <constraint firstItem="d1v-3X-mkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="g4c-5Y-qkq"/>
                            <constraint firstItem="d1v-3X-mkq" firstAttribute="top" secondItem="e2r-2Y-lkq" secondAttribute="bottom" constant="20" id="h4c-6Y-rkq"/>
                            <constraint firstAttribute="trailing" secondItem="d1v-3X-mkq" secondAttribute="trailing" constant="37.5" id="i4c-7Y-skq"/>
                            <constraint firstAttribute="width" secondItem="d1v-3X-mkq" secondAttribute="width" multiplier="1" constant="0.0" id="j4c-8Y-tkq"/>
                            <constraint firstItem="d1v-3X-mkq" firstAttribute="height" constant="100" id="k4c-9Y-ukq"/>

                            <!-- 商品2约束 -->
                            <constraint firstItem="h1v-4X-xkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="l4c-0Z-vkq"/>
                            <constraint firstItem="h1v-4X-xkq" firstAttribute="top" secondItem="d1v-3X-mkq" secondAttribute="bottom" constant="10" id="m4c-1Z-wkq"/>
                            <constraint firstAttribute="trailing" secondItem="h1v-4X-xkq" secondAttribute="trailing" constant="37.5" id="n4c-2Z-xkq"/>
                            <constraint firstItem="h1v-4X-xkq" firstAttribute="height" constant="100" id="o4c-3Z-ykq"/>

                            <!-- 商品3约束 -->
                            <constraint firstItem="k1v-7X-8kq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="p4c-4Z-zkq"/>
                            <constraint firstItem="k1v-7X-8kq" firstAttribute="top" secondItem="h1v-4X-xkq" secondAttribute="bottom" constant="10" id="q4c-5Z-0kq"/>
                            <constraint firstAttribute="trailing" secondItem="k1v-7X-8kq" secondAttribute="trailing" constant="37.5" id="r4c-6Z-1kq"/>
                            <constraint firstItem="k1v-7X-8kq" firstAttribute="height" constant="100" id="s4c-7Z-2kq"/>

                            <!-- 恢复购买按钮约束 -->
                            <constraint firstItem="n1v-8X-jkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="37.5" id="t4c-8Z-3kq"/>
                            <constraint firstItem="n1v-8X-jkq" firstAttribute="top" secondItem="k1v-7X-8kq" secondAttribute="bottom" constant="20" id="u4c-9Z-4kq"/>
                            <constraint firstAttribute="trailing" secondItem="n1v-8X-jkq" secondAttribute="trailing" constant="37.5" id="v4c-0a-5kq"/>
                            <constraint firstItem="n1v-8X-jkq" firstAttribute="height" constant="40" id="w4c-1a-6kq"/>
                        </constraints>
                    </view>
                    <connections>
                        <outlet property="product1Label" destination="f1v-4X-nkq" id="con-0a-7kq"/>
                        <outlet property="product2Label" destination="i1v-5X-ykq" id="con-1a-8kq"/>
                        <outlet property="product3Label" destination="l1v-8X-9kq" id="con-2a-9kq"/>
                        <outlet property="product1Button" destination="g1v-5X-okq" id="con-3a-akq"/>
                        <outlet property="product2Button" destination="j1v-6X-zkq" id="con-4a-bkq"/>
                        <outlet property="product3Button" destination="m1v-9X-akq" id="con-5a-ckq"/>
                        <outlet property="statusLabel" destination="e2r-2Y-lkq" id="con-6a-dkq"/>
                        <action selector="purchaseProduct1:" destination="BYZ-38-t0r" eventType="touchUpInside" id="con-7a-ekq">
                            <sender slot="source" id="g1v-5X-okq"/>
                        </action>
                        <action selector="purchaseProduct2:" destination="BYZ-38-t0r" eventType="touchUpInside" id="con-8a-fkq">
                            <sender slot="source" id="j1v-6X-zkq"/>
                        </action>
                        <action selector="purchaseProduct3:" destination="BYZ-38-t0r" eventType="touchUpInside" id="con-9a-gkq">
                            <sender slot="source" id="m1v-9X-akq"/>
                        </action>
                        <action selector="restorePurchases:" destination="BYZ-38-t0r" eventType="touchUpInside" id="con-0b-hkq">
                            <sender slot="source" id="n1v-8X-jkq"/>
                        </action>
                    </connections>
                </viewController>
                <placeholder placeholderIdentifier="IBFilesOwner" id="dkx-z0-nzr" sceneMemberID="filesOwner"/>
                <placeholder placeholderIdentifier="IBFirstResponder" id="26j-XQyn0c" userLabel="First Responder" sceneMemberID="firstResponder"/>
            </objects>
            <point key="canvasLocation" x="137.5" y="102.5"/>
        </scene>
    </scenes>
</document>

建立info.list创建Info.plist文件,配置应用程序的基本信息,包括应用名称、版本、Bundle Identifier等。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>$(DEVELOPMENT_LANGUAGE)</string>
    <key>CFBundleDisplayName</key>
    <string>iOSInAppPurchaseDemo</string>
    <key>CFBundleExecutable</key>
    <string>$(EXECUTABLE_NAME)</string>
    <key>CFBundleIdentifier</key>
    <string>Liu.GameTest</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>$(PRODUCT_NAME)</string>
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleVersion</key>
    <string>1</string>
    <key>LSRequiresIPhoneOS</key>
    <true/>
    <key>UILaunchStoryboardName</key>
    <string>LaunchScreen</string>
    <key>UIMainStoryboardFile</key>
    <string>Main</string>
    <key>UIRequiredDeviceCapabilities</key>
    <array>
        <string>armv7</string>
    </array>
    <key>UISupportedInterfaceOrientations</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
    <key>UISupportedInterfaceOrientations~ipad</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationPortraitUpsideDown</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsArbitraryLoads</key>
        <true/>
    </dict>
</dict>
</plist>

创建LaunchScreen.storyboard文件,作为应用程序的启动界面

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" launchScreen="YES">
    <device id="retina4_7" orientation="portrait" appearance="light"/>
    <dependencies>
        <deployment identifier="iOS"/>
        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21504"/>
        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
    </dependencies>
    <scenes>
        <!--View Controller-->
        <scene sceneID="tne-QT-ifu">
            <objects>
                <viewController id="BYZ-38-t0r" sceneMemberID="viewController">
                    <layoutGuides>
                        <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
                        <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
                    </layoutGuides>
                    <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                        <subviews>
                            <label userLabel="启动标题" text="iOS内购演示" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="e1v-1X-jkq">
                                <rect key="frame" x="75" y="276.5" width="225" height="40"/>
                                <fontDescription key="fontDescription" type="system" pointSize="30" weight="medium"/>
                                <color key="textColor" red="0.1294117647" green="0.1294117647" blue="0.1294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <nil key="highlightedColor"/>
                            </label>
                            <label userLabel="启动副标题" text="Loading..." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="e2v-2X-lkq">
                                <rect key="frame" x="75" y="326.5" width="225" height="20"/>
                                <fontDescription key="fontDescription" type="system" pointSize="16"/>
                                <color key="textColor" red="0.5568627451" green="0.5568627451" blue="0.5764705882" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                                <nil key="highlightedColor"/>
                            </label>
                        </subviews>
                        <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                        <constraints>
                            <constraint firstItem="e1v-1X-jkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="75" id="a1c-3X-mkq"/>
                            <constraint firstItem="e1v-1X-jkq" firstAttribute="centerY" secondItem="8bC-Xf-vdC" secondAttribute="centerY" constant="-25" id="b1c-4X-nkq"/>
                            <constraint firstAttribute="trailing" secondItem="e1v-1X-jkq" secondAttribute="trailing" constant="75" id="c1c-5X-okq"/>
                            <constraint firstItem="e2v-2X-lkq" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" constant="75" id="d1c-6X-pkq"/>
                            <constraint firstItem="e2v-2X-lkq" firstAttribute="top" secondItem="e1v-1X-jkq" secondAttribute="bottom" constant="10" id="e1c-7X-qkq"/>
                            <constraint firstAttribute="trailing" secondItem="e2v-2X-lkq" secondAttribute="trailing" constant="75" id="f1c-8X-rkq"/>
                        </constraints>
                    </view>
                </viewController>
                <placeholder placeholderIdentifier="IBFilesOwner" id="dkx-z0-nzr" sceneMemberID="filesOwner"/>
                <placeholder placeholderIdentifier="IBFirstResponder" id="26j-XQyn0c" userLabel="First Responder" sceneMemberID="firstResponder"/>
            </objects>
            <point key="canvasLocation" x="137.5" y="102.5"/>
        </scene>
    </scenes>
</document>

aunchScreen.storyboard已创建完成。现在需要创建Xcode项目配置文件project.pbxproj,这是项目的核心配置文件,定义了项目结构和构建设置。

// !$*UTF8*$!
{
    archiveVersion = 1;
    classes = {
    };
    objectVersion = 46;
    objects = {
        /* Begin PBXBuildFile section */
        19786D9D1E8E4F5A00D94E8C /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 19786D9C1E8E4F5A00D94E8C /* AppDelegate.m */; };
        19786D9F1E8E4F5A00D94E8C /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 19786D9E1E8E4F5A00D94E8C /* ViewController.m */; };
        19786DA31E8E4F5A00D94E8C /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 19786DA21E8E4F5A00D94E8C /* Main.storyboard */; };
        19786DA51E8E4F5A00D94E8C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 19786DA41E8E4F5A00D94E8C /* LaunchScreen.storyboard */; };
        19786DA71E8E4F5A00D94E8C /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 19786DA61E8E4F5A00D94E8C /* Info.plist */; };
        /* End PBXBuildFile section */

        /* Begin PBXFileReference section */
        19786D9A1E8E4F5A00D94E8C /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = iOSInAppPurchaseDemo/AppDelegate.h; sourceTree = "<group>"; };
        19786D9C1E8E4F5A00D94E8C /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = iOSInAppPurchaseDemo/AppDelegate.m; sourceTree = "<group>"; };
        19786D9D1E8E4F5A00D94E8C /* ViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = iOSInAppPurchaseDemo/ViewController.h; sourceTree = "<group>"; };
        19786D9E1E8E4F5A00D94E8C /* ViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = iOSInAppPurchaseDemo/ViewController.m; sourceTree = "<group>"; };
        19786DA21E8E4F5A00D94E8C /* Main.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = iOSInAppPurchaseDemo/Base.lproj/Main.storyboard; sourceTree = "<group>"; };
        19786DA41E8E4F5A00D94E8C /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = iOSInAppPurchaseDemo/Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
        19786DA61E8E4F5A00D94E8C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = iOSInAppPurchaseDemo/Info.plist; sourceTree = "<group>"; };
        19786DA91E8E4F5A00D94E8C /* iOSInAppPurchaseDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOSInAppPurchaseDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
        19786DAA1E8E4F5A00D94E8C /* SubscriptionKey_Q9W29CG749.p8 */ = {isa = PBXFileReference; lastKnownFileType = text; path = SubscriptionKey_Q9W29CG749.p8; sourceTree = "<group>"; };
        /* End PBXFileReference section */

        /* Begin PBXGroup section */
        19786D931E8E4F5A00D94E8C = {isa = PBXGroup; children = (
            19786D9A1E8E4F5A00D94E8C /* AppDelegate.h */,
            19786D9C1E8E4F5A00D94E8C /* AppDelegate.m */,
            19786D9D1E8E4F5A00D94E8C /* ViewController.h */,
            19786D9E1E8E4F5A00D94E8C /* ViewController.m */,
            19786DA01E8E4F5A00D94E8C /* Base.lproj */,
            19786DA61E8E4F5A00D94E8C /* Info.plist */,
        ); path = iOSInAppPurchaseDemo; sourceTree = "<group>"; };
        19786DA01E8E4F5A00D94E8C /* Base.lproj */ = {isa = PBXGroup; children = (
            19786DA21E8E4F5A00D94E8C /* Main.storyboard */,
            19786DA41E8E4F5A00D94E8C /* LaunchScreen.storyboard */,
        ); path = Base.lproj; sourceTree = "<group>"; };
        19786D921E8E4F5A00D94E8C = {isa = PBXGroup; children = (
            19786D931E8E4F5A00D94E8C /* iOSInAppPurchaseDemo */,
            19786DA91E8E4F5A00D94E8C /* Products */,
        ); sourceTree = "<group>"; };
        19786DA91E8E4F5A00D94E8C = {isa = PBXGroup; children = (
            19786DA91E8E4F5A00D94E8C /* iOSInAppPurchaseDemo.app */,
        ); name = Products; sourceTree = "<group>"; };
        /* End PBXGroup section */

        /* Begin PBXNativeTarget section */
        19786D991E8E4F5A00D94E8C = {isa = PBXNativeTarget; buildConfigurationList = 19786DAE1E8E4F5A00D94E8C /* Build configuration list for PBXNativeTarget "iOSInAppPurchaseDemo" */; buildPhases = (
            19786D951E8E4F5A00D94E8C /* Sources */,
            19786D961E8E4F5A00D94E8C /* Resources */,
            19786D971E8E4F5A00D94E8C /* Frameworks */,
        ); buildRules = (
        ); dependencies = (
        ); name = iOSInAppPurchaseDemo; productName = iOSInAppPurchaseDemo; productReference = 19786DA91E8E4F5A00D94E8C /* iOSInAppPurchaseDemo.app */; productType = "com.apple.product-type.application"; };
        /* End PBXNativeTarget section */

        /* Begin PBXProject section */
        19786D911E8E4F5A00D94E8C = {isa = PBXProject; attributes = {
            LastSwiftUpdateCheck = 0900;
            LastUpgradeCheck = 0900;
            ORGANIZATIONNAME = "Your Organization";
            targetAttributes = {
                19786D991E8E4F5A00D94E8C = {
                    CreatedOnToolsVersion = 9.0;
                    LastSwiftMigration = 0900;
                };
            };
        };
            buildConfigurationList = 19786D901E8E4F5A00D94E8C /* Build configuration list for PBXProject "iOSInAppPurchaseDemo" */;
            compatibilityVersion = "Xcode 3.2";
            developmentRegion = "zh_CN";
            hasScannedForEncodings = 0;
            mainGroup = 19786D921E8E4F5A00D94E8C;
            productRefGroup = 19786DA91E8E4F5A00D94E8C /* Products */;
            projectDirPath = "";
            projectRoot = "";
            targets = (
                19786D991E8E4F5A00D94E8C /* iOSInAppPurchaseDemo */,
            );
        };
        /* End PBXProject section */

        /* Begin PBXResourcesBuildPhase section */
        19786D961E8E4F5A00D94E8C /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647;
            files = (
                19786DA31E8E4F5A00D94E8C /* Main.storyboard in Resources */,
                19786DA51E8E4F5A00D94E8C /* LaunchScreen.storyboard in Resources */,
                19786DA71E8E4F5A00D94E8C /* Info.plist in Resources */,
            );
            runOnlyForDeploymentPostprocessing = 0;
        };
        /* End PBXResourcesBuildPhase section */

        /* Begin PBXSourcesBuildPhase section */
        19786D951E8E4F5A00D94E8C /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647;
            files = (
                19786D9D1E8E4F5A00D94E8C /* AppDelegate.m in Sources */,
                19786D9F1E8E4F5A00D94E8C /* ViewController.m in Sources */,
            );
            runOnlyForDeploymentPostprocessing = 0;
        };
        /* End PBXSourcesBuildPhase section */

        /* Begin PBXFrameworksBuildPhase section */
        19786D971E8E4F5A00D94E8C /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647;
            files = (
                19786DB01E8E4F5A00D94E8C /* StoreKit.framework in Frameworks */,
            );
            runOnlyForDeploymentPostprocessing = 0;
        };
        /* End PBXFrameworksBuildPhase section */

        /* Begin PBXBuildFile for Frameworks */
        19786DB01E8E4F5A00D94E8C /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 19786DB11E8E4F5A00D94E8C /* StoreKit.framework */; };
        /* End PBXBuildFile for Frameworks */

        /* Begin PBXFileReference for Frameworks */
        19786DB11E8E4F5A00D94E8C /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
        /* End PBXFileReference for Frameworks */

        /* Begin PBXBuildConfiguration section */
        19786D9B1E8E4F5A00D94E8C /* Debug */ = {isa = PBXBuildConfiguration; buildSettings = {
            ALWAYS_SEARCH_USER_PATHS = NO;
            CLANG_ANALYZER_NONNULL = YES;
            CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
            CLANG_CXX_LIBRARY = "libc++";
            CLANG_ENABLE_MODULES = YES;
            CLANG_ENABLE_OBJC_ARC = YES;
            CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
            CLANG_WARN_BOOL_CONVERSION = YES;
            CLANG_WARN_COMMA = YES;
            CLANG_WARN_CONSTANT_CONVERSION = YES;
            CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
            CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES;
            CLANG_WARN_EMPTY_BODY = YES;
            CLANG_WARN_ENUM_CONVERSION = YES;
            CLANG_WARN_INFINITE_RECURSION = YES;
            CLANG_WARN_INT_CONVERSION = YES;
            CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
            CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
            CLANG_WARN_OBJC_ROOT_CLASS = YES;
            CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
            CLANG_WARN_STRICT_PROTOTYPES = YES;
            CLANG_WARN_SUSPICIOUS_MOVE = YES;
            CLANG_WARN_UNREACHABLE_CODE = YES;
            CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
            COPY_PHASE_STRIP = NO;
            DEBUG_INFORMATION_FORMAT = dwarf;
            ENABLE_STRICT_OBJC_MSGSEND = YES;
            ENABLE_TESTABILITY = YES;
            GCC_C_LANGUAGE_STANDARD = gnu99;
            GCC_DYNAMIC_NO_PIC = NO;
            GCC_NO_COMMON_BLOCKS = YES;
            GCC_OPTIMIZATION_LEVEL = 0;
            GCC_PRECOMPILE_PREFIX_HEADER = NO;
            GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
            GCC_WARN_ABOUT_RETURN_TYPE = YES;
            GCC_WARN_UNDECLARED_SELECTOR = YES;
            GCC_WARN_UNINITIALIZED_AUTOS = YES;
            GCC_WARN_UNUSED_FUNCTION = YES;
            GCC_WARN_UNUSED_VARIABLE = YES;
            IPHONEOS_DEPLOYMENT_TARGET = 12.0;
            MTL_ENABLE_DEBUG_INFO = YES;
            MTL_FAST_MATH = YES;
            ONLY_ACTIVE_ARCH = YES;
            SDKROOT = iphoneos;
            TARGETED_DEVICE_FAMILY = "1,2";
        };
            name = Debug;
        };
        19786D9C1E8E4F5A00D94E8C /* Release */ = {isa = PBXBuildConfiguration; buildSettings = {
            ALWAYS_SEARCH_USER_PATHS = NO;
            CLANG_ANALYZER_NONNULL = YES;
            CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
            CLANG_CXX_LIBRARY = "libc++";
            CLANG_ENABLE_MODULES = YES;
            CLANG_ENABLE_OBJC_ARC = YES;
            CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
            CLANG_WARN_BOOL_CONVERSION = YES;
            CLANG_WARN_COMMA = YES;
            CLANG_WARN_CONSTANT_CONVERSION = YES;
            CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
            CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES;
            CLANG_WARN_EMPTY_BODY = YES;
            CLANG_WARN_ENUM_CONVERSION = YES;
            CLANG_WARN_INFINITE_RECURSION = YES;
            CLANG_WARN_INT_CONVERSION = YES;
            CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
            CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
            CLANG_WARN_OBJC_ROOT_CLASS = YES;
            CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
            CLANG_WARN_STRICT_PROTOTYPES = YES;
            CLANG_WARN_SUSPICIOUS_MOVE = YES;
            CLANG_WARN_UNREACHABLE_CODE = YES;
            CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
            COPY_PHASE_STRIP = YES;
            DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
            ENABLE_NS_ASSERTIONS = NO;
            ENABLE_STRICT_OBJC_MSGSEND = YES;
            GCC_C_LANGUAGE_STANDARD = gnu99;
            GCC_NO_COMMON_BLOCKS = YES;
            GCC_PRECOMPILE_PREFIX_HEADER = NO;
            GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
            GCC_WARN_ABOUT_RETURN_TYPE = YES;
            GCC_WARN_UNDECLARED_SELECTOR = YES;
            GCC_WARN_UNINITIALIZED_AUTOS = YES;
            GCC_WARN_UNUSED_FUNCTION = YES;
            GCC_WARN_UNUSED_VARIABLE = YES;
            IPHONEOS_DEPLOYMENT_TARGET = 12.0;
            MTL_ENABLE_DEBUG_INFO = NO;
            MTL_FAST_MATH = YES;
            SDKROOT = iphoneos;
            TARGETED_DEVICE_FAMILY = "1,2";
        };
            name = Release;
        };
        19786DAF1E8E4F5A00D94E8C /* Debug */ = {isa = PBXBuildConfiguration; buildSettings = {
            ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
            CLANG_ENABLE_MODULES = YES;
            INFOPLIST_FILE = "iOSInAppPurchaseDemo/Info.plist";
            IPHONEOS_DEPLOYMENT_TARGET = 12.0;
            PRODUCT_BUNDLE_IDENTIFIER = Liu.GameTest;
            PRODUCT_NAME = iOSInAppPurchaseDemo;
            TARGETED_DEVICE_FAMILY = "1,2";
        };
            name = Debug;
        };
        19786DB01E8E4F5A00D94E8C /* Release */ = {isa = PBXBuildConfiguration; buildSettings = {
            ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
            CLANG_ENABLE_MODULES = YES;
            INFOPLIST_FILE = "iOSInAppPurchaseDemo/Info.plist";
            IPHONEOS_DEPLOYMENT_TARGET = 12.0;
            PRODUCT_BUNDLE_IDENTIFIER = Liu.GameTest;
            PRODUCT_NAME = iOSInAppPurchaseDemo;
            TARGETED_DEVICE_FAMILY = "1,2";
        };
            name = Release;
        };
        /* End PBXBuildConfiguration section */

        /* Begin XCConfigurationList section */
        19786D901E8E4F5A00D94E8C /* Build configuration list for PBXProject "iOSInAppPurchaseDemo" */ = {isa = XCConfigurationList; buildConfigurations = (
            19786D9B1E8E4F5A00D94E8C /* Debug */,
            19786D9C1E8E4F5A00D94E8C /* Release */,
        ); defaultConfigurationIsVisible = 0;
            defaultConfigurationName = Release;
        };
        19786DAE1E8E4F5A00D94E8C /* Build configuration list for PBXNativeTarget "iOSInAppPurchaseDemo" */ = {isa = XCConfigurationList; buildConfigurations = (
            19786DAF1E8E4F5A00D94E8C /* Debug */,
            19786DB01E8E4F5A00D94E8C /* Release */,
        ); defaultConfigurationIsVisible = 0;
            defaultConfigurationName = Release;
        };
        /* End XCConfigurationList section */
    };
    rootObject = 19786D911E8E4F5A00D94E8C;
}

核心内容参考已经 写完,更多内容可以参考 开源出来的 文件

整个demo实例:

iOS 内购开发 (In-App Purchase) Objective-C Storyboard 实例

项目介绍

这是一个基于 Objective-C 和 Storyboard 的 iOS 内购开发示例项目,展示了如何在 iOS 应用中实现苹果应用内购买功能。

功能特性

  • 支持多个内购商品的展示和购买
  • 实现商品信息请求与展示
  • 完整的购买流程处理
  • 恢复已购买商品功能
  • 错误处理和状态反馈

技术架构

  • 开发语言: Objective-C
  • 界面设计: Storyboard
  • 内购框架: StoreKit.framework
  • 最低支持系统: iOS 12.0
  • Bundle Identifier: Liu.GameTest

项目结构

iOSInAppPurchaseDemo/
├── AppDelegate.h           # 应用程序委托头文件
├── AppDelegate.m           # 应用程序委托实现
├── ViewController.h        # 主视图控制器头文件
├── ViewController.m        # 主视图控制器实现(包含内购核心逻辑)
├── Info.plist              # 应用配置文件
└── Base.lproj/
    ├── Main.storyboard     # 主界面故事板
    └── LaunchScreen.storyboard # 启动界面故事板

内购商品配置

项目中配置了三个测试商品ID:

  • 001Liu
  • 002Liu
  • 003Liu

安装与运行

  1. 克隆或下载本项目
  2. 打开 iOSInAppPurchaseDemo.xcodeproj 文件
  3. 确保已在 Xcode 中配置开发者账号
  4. 在真机或模拟器上运行项目

注意事项

  1. 内购功能需要在真机上进行完整测试
  2. 在 App Store Connect 中配置相应的内购项目才能进行真实购买
  3. 项目包含测试用的 SubscriptionKey_Q9W29CG749.p8 文件
  4. 实际项目中请替换为自己的密钥文件和 Bundle Identifier

使用说明

  1. 启动应用后,系统会自动请求商品信息
  2. 点击对应商品的购买按钮进行购买
  3. 输入 Apple ID 密码完成支付
  4. 购买成功后会显示成功提示
  5. 可以点击”恢复购买”按钮恢复已购买的商品

开发提示

  • 确保在 Info.plist 中正确配置了应用的 Bundle Identifier
  • 在 App Store Connect 中创建相应的应用和内购项目
  • 测试内购时使用沙盒测试账号
  • 处理各种交易状态和错误情况

License

© 2025 优雅草科技. All rights reserved.

❌
❌