普通视图

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

Xcode 高效秘诀:这 11 个快捷键你必须知道!

作者 iOS新知
2025年6月26日 20:02

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

前言

作为一个 iOS 开发者,在使用 Xcode 时,掌握键盘快捷键是提高生产力和效率的关键。在这篇文章中,我将为大家介绍一些我最喜欢的 Xcode 快捷键。

其实之前也写过一些相关的文章,感兴趣的也可以去看看:

快捷键速查表

在开始之前,先来一个快捷键速查表:

  • ⌘ - Command

  • ⇧ - Shift

  • ⌥ - Option/Alt

  • ⌃ - Control

现在,让我们一起深入了解吧。

1. 产品菜单快捷键

首先,让我们从 Xcode 的产品菜单中一些基础快捷键开始:

  • 运行:⌘ R

  • 测试:⌘ U

  • 清理构建文件夹:⇧ ⌘ K

  • 清理 DerivedData 文件夹:⌘ ⇧ K

  • 停止:⌘ .

如果你已经是一个 iOS 开发者了,相信这些快捷键你已经很熟悉了,下面我们再介绍一些更高级的快捷键。

2. 快速导航

在处理不同文件,或者跟踪调用栈来解决问题时,快速导航是节省时间的法宝:

  • 前进:⌃ ⌘ →

  • 后退:⌃ ⌘ ←

3. 快速打开与跳转定义

另一个重要的快捷键是快速打开文件,使用 ⇧ ⌘ O,这个快捷键不仅可以搜索文件,还可以搜索类名和方法。

image.png

使用此快捷键后,跳转到定义,然后如果你想知道当前方法的所在的文件,可以使用 ⌃ ⌘ J 快捷键。

4. 查找功能

无论是在当前打开的文件中,还是在整个项目/工作区内,查找都是必不可少的操作:

  • 当前文件查找:⌘ F

  • 当前文件查找并替换:⌥ ⌘ F

  • 全局查找:⇧ ⌘ F

5. 视图管理

能够快速显示和隐藏不同的 Xcode 区域特别有用,尤其是在较小屏幕上工作时:

  • 显示/隐藏项目导航器:⌘ 0

  • 显示/隐藏调试控制台:⌘ ⇧ Y

  • 显示/隐藏检查器:⌘ ⌥ 0

6. 快速打开两个文件并排显示

我之前提到过的快速打开命令 ⌘ ⇧ O,在结合使用 ⌥ 时更加强大。

  • 使用 ⌘ ⇧ O 打开对话框

  • 输入你要查找的文件名

  • 按住 ⌥ 键然后用鼠标单击目标项目,或者使用 enter 键选择文件。新文件将会在一个单独的编辑器中打开,这样你可以继续在当前文件上工作,同时访问新打开的文件。

这个 ⌥ 技巧在项目导航器中选择文件时也同样适用。

7. 快速跳转到文件中的特定方法

对于大型文件中有众多方法的情况,这个快捷键非常有用,因为滚动很快会变得繁琐。

  • 使用 ⌃ 6 打开文档结构

  • 开始输入方法名

  • 使用 enter 键跳转到该方法

image.png

在输入时还可以模糊匹配,Xcode 会为你完成搜索。

8. 重复上一次测试

如果你经常写单元测试,快捷键 ⌃ ⌘ ⌥ G 在特别有用,可以重复运行我们上次进行的测试。

9. 重新缩进代码

我们可以通过按 ⌃ I 来重新缩进选定的代码,这在代码位置混乱时(例如在重构之后)特别有用。

我一般会配合全选快捷键 ⌘ A 一起使用,先全选再重新缩进,这样就可以将整个文件的代码进行重新缩进了。

10. 启用/禁用断点

在调试代码时,快捷键 ⌘ Y 可以帮助我们快速启用和禁用断点。

11. 不编译运行

这个快捷键非常有用,但可能很多人不知道。

在开发过程中,我们通常使用 ⌘ R 来运行代码,但这个命令其实是先编译再运行,但有时候我们并不需要编译,比如我刚执行完 ⌘ B,或者刚刚运行完没改代码的情况下想再运行一次。

这时候就可以使用 ⌃ ⌘ R 快捷键,直接运行,不用重新编译,非常节省时间。

总结

通过掌握这些快捷键,你可以大大提高在 Xcode 中的工作效率,节省宝贵的时间,让开发过程更加顺畅。希望这些快捷键能为你的开发旅程带来更多便利,你还有哪些喜欢的快捷键,欢迎在评论区留言分享。

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

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

包瘦身之未引用图片资源扫描工具

2025年6月26日 18:33

未引用图片资源扫描工具

该工具用于扫描指定项目目录中的图片资源,并检测这些图片是否在代码中被引用,帮助开发者清理未使用的图片资源,节省项目体积和维护成本。


功能概述

  • 扫描图片资源
    支持扫描普通图片文件(.png.jpg.jpeg.gif.pdf)以及 .imageset 目录中的图片资源。
  • 扫描代码中的图片引用
    支持扫描 Objective-C 和 Swift 代码文件(.swift.m.mm.xib.storyboard),通过常见的图片引用方式(如 imageNamed:UIImage(named:)setImage:forState: 等)提取图片名。
  • 生成未引用图片列表
    将未被代码引用的图片路径写入输出文件 unused_images.txt,供开发者进一步确认和处理。

使用说明

1. 配置参数

  • PROJECT_PATH:项目路径,默认为当前目录下的 ./TestUnUserRes,请根据实际项目路径修改。
  • OUTPUT_FILE:输出文件名,默认为 unused_images.txt
  • IMAGE_EXTENSIONS:支持的图片文件后缀列表。
  • CODE_EXTENSIONS:支持扫描的代码文件后缀列表。

2. 扫描流程

  • 扫描图片资源
    遍历项目目录,收集所有符合后缀的图片文件和 .imageset 目录,图片名统一小写且不包含扩展名。
  • 扫描代码引用
    遍历代码文件,使用正则表达式匹配常见的图片引用方式,提取引用的图片名(去除路径和扩展名,统一小写)。
  • 对比并输出
    找出图片资源中未被代码引用的图片,将其完整路径写入输出文件。

3. 输出文件格式

  • 输出文件开头有提示文字:

    未被引用的图片:⚠️⚠️⚠️需要二次确认
    
  • 后续列出所有未被引用的 .png 格式图片的完整路径。


代码模块说明

find_images(project_path)

  • 输入:项目根路径
  • 输出:字典,键为图片名(不含扩展名,统一小写),值为对应图片文件或 .imageset 目录的完整路径列表
  • 功能:遍历目录收集所有图片资源

find_image_references(project_path)

  • 输入:项目根路径
  • 输出:集合,包含代码中引用的所有图片名(不含扩展名,统一小写)
  • 功能:遍历代码文件,使用正则表达式匹配图片引用

main()

  • 执行扫描流程,打印扫描结果,并将未引用图片写入输出文件。

注意事项

  • 仅支持部分常见图片引用方式,可能存在漏判或误判情况,输出结果需二次确认。
  • 只输出未引用的 .png 图片路径,其他格式未引用图片未写入文件。
  • 读取文件时默认使用 UTF-8 编码,若项目中有其他编码文件可能导致读取异常。
  • .imageset 目录视为一组图片资源,统一以目录名作为图片名处理。
  • 运行脚本前请确保 Python 环境已正确安装。

示例输出

未被引用的图片:⚠️⚠️⚠️需要二次确认
图片名称: ./TestUnUserRes/Assets.xcassets/Scaner/scaner_flashlight_on.imageset/scaner_flashlight_on@3x.png
图片名称: ./TestUnUserRes/Assets.xcassets/Scaner/nav_back_whiteArrow.imageset/nav_back_whiteArrow@3x.png
...

使用实例

见github上的demo

扩展建议

  • 增加对更多图片引用方式的支持,提高准确率。
  • 支持输出所有未引用图片格式,不限 .png
  • 支持多语言编码文件读取。
  • 增加命令行参数支持,方便自定义扫描路径和输出文件名。

如有疑问或需求,欢迎反馈和改进!# TestUnUserRes

昨天 — 2025年6月26日iOS

Swift 协议之 Equatable

2025年6月26日 18:27

在 Swift 中,Equatable 是一个非常常见的协议。它的作用是判断两个值是否相等,是 Swift 中比较两个值最直接、最常见的方式。

如果我们需要判断两个字符串是否相等,通常会用下面的方式来实现:

let str1 = "Swift"
let str2 = "Swift"

let result = (str1 == str2)

那为什么字符串类型可以直接使用 == 操作符呢?答案就是因为系统已经给字符串类型实现了 Equatable 协议。同样的还有 IntArray 等系统类型都默认实现了该协议。

下面,我们先来看一下 Equatable 都包含什么内容。

Equatable 接口

Equatable 是一个标准库协议,它定义了一个基本的接口:

protocol Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool
}

从上面的源码可以看到,这个协议只要求实现一个方法:==

接下来,我们看一下什么场景下需要使用到这个协议。

Equatable 的使用场景

判断两个值是否相等,在日常开发中是非常常见的操作,比如以下的场景

  • 比较两个结构体是否代表同一个实体;
  • 判断数组中是否包含某个元素;
  • 在集合(如 Set)中去重;
  • 在 UI 中判断状态是否变化,是否需要刷新;

虽然,在 Swift 的标准库中,很多系统类型都遵守了 Equatable。但我们开发中不可能只使用标准库提供的类型,很多情况下,我们需要自定义类型。那么,如何让自定义的类型也能使用 == 操作符呢?

class Person {
    let name: String
    let id: Int
    init(name: String, id: Int) {
        self.name = name
        self.id = id
    }
}

let jack = Person(name: "jack", id: 123)
let rose = Person(name: "rose", id: 234)
print(jack == rose) // 编译报错 Binary operator '==' cannot be applied to two 'Person' operands

比如,上面我们自定义了一个 Person 的类,并且构建了两个实例对象,如果直接对两个对象使用 == 操作符,会导致上面的编译报错。

如何让自定义类型遵守 Equatable

对与我们自定义的类型,有两种方式可以遵守 Equatable。

方式一:自动合成

如果我们定义的结构体或枚举的所有成员都已经是遵守 Equatable 的类型,Swift 会自动帮你合成 == 实现。只要显式声明 Equatable,就能直接使用。比如我们上面举例的代码,我们只需要改动两个地方就可以让其遵守 Equatable 协议:

  • class 改为 struct;
  • 在 Person 后面显式的写出 Equatable 协议;

代码如下:

struct Person: Equatable {
    let name: String
    let id: Int
    init(name: String, id: Int) {
        self.name = name
        self.id = id
    }
}

let jack = Person(name: "jack", id: 123)
let rose = Person(name: "rose", id: 234)
print(jack == rose) 

需要注意的是:自动合成 == 只在以下条件下成立:

  • 所有属性都遵守 Equatable
  • 没有提供自定义的 == 实现;
  • 类型是结构体或者枚举;

如果我们想用类的话,或者想自定义比较逻辑的话。只能只用第二种方式:手动实现 Equatable 协议的方法。

方式二:手动实现

手动实现的示例代码如下:

extension Person: Equatable {
    static func == (lhs: Person, rhs: Person) -> Bool {
        lhs.id == rhs.id
    }
}

其余代码保持不变,我们只需用给 Person 添加一个扩展,并在扩展中实现 == 函数即可。这种方式更加灵活,因为我们可以在函数体里面自定义我们的比较逻辑。

Equatable 与泛型

当我们声明泛型函数的时候,可以给参数添加 Equatable 限制,以便进行参数比较,这样也可以更好的提高代码的健壮性。示例代码如下:

func areEqual<T: Equatable>(_ a: T, _ b: T) -> Bool {
    return a == b
}

print(areEqual(3, 3)) // true
print(areEqual("hi", "hello")) // false

let jack = Person(name: "jack", id: 123)
let rose = Person(name: "rose", id: 234)
print(areEqual(jack, rose)) // 如果 Person 没有遵守 Equatable协议的话,这一行会编译报错。

iOS26适配指南之Update Properties

作者 YungFan
2025年6月26日 18:00

介绍

  • UIViewController 与 UIView 均增加了一个名为updateProperties()的新方法,可以通过修改属性值达到更新 UI 的效果。
  • 它是一种轻量级的 UI 更新方式,不会触发完整的布局过程(不会触发layoutSubviews()或者viewWillLayoutSubviews()方法)。常见使用场景如下。
    • 更改 UI 的内容。
    • 显示/隐藏 UI。
    • 无需移动或者调整 UI 的大小。
  • 可以自动追踪 @Observable Object。
  • 可以通过调用setNeedsUpdateProperties()方法手动触发更新。

自动追踪

案例

import UIKit

@Observable class Model {
    var currentColor: UIColor = .systemGray
    var currentValue: String = "WWDC26"
}

class ViewController: UIViewController {
    lazy var label: UILabel = {
        let label = UILabel()
        label.frame = CGRect(x: 0, y: 0, width: 300, height: 60)
        label.textAlignment = .center
        label.font = UIFont.boldSystemFont(ofSize: 64)
        label.center = view.center
        return label
    }()
    let model = Model()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(label)
    }

    override func updateProperties() {
        super.updateProperties()

        label.textColor = model.currentColor
        label.text = model.currentValue
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        model.currentColor = .systemBlue
        model.currentValue = "iOS26"
    }
}

效果

自动追踪.gif

手动更新

案例

import UIKit

class Model {
    var currentColor: UIColor = .systemBlue
    var currentValue: String = "WWDC26"
}

class ViewController: UIViewController {
    lazy var label: UILabel = {
        let label = UILabel()
        label.frame = CGRect(x: 0, y: 0, width: 300, height: 60)
        label.textAlignment = .center
        label.font = UIFont.boldSystemFont(ofSize: 64)
        label.center = view.center
        return label
    }()
    let model = Model()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(label)
    }

    override func updateProperties() {
        super.updateProperties()

        label.textColor = model.currentColor
        label.text = model.currentValue
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        model.currentColor = .systemGray
        model.currentValue = "iOS26"
        // 手动更新
        setNeedsUpdateProperties()
    }
}

效果

手动更新

一行命令生成xcode自定义模板工程

作者 iOS日常
2025年6月26日 12:25

工作中经常会创建一些Demo研究新特性、测试一些功能等,每次都要创建新工程写一些模板代码,很浪费时间,本文教你如何利用xcodegen生成自定义模板项目

xcodegen 简介

xcodegen是一个可以生成.xcodgeproj的命令行工具

xcodegen安装

利用Homebrew安装:

brew install xcodegen

yml文件

xcodegen根据yml配置生成.xcodeproj文件

name: MyApp  // 会生成名为:MyApp.xcodeproj的文件
options:
  bundleIdPrefix: com.example 
packages:   
  SnapKit:
    url: https://github.com/SnapKit/SnapKit.git
    from: 5.7.1
targets:
  MyApp:
    type: application
    platform: iOS
    sources:
      - path: Source  // 资源、源码等文件的路径
    info:
      path: Source/Info.plist
      properties:
        UILaunchStoryboardName: LaunchScreen
        UIMainStoryboardFile: Main
        UIApplicationSceneManifest:
          UIApplicationSupportsMultipleScenes: false
          UISceneConfigurations:
            UIWindowSceneSessionRoleApplication:
              - UISceneConfigurationName: "Default Configuration"
                UISceneDelegateClassName: "\$(PRODUCT_MODULE_NAME).SceneDelegate"
                UISceneStoryboardFile: "Main"

将上面配置保存为project.yml文件,把模板文件夹Source放到与yml同级目录里

文件夹名称Source就是yml配置的名称,如下图所示:

注意:xcodegen只能生成.xcodeproj文件,源码、资源等文件不会自动生成,只能通过yml中配置路径的方式引入

生成MyApp.xcodeproj文件

终端cd到project.yml所在的目录,然后执行

xcodegen generate

会生成一个名为MyApp.xcodeproj的文件,点击打开就可以运行了

一行命令生成xcode模板项目

通过上面的方式生成工程后,我们就可以制作这样的脚本了

创建脚本

#!/bin/bash

# 1. 获取参数
if [ -z "$1" ]; then
  echo "❌ 使用方法: 缺少参数 <ProjectName>"
  exit 1
fi

PROJECT_NAME=$1
SRC_DIR=$PROJECT_NAME

# 2. 检查 xcodegen
if ! command -v xcodegen &> /dev/null; then
    echo "❌ xcodegen 未安装,请执行:brew install xcodegen"
    exit 1
fi

# 3. 创建项目结构
mkdir -p "$PROJECT_NAME/$SRC_DIR"
cd "$PROJECT_NAME" || exit 1

# 4. 拷贝模板源码文件(假设放在脚本同级目录下的 TemplateFiles 目录中)
TEMPLATE_DIR="这里填写自定义模板文件路径"
if [ -d "$TEMPLATE_DIR" ]; then
  echo "📄 拷贝源码文件到 $SRC_DIR/ ..."
  cp -R "$TEMPLATE_DIR"/. "$SRC_DIR"/
else
  echo "⚠️ 未找到模板目录 $TEMPLATE_DIR,跳过拷贝"
fi

# 5. 创建 project.yml
cat > project.yml <<EOF
name: $PROJECT_NAME
options:
  bundleIdPrefix: com.example
packages:
  SnapKit:
    url: https://github.com/SnapKit/SnapKit.git
    from: 5.7.1
targets:
  $PROJECT_NAME:
    type: application
    platform: iOS
    sources:
      - path: $SRC_DIR
    info:
      path: $SRC_DIR/Info.plist
      properties:
        UILaunchStoryboardName: LaunchScreen
        UIMainStoryboardFile: Main
        UIApplicationSceneManifest:
          UIApplicationSupportsMultipleScenes: false
          UISceneConfigurations:
            UIWindowSceneSessionRoleApplication:
              - UISceneConfigurationName: "Default Configuration"
                UISceneDelegateClassName: "\$(PRODUCT_MODULE_NAME).SceneDelegate"
                UISceneStoryboardFile: "Main"
EOF

# 6. 生成并打开项目
echo "📦 生成 Xcode 工程..."
xcodegen generate

open "$PROJECT_NAME.xcodeproj"

将脚本保存为iosapp.sh,使用 chmod 命令给脚本文件添加执行权限:

chmod +x myscript.sh

执行脚本命令:

可以先这样执行脚本:

./iosapp.sh MyDemo

如果没问题,再将脚本移动到一个可以在终端中随时调用的命令:

sudo mv iosapp.sh /usr/local/bin/iosapp

然后直接使用下面方式执行脚本命令:

iosapp 项目名称

参考资料: github.com/yonaskolb/X…

日月之行,若出其中。星汉灿烂,若出其里。

2025年6月26日 11:50

three优化篇

1.模型简化

  • 型简化是通过减少多边形数(即顶点和面)来降低模型的复杂度。LOD(Level of Detail,细节层次)
  • Three.js支持LOD功能,能根据相机与模型的距离,自动切换不同细节的模型版本,从而减少渲染负荷。
import { LOD } from 'three';
 
const lod = new LOD();
 
// 设置低细节模型(适合远处显示)
const lowDetailMesh = createLowDetailMesh();
lod.addLevel(lowDetailMesh, 100); // 距离相机100单位时使用低细节模型
 
// 设置中细节模型
const mediumDetailMesh = createMediumDetailMesh();
lod.addLevel(mediumDetailMesh, 50); // 距离相机50单位时使用中细节模型
 
// 设置高细节模型(适合近距离显示)
const highDetailMesh = createHighDetailMesh();
lod.addLevel(highDetailMesh, 0); // 距离相机为0时使用高细节模型
 
scene.add(lod);

2.模型批处理

  • 批处理将多个小模型合并为一个大模型,减少WebGL的绘制调用次数,从而提高性能。在Three.js中,常用的批处理技术包括合并网格(Mesh)、实例化渲染(Instanced Rendering)等。
  • 可以将场景中多个静态对象合并成一个网格,从而减少渲染调用。
import { BufferGeometry, BoxGeometry, Mesh, MeshBasicMaterial, MeshStandardMaterial, Scene } from 'three';
 
// 创建材质和几何体
const material = new MeshStandardMaterial({ color: 0x00ff00 });
const geometry = new BoxGeometry();
 
// 创建多个网格
const meshes = [];
for (let i = 0; i < 10; i++) {
  const mesh = new Mesh(geometry, material);
  mesh.position.set(i * 2, 0, 0);
  meshes.push(mesh);
}
 
// 合并网格
const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(meshes.map(m => m.geometry), true);
const mergedMesh = new Mesh(mergedGeometry, material);
scene.add(mergedMesh);

实例化渲染:使用InstancedMesh可以实现一次性渲染多个相同的网格,提高渲染效率。

import { InstancedMesh, BoxGeometry, MeshBasicMaterial, Matrix4 } from 'three';
 
const geometry = new BoxGeometry();
const material = new MeshBasicMaterial({ color: 0x00ff00 });
const count = 100;
 
const instancedMesh = new InstancedMesh(geometry, material, count);
 
for (let i = 0; i < count; i++) {
  const matrix = new Matrix4();
  matrix.setPosition(i % 10, Math.floor(i / 10), 0);  // 设置位置
  instancedMesh.setMatrixAt(i, matrix);
}
 
scene.add(instancedMesh);

硬件加速与性能优化技巧

硬件加速和性能优化是确保Three.js应用在多种设备上流畅运行的关键。优化渲染管道和减少GPU负荷能有效提高应用的响应速度。

  • 使用帧率限制和动态分辨率。通过调整渲染器的更新时间,控制最大帧率。
let lastRenderTime = 0;
const maxFPS = 30;
 
function animate(time) {
  const delta = time - lastRenderTime;
  if (delta > 1000 / maxFPS) {
    renderer.render(scene, camera);
    lastRenderTime = time;
  }
  requestAnimationFrame(animate);
}
animate(0);

动态分辨率:动态调整渲染分辨率,在保持画质的同时降低渲染负担。Three.js中可以通过调整渲染器的setPixelRatio来实现。

renderer.setPixelRatio(window.devicePixelRatio > 1 ? 1.5 : 1);

场景剔除和遮挡剔除

  • 场景剔除:Three.js中的场景剔除技术会自动隐藏相机视野外的对象。可以进一步使用分区剔除技术来优化大型场景。
  • 遮挡剔除:在一些场景中,对被完全遮挡的物体进行剔除,可以减轻渲染负担。

清除不必要场景模型

/* 清除不再使用内存模型几何体与材质 防止泄露 */
mesh.remove()
1.
mesh.traverse((obj:any)=> {
    if (obj.type === 'Mesh') {
      obj.geometry.dispose();
      obj.material.dispose();
    }
})

2.
model.traverse((obj) => {
    if (!obj.isMesh) return;
    obj.geometry.dispose();
    obj.material.dispose();
});
model = null;

节流渲染 requestAnimationFrame()

-面板超出一定时间停止渲染、鼠标事件触发重新渲染

1. 节流渲染
let timeOut;
let render = () => {
    stats.update();
    if (timeOut) {
      controls.update();
      composer.render();
    }
    /* 动画事件 */
    requestAnimationFrame(render);
};render()


SwiftUI 5.0(iOS 17)TipKit 让用户更懂你的 App

2025年6月26日 11:34

在这里插入图片描述

概览

作为我们秃头开发者来说,写出一款创意炸裂的 App 还不足以吸引用户眼球,更重要的是如何让用户用最短的时间掌握我们 App 的使用技巧。

在这里插入图片描述

从 iOS 17 开始, 推出了全新的 TipKit 框架专注于此事。有了它,我们再也不用自己写 App 用户帮助以及使用指南的逻辑和界面了。

使用 TipKit 非常简单,接下来就让我们一起走进 TipKit 的世界吧!

  1. 什么是 TipKit?
  2. 创建一个 Tip
  3. TipKit 显示的两种方式
  4. TipKit 全局配置
  5. TipKit 显示规则
  6. 为 TipKit 增加更多互动性
  7. 如何测试 TipKit?

本文代码全部在 Xcode 15 beta8 上编译,在 iOS 17 beta8 上运行。


1. 什么是 TipKit?

在这里插入图片描述

TipKit 是  在 WWDC 23 上推出的一款新框架,用于在界面显示提示(Tips)来帮助用户快速发掘我们 App 的使用特性。

在这里插入图片描述在这里插入图片描述

目前该框架仍属于 beta 阶段,意味着它还有很多不确定性。

如果我没有记错, 直到 Xcode beta4 才将 TipKit 提供给开发者,而且现在 官网 TipKit 的示例代码在 Xcode beta8 中已提示语法错误了(我们后面会说明):

在这里插入图片描述


对于  这种习惯性“谜之”行为的更多细节,感兴趣的小伙伴们可以到如下链接中观赏:


2. 创建一个 Tip

按照 SwiftUI 的“习性”,一个 Tip 同时意味着外观和逻辑双重含义。

创建一个提示很简单,只需遵循 Tip 协议即可:

struct FavoriteTip: Tip {
    var title: Text {
        Text("收藏最爱的图片")
            .bold()
    }
    
    var message: Text? {
        Text("将心仪的图片保存到相册中")
            .font(.headline)
            .foregroundStyle(.gray.gradient)
    }
}

Tip 协议还有很多其它可选属性,比如我们还可以为 Tip 界面进一步增加图片修饰:

struct FavoriteTip: Tip {
    var image: Image? {
        Image(systemName: "heart")
    }
}

3. TipKit 显示的两种方式

在 Tip 创建之后如何显示它们呢?有两种方式:嵌入和弹出。

我们可以直接将 Tip 嵌在视图中:

struct ContentView: View {
    let favTip = FavoriteTip()
    
    var body: some View {
        NavigationStack {
            VStack {
                
                TipView(favTip)
            }
            .padding()
            .navigationTitle("TitKit演示")
        }
    }
}

显示效果如下:

在这里插入图片描述

或者我们还可以将 Tip 直接依附于某一个视图,比如图片或按钮:

struct ContentView: View {
    let favTip = FavoriteTip()
    
    var body: some View {
        NavigationStack {
            VStack {...}
            .padding()
            .navigationTitle("TitKit演示")
            .toolbar {
                ToolbarItem {
                    Image(systemName: "heart")
                        .font(.title.weight(.black))
                        .foregroundStyle(.pink.gradient)
                        .popoverTip(favTip, arrowEdge: .top)
                }
            }
        }
    }
}

在这里插入图片描述

我们可以根据不同需求来组合使用这两种显示方式。


更多定制 Tip 外观的方法,请小伙伴们移步下面的博文观赏:


4. TipKit 全局配置

其实,TipKit 框架会在 App (本地目录)中存放一些相关的配置信息。理论上说,它们可能会通过 iCloud 同步到其它设备上去,这意味着在不同设备上相同 App 中的 TipKit 共享同一组配置。

我们可以进一步定制 TipKit 的配置细节,比如 Tip 显示频率、配置数据库保存的本地位置等等:

import SwiftUI
import TipKit

@main
struct TipKitTestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {                    
                    try? Tips.configure([
                        .displayFrequency(.immediate),
                        .datastoreLocation(.applicationDefault)
                    ])
                    
                    // Xcode 15 beta4 中过时的语法,目前已不能使用:
                    /*
                    try? await Tips.configure {
                        DisplayFrequency(.immediate)
                        DatastoreLocation(.applicationDefault)
                    }*/
                }
        }
    }
}

如上代码所示,我们需要所有 Tip 立即显示,并且让系统决定配置数据存储的位置(我们也可以自己设置存储路径)。

在代码中,我们注释了之前官方示例中出错的代码片段,这些代码在 Xcode 15 beta8 中已不能使用。

5. TipKit 显示规则

除了全局 Tip 显示限制以外,我还可以设置单个 Tip 之间的显示规则。

比如,假设有两个提示,我们希望 FavoriteTip 在 StartTip 提示关闭后再显示,我们可以在 FavoriteTip 中用特定的规则(Rule)来表示这一约束:

struct FavoriteTip: Tip {
    // 其它代码从略
    
    var rules: [Rule] {
        #Rule(Self.$startTipHasDisplayed) { $0 == true}
    }
    
    @Parameter
    static var startTipHasDisplayed: Bool = false
    
}

现在,我们需要在 StartTip 提示关闭时将 FavoriteTip.startTipHasDisplayed 置为 true 才能触发 FavoriteTip 的显示:

struct ContentView: View {
    
    let startTip = StartTip()
    let favTip = FavoriteTip()
    
    var body: some View {
        NavigationStack {
            
            VStack {
                
                Image("1")
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .onTapGesture {
                        // 关闭 startTip 提示
                        startTip.invalidate(reason: .actionPerformed)
                        // 触发 FavoriteTip 提升的显示
                        FavoriteTip.startTipHasDisplayed = true
                    }
                
                TipView(startTip)
            }
            .padding()
            .navigationTitle("TitKit演示")
            .toolbar {
                ToolbarItem {
                    Image(systemName: "heart")
                        .font(.title.weight(.black))
                        .foregroundStyle(.pink.gradient)
                        .popoverTip(favTip, arrowEdge: .top)
                }
            }
        }
    }
}

现在,只有等 StartTip 关闭后,FavoriteTip 提示才能显示出来: 在这里插入图片描述

6. 为 TipKit 增加更多互动性

有时,我们希望提示为用户提供更丰富的交互功能,比如在 Tip 中提供按钮跳转到更详细的使用教程界面。

TipKit 为此也提供了很好的支持,我们可以为 Tip 添加 Action 来驱动交互行为:

struct FavoriteTip: Tip {
    // 其它代码从略
    
    var actions: [Action] {
        [
            Tip.Action(id: "learn-more", title: "了解更多"),
            Tip.Action(id: "forget", title: "下次再说")
        ]
    }
}

在 Tip Action 被触发时,我们可以执行自定义行为:

struct ContentView: View {
    
    let startTip = StartTip()
    let favTip = FavoriteTip()
    
    var body: some View {
        NavigationStack {
            
            VStack {
                
                Image("1")
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .onTapGesture {
                        startTip.invalidate(reason: .actionPerformed)
                        
                        FavoriteTip.startTipHasDisplayed = true
                    }
                
                TipView(startTip)
            }
            .padding()
            .navigationTitle("TitKit演示")
            .toolbar {
                ToolbarItem {
                    Image(systemName: "heart")
                        .font(.title.weight(.black))
                        .foregroundStyle(.pink.gradient)
                        .popoverTip(favTip, arrowEdge: .top){ action in
                            switch action.index {
                            case 0:
                                favTip.invalidate(reason: .tipClosed)

                                // 跳转到详细使用教程
                            case 1:
                                favTip.invalidate(reason: .tipClosed)
                                
                                // 直接退出提示
                            default:
                                break
                            }
                        }
                }
            }
        }
    }
}

现在,用户可以选择“了解更多”来进一步学习 App 的使用“秘技”了:

在这里插入图片描述

7. 如何测试 TipKit?

TipKit 全局配置存储在本地带有持久化特性,为了便于开发者即时测试, 提供了一些方法来快速显示或隐藏全部或指定 Tip:

在这里插入图片描述

一般的,要想在 Xcode 预览中正确测试 TipKit 的行为,我们需要在每次视图刷新时重置 TipKit 数据库,否则 Tip 不会正常显示:

#Preview {
    ContentView()
        .task {
            // 在每次视图刷新时将 TipKit 数据库重置为初始状态
            try? Tips.resetDatastore()
            
            try? Tips.configure([
                .displayFrequency(.immediate),
                .datastoreLocation(.applicationDefault)
            ])
        }
}

总结

在本篇博文中,我们介绍了 SwiftUI 5.0(iOS 17)中新引进的开发框架 TipKit,使用它我们可以非常方便和快速的向用户介绍我们 App 中的各种特性和使用指南,小伙伴们还不快操练起来!

感谢观赏,再会!8-)

Swift 新并发模型中 isolated 和 nonisolated 关键字的含义看这篇就懂了!

2025年6月26日 11:30

在这里插入图片描述

概览

在 Swift 新 async/await 并发模型中,我们可以利用 Actor 来避免并发同步时的数据竞争,并从语义上简化代码。

Actor 伴随着两个独特关键字:isolatednonisolated,弄懂它们的含义、合理合规的使用它们是完美实现同步的必要条件。

那么小伙伴们真的搞清楚它们了吗?

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

  1. isolated 关键字
  2. nonisolated 关键字
  3. 没有被 async 修饰的方法也可以被异步等待!

闲言少叙,让我们即刻启航!

Let‘s go!!!;)


isolated 关键字

Actor 从本质上来说就是一个同步器,它必须严格限制单个实例执行上下文以满足同步的语义。

这意味着在 Actor 中,所有可变属性、计算属性以及方法等默认都是被隔离执行的。

actor Foo {
    let name: String
    let age: Int
    var luck = 0
    
    init(name: String, age: Int, luck: Int = 0) {
        self.name = name
        self.age = age
        self.luck = luck
    }
    
    func incLuck() {
        luck += 1
    }
    
    var fullDesc: String {
        "\(name)[\(age)] luck is *\(luck)*"
    }
}

如上代码所示,Foo 中的 luck 可变属性、incLuck 方法以及 fullDesc 计算属性默认都被打上了 isolated 烙印。大家可以想象它们前面都隐式被 isolated 关键字修饰着,但这不能写出来,如果写出来就会报错:

在这里插入图片描述

在实际访问或调用这些属性或方法时,必须使用 await 关键字:

Task {
    let foo = Foo(name: "hopy", age: 11)
    await foo.incLuck()
    print(await foo.luck)
    print(await foo.fullDesc)
}

正是 await 关键字为 Foo 实例内容的同步创造了隔离条件,以摧枯拉朽之势将数据竞争覆巢毁卵。

nonisolated 关键字

但是在有些情况下 isolated 未免有些“防御过度”了。

比如,如果我们希望 Foo 支持 CustomStringConvertible 协议,那么势必需要实现 description 属性:

extension Foo: CustomStringConvertible {
    var description: String {
        "\(name)[\(age)]"
    }
}

如果大家像上面这样写,那将会妥妥的报错:

在这里插入图片描述

因为 description 作为计算属性放在 Actor 中,其本身默认处在“隔离”状态,而 CustomStringConvertible 对应的 description 实现必须是“非隔离”状态!

大家可以这样理解:我们不能异步调用 foo.description!

extension Foo: CustomStringConvertible {
    /*
    var description: String {
        "\(name)[\(age)]"
    }*/
    
    var fakeDescription: String {
        "\(name)[\(age)]"
    }
}

Task {
    let foo = Foo(name: "hopy", age: 11)
    // foo.description 不能异步执行!!!
    print(await foo.fakeDescription)
}

大家或许注意到,在 Foo#description 中,我们只使用了 Foo 中的只读属性。因为 Actor 中只读属性都是 nonisolated 隐式修饰,所以这时我们可以显式用 nonisolated 关键字修饰 description 属性,向 Swift 表明无需考虑 Foo#description 计算属性内部的同步问题,因为里面没有任何可变的内容:

extension Foo: CustomStringConvertible {
    nonisolated var description: String {
        "\(name)[\(age)]"
    }
}

Task {
    let foo = Foo(name: "hopy", age: 11)
    print(foo)
}

但是,如果 nonisolated 修饰的计算属性中含有可变(isolated)内容,还是会让编译器“怨声载道”:

在这里插入图片描述

没有被 async 修饰的方法也可以被异步等待!

最后,我们再介绍 isolated 关键字一个非常有用的使用场景。

考虑下面的 incLuck() 全局函数,它负责递增传入 Foo 实例的 luck 值,由于 Actor 同步保护“魔法”的存在,它必须是一个异步函数:

func incLuck(_ foo: Foo) async {
    await foo.incLuck()
}

不过,如果我们能够保证 incLuck() 方法传入 Foo 实参的“隔离性”,则可以直接访问其内部的“隔离”(可变)属性!

如何保证呢?

很简单,使用 isolated 关键字:

func incLuck2(_ foo: isolated Foo) {
    foo.luck += 1
}

看到了吗? luck 是 Foo 内部的“隔离”属性,但我们竟然可以在外部对其进行修改,是不是很神奇呢?

这里,虽然 incLuck2() 未用 async 修饰,但它仍是一个异步方法,我称之为全局“隐式异步”方法:

Task {
    let foo = Foo(name: "hopy", age: 11)
    await incLuck(foo)
    await incLuck2(foo)
}

虽然 foo 是一个 Actor 实例,它包含一些外部无法直接查看的“隔离”内容,但我们仍然可以使用一些调试手段探查其内部,比如 dump 方法:

Task {
    let foo = Foo(name: "hopy", age: 11)
    await incLuck(foo)
    await incLuck2(foo)
    dump(foo)
}

输出如下:

over
hopy[11]
▿ hopy[11] #0
  - $defaultActor: (Opaque Value)
  - name: "hopy"
  - age: 11
  - luck: 2

通过 dump() 方法输出可以看到,foo 的 luck 值被正确增加了 2 次,棒棒哒!!!💯

总结

在本篇博文中,我们通过几个通俗易懂的例子让小伙伴们轻松了解到 Swift 新 async/await 并发模型中 isolated 与 nonisolated 关键字的精髓,并对它们做了进一步的深入拓展。

感谢观赏,再会!8-)

如何让异步序列(AsyncSequence)优雅的感知被取消(Cancel)

2025年6月26日 11:23

在这里插入图片描述

概览

自  从 Swift 5.5 推出新的 async/await 并发模型以来,异步队列(AsyncSequence)就成为其中不可或缺的重要一员。

不同于普通的序列,异步序列有着特殊的“惰性”和并发性,若序列中的元素还未准备好,系统在耐心等待的同时,还将宝贵的线程资源供其它任务去使用,极大的提高了系统整体性能。

在本篇博文中,您将学到以下知识:

  1. 什么是异步序列?
  2. 创建自定义异步序列
  3. 另一种创建异步序列的方式:AsyncStream
  4. 取消异步序列的处理

什么是异步序列?

异步序列(AsyncSequence)严格的说是一个协议,它为遵守者提供异步的、序列的、迭代的序列元素访问。

在这里插入图片描述

表面看起来它是序列,实际上它内部元素是异步产生的,这意味着当子元素暂不可用时使用者将会等待直到它们可用为止: 在这里插入图片描述

诸多系统框架都对异步序列做了相应扩展,比如 Foundation 的 URL、 Combine 的发布器等等:

// Foundation
let url = URL(string: "https://kinds.blog.csdn.net/article/details/132787577")!
Task {
    do {
        for try await line in url.lines {
            print(line)
        }
    }catch{
        print("ERR: \(error.localizedDescription)")
    }
}

// Combine
let p = PassthroughSubject<Int,Never>()
for await val in p.values {
    print(val)
}

如上代码所示,URL#lines 和 Publisher#values 属性都是异步序列。

除了系统已为我们考虑的以外,我们自己同样可以非常方便的创建自定义异步序列。

创建自定义异步序列

一般来说,要创建自定义异步序列我们只需遵守 AsyncSequence 和 AsyncIteratorProtocol 协议即可:

在这里插入图片描述

下面我们就来创建一个“超级英雄们(Super Heros)”的异步序列吧:

struct SuperHeros: AsyncSequence, AsyncIteratorProtocol {
    
    private let heros = ["超人", "钢铁侠", "孙悟空", "元始天尊", "菩提老祖"]
    
    typealias Element = String
    var index = 0
    
    mutating func next() async throws -> Element? {
        defer { index += 1}
        
        try? await Task.sleep(for: .seconds(1.0))
        
        if index >= heros.count {
            return nil
        }else{
            return heros[index]
        }
    }
    
    func makeAsyncIterator() -> SuperHeros {
        self
    }
}

Task {
    let heros = SuperHeros()
    for try await hero in heros {
        print("出场英雄:\(hero)")
    }
}

以上异步序列会每隔 1 秒“产出”一名超级英雄:

在这里插入图片描述

如上代码所示,如果下一个超级英雄还未就绪,系统会在等待同时去执行其它合适的任务,不会有任何资源上的浪费。

另一种创建异步序列的方式:AsyncStream

其实,除了直接遵守 AsyncSequence 协议以外,我们还有另外一种选择:AsyncStream!

不像 AsyncSequence 和 AsyncIteratorProtocol 协议 ,AsyncStream 是彻头彻尾的结构(实体): 在这里插入图片描述

它提供两种构造器,分别供正常和异步序列产出(Spawning)情境使用:

public init(_ elementType: Element.Type = Element.self, bufferingPolicy limit: AsyncStream<Element>.Continuation.BufferingPolicy = .unbounded, _ build: (AsyncStream<Element>.Continuation) -> Void)

    
public init(unfolding produce: @escaping () async -> Element?, onCancel: (@Sendable () -> Void)? = nil)

下面为此举两个  官方提供的代码示例:

let stream_0 = AsyncStream<Int>(Int.self,
                    bufferingPolicy: .bufferingNewest(5)) { continuation in
     Task.detached {
         for _ in 0..<100 {
             await Task.sleep(1 * 1_000_000_000)
             continuation.yield(Int.random(in: 1...10))
         }
         continuation.finish()
    }
}

let stream_1 = AsyncStream<Int> {
    await Task.sleep(1 * 1_000_000_000)
    return Int.random(in: 1...10)
}

更多关于异步序列的知识,请小伙伴们移步如下链接观赏:


取消异步序列的处理

我们知道  新的 async/await 并发模型主打一个“结构化”,之所以称为“结构化”一个重要原因就是并发中所有任务都共同组成一个层级继承体系,当父任务出错或被取消时,所有子任务都会收到取消通知,异步序列同样也不例外。

就拿下面倒计时异步序列来说吧,它能感应父任务取消事件的原因是由于其中调用了 Task.sleep() 方法( sleep() 方法内部会对取消做出响应):

let countdown = AsyncStream<String> { continuation in
    Task {
        for i in (0...3).reversed() {
            try await Task.sleep(until: .now + .seconds(1.0), clock: .suspending)
            
            guard i > 0 else {
                continuation.yield(with: .success("🎉 " + "see you!!!"))
                return
            }
            
            continuation.yield("\(i) ...")
        }
    }
}

Task {
    for await count in countdown {
        print("current is \(count)")
    }
}

正常情况下,我们应该在异步序列计算昂贵元素之前显式检查 Cancel 状态:

let stream_1 = AsyncStream<Int> {
    // 假设 spawn() 是一个“昂贵”方法
    func spawn() -> Int {
        Int.random(in: 1...10)
    }
    
    // 或者使用 Task.checkCancellation() 处理异常
    if Task.isCancelled {
        return nil
    }
    
    return spawn()
}

在某些情况下,我们希望用自己的模型(Model)去关联退出状态,这时我们可以利用 withTaskCancellationHandler() 方法为异步序列保驾护航:

public func next() async -> Order? {
    return await withTaskCancellationHandler {
        let result = await kitchen.generateOrder()
        // 使用自定义模型中的状态来判断是否取消
        guard state.isRunning else {
            return nil
        }
        return result
    } onCancel: {
    // 在父任务取消时设置取消状态!
        state.cancel()
    }
}

注意,当父任务被取消时上面 onCancel() 闭包中的代码会立即执行,很可能和 withTaskCancellationHandler() 方法主体代码同步进行。

现在,在一些 Task 内置取消状态不适合或不及时的场合下,我们可以在异步序列中使用 withTaskCancellationHandler() 的 onCancel() 子句来更有效率的完成退出操作,棒棒哒!💯。

总结

在本篇博文中,我们首先简单介绍了什么是异步序列,接着学习了几种创建自定义异步序列的方法,最后我们讨论了如何优雅的取消异步序列的迭代。

感谢观赏,再会!8-)

Objective-C 的 NSHashTable 是什么

作者 冯志浩
2025年6月26日 11:09

前言

在 Objective-C 开发中,我们时常需要使用集合类型来存储多个对象。常见的集合类如 NSArrayNSSet 是非常常用的,但它们默认会对元素进行强引用(strong reference)。这意味着只要集合中还保留着对象的引用,该对象就不会被释放。

然而,在某些情况下,我们希望存储对象的同时不干扰其生命周期。这时,NSHashTable 就派上了用场。

下面,我们先来看下什么是 NSHashTable?

什么是 NSHashTable?

NSHashTable 是 Foundation 框架中提供的一个集合类,与 NSSet 类似,但具备更大的灵活性。它不仅可以存储对象,还允许我们定义集合对对象的引用语义,比如下面的支持特性:

  • 弱引用
  • 可变元素
  • 支持非对象类型

那么,什么场景下我们需要用到它呢?

NSHashTable的使用场景

假设你正在实现一个管理监听器的类(比如事件分发器、观察者模式等),你不希望这些监听器因为被存储在集合中而无法释放。NSHashTable 的弱引用特性可以帮助我们:

  • 避免循环引用(retain cycle)
  • 避免内存泄漏
  • 无需手动移除释放的对象

这对于写出健壮、内存安全的代码非常关键。我们可以存储对象但不强引用它们,示例代码如下:

// 声明一个 Person 的类型
@interface Person : NSObject

@property (nonatomic, copy) NSString *name;

@end

@implementation Person
- (void)dealloc {
    NSLog(@"Deallocating %@", self.name);
}
@end

// 在控制器中使用 weak HashTable 存储 Person 对象
#import "ViewController.h"
#import "Person.h"
@interface ViewController ()
@property (nonatomic, strong) NSHashTable *weakTable;
@end

@implementation ViewController


- (void)viewDidLoad {
    [super viewDidLoad];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self testP];
}

- (void)testP {
    // 创建一个弱引用的 NSHashTable
    self.weakTable = [NSHashTable weakObjectsHashTable];
    
    // 创建作用域内的对象
    for (int i = 0; i < 3; i++) {
        Person *obj = [[Person alloc] init];
        obj.name = [NSString stringWithFormat:@"Obj-%d", i];
        [self.weakTable addObject:obj];
    }
    NSLog(@"对象添加后:%@", self.weakTable.allObjects);
}

@end

输出如下:

Deallocating Obj-0
Deallocating Obj-1
Deallocating Obj-2
对象添加后:(
)

通过上面的打印我们可以看到,尽管我们将对象添加到 NSHashTable 中,但由于它是以弱引用的方式存储的,当对象超出作用域并没有其他强引用时,会立即释放。

配置 NSHashTable 的其他方式

使用 NSHashTable 我们还可以通过不同的选项构建出适合需求的集合:

NSHashTable *strongTable = [[NSHashTable alloc] initWithOptions:NSPointerFunctionsStrongMemory capacity:0];
NSHashTable *copyTable = [[NSHashTable alloc] initWithOptions:NSPointerFunctionsCopyIn capacity:0];

但大多数情况下,使用内建方法就足够:

  • + (instancetype)weakObjectsHashTable:弱引用,不保留对象。
  • + (instancetype)hashTableWithOptions::可以自定义引用语义。

实际场景应用:事件监听器

考虑一个经典场景:你有一个“事件中心”类,多个组件注册为监听器。我们希望监听器随时可以释放,而不是被事件中心“卡住”。

@protocol EventListener <NSObject>
- (void)onEventReceived:(NSString *)event;
@end

@interface EventCenter : NSObject
- (void)addListener:(id<EventListener>)listener;
- (void)notifyEvent:(NSString *)event;
@end

@implementation EventCenter {
    NSHashTable *_listeners;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        _listeners = [NSHashTable weakObjectsHashTable];
    }
    return self;
}

- (void)addListener:(id<EventListener>)listener {
    [_listeners addObject:listener];
}

- (void)notifyEvent:(NSString *)event {
    for (id<EventListener> listener in _listeners) {
        [listener onEventReceived:event];
    }
}
@end

这样,监听器一旦没有其他强引用,将自动释放,不会引起内存泄漏。

注意事项

  • NSHashTable 只能用于存储对象(不是基本数据类型)。
  • 使用 weakObjectsHashTable 存储的对象若没有强引用,会自动从集合中移除。
  • 如果你需要键值对形式的弱引用集合,请使用 NSMapTable

总结

NSHashTable 它允许我们构建更加灵活、内存友好的集合,尤其在实现弱引用集合时非常实用。使用它可以有效地避免很多典型的内存管理问题,如观察者未移除、循环引用等。

Swift Collections:Deque 的使用与原理

2025年6月25日 18:32

在日常开发中,我们经常使用数组(Array)来存储和管理数据。然而,当我们频繁地在集合的两端插入或删除元素时,数组的性能就会成为一个问题。为了解决这个问题,Swift Collections 提供了一个名为 Deque 的双端队列类型,它在效率和灵活性方面提供了更优的选择。

本文将主要介绍 Deque 是什么、它的优势、使用方式以及适用场景,希望能对你有所帮助。

什么是 Deque?

DequeDouble-Ended Queue 的缩写,意思是“双端队列”。它是一种支持在队列的两端进行高效插入和删除的容器。

与普通的 Swift 数组相比,Deque 特别适合需要在头部和尾部频繁操作元素的场景。比如以下的场景:

  • 实现浏览器的前进/后退功能
  • 滚动窗口(sliding window)算法
  • 深度/广度优先搜索中维护任务队列

Swift Collections 提供的 Deque 结构,位于 swift-collections 仓库 中,是开源的。

为什么不用 Array?

在 Swift 中,Array 是一种动态数组,虽然对尾部的追加(append(_:))非常高效,但对头部的插入或删除则可能导致整段内存的复制,从而影响性能。例如:

var array = [1, 2, 3]
array.removeFirst()  // 会移动后续所有元素

如果你需要在头部频繁插入或删除,这种操作的时间复杂度是 O(n),性能可能会随着数据量增加而显著下降。

Deque 在这方面就显得更加得心应手。

Deque 的核心特点

Deque 有以下几个核心特性:

  1. 双端高效操作
    在头部或尾部插入和删除元素,平均时间复杂度为 O(1)。

  2. 值语义(value semantics)
    Array 一样,Deque 是值类型,遵循 Swift 的 copy-on-write(写时复制)策略。你可以放心地传递它而不用担心副作用。

  3. 遵循 Collection 协议
    可以使用 for-in 循环、索引访问、切片等方式处理 Deque 的内容。

  4. 容量管理灵活
    支持预分配容量(reserveCapacity(_:))来优化性能,尤其在知道元素数量的场景下。

如何使用 Deque

首先需要导入 Swift Collections 库。如果你是通过 Swift Package Manager 引入的,只需要在文件顶部导入模块即可:

import DequeModule
  • 创建一个 Deque
var deque: Deque<Int> = [1, 2, 3]

或使用默认构造函数:

var emptyDeque = Deque<Int>()
  • 在队尾添加元素
deque.append(4)  // [1, 2, 3, 4]
  • 在队头添加元素
deque.prepend(0)  // [0, 1, 2, 3, 4]
  • 删除元素
let first = deque.removeFirst()   // 返回 0,deque 变成 [1, 2, 3, 4]
let last = deque.removeLast()     // 返回 4,deque 变成 [1, 2, 3]
  • 支持下标访问
let item = deque[1]   // 获取索引为 1 的元素:2
  • 遍历 Deque
for value in deque {
    print(value)
}

示例:滑动窗口最大值

在算法题中,滑动窗口(Sliding Window)常常用到双端队列。下面是一个简单示例,演示如何用 Deque 实现维护一个滑动窗口中的最大值索引:

import DequeModule

func maxSlidingWindow(_ nums: [Int], _ k: Int) -> [Int] {
    var deque = Deque<Int>() // 存储索引
    var result = [Int]()

    for i in 0..<nums.count {
        // 如果队列首部的索引不在窗口内,移除
        if let first = deque.first, first <= i - k {
            deque.removeFirst()
        }

        // 移除所有比当前值小的尾部元素
        while let last = deque.last, nums[last] < nums[i] {
            deque.removeLast()
        }

        deque.append(i)

        // 记录窗口最大值
        if i >= k - 1 {
            result.append(nums[deque.first!])
        }
    }

    return result
}

这个算法通过维护一个单调递减的索引队列,在每次滑动时快速获得当前窗口的最大值,时间复杂度仅为 O(n)。

性能对比

操作 Array 平均复杂度 Deque 平均复杂度
append() O(1) O(1)
insert()/prepend() O(n) O(1)
removeFirst() O(n) O(1)
removeLast() O(1) O(1)
索引访问 O(1) O(1)

从上面的表格上可以看出:Deque 在对头部操作时明显优于 Array,而在索引访问和尾部操作方面则性能相近。

注意事项

虽然 Deque 功能强大,但也并非所有场景都适合使用它:

  • 如果你只是在尾部操作元素,Array 已经非常高效;
  • 如果你需要排序功能,Array 的支持更完整;
  • Deque 并不是随机插入的理想结构(不支持在中间插入)。

总结

Deque 是 Swift Collections 中非常实用的数据结构,尤其适合需要高效双端操作的场景。它填补了 Array 无法高效支持头部操作的短板,为我们处理某些算法和队列问题提供了天然的工具。

在 Swift 项目中,如果你发现自己在频繁进行 insert(at: 0)removeFirst() 操作,不妨尝试将这些代码替换为 Deque,可能会带来性能上的显著提升。

Swift Collections:Heap 的使用

作者 冯志浩
2025年6月25日 08:50

前言

在 Swift Collections 中,Heap 是一个提供双端优先队列功能的泛型容器类型,位于 HeapModule 模块内。它通过堆(heap)的数据结构实现,可以高效地同时支持获取和删除最小值/最大值操作。这种设计类型在许多算法中非常常用,比如调度、路径查找、优先任务处理等。

我们需要注意的是:对于要存储在 Heap 元素类型必须要符合 Comparable 协议,因为该结构需要通过该协议定义元素的排序关系,从而确保堆结构的正确性和稳定性。

API 概览

API 整体清晰、简洁:

init()
init(_ elements: some Sequence<Element>)

var count: Int { get }
var isEmpty: Bool { get }
var unordered: [Element] { get }
var min: Element? { get }
var max: Element? { get }

mutating func insert(_ element: Element)
mutating func insert(contentsOf newElements: some Sequence<Element>)

mutating func popMax() -> Element?
mutating func popMin() -> Element?

mutating func removeMax() -> Element
mutating func removeMin() -> Element

mutating func replaceMax(with replacement: Element) -> Element
mutating func replaceMin(with replacement: Element) -> Element
mutating func reserveCapacity(_ minimumCapacity: Int)
  • 构造器

    • init():创建空堆。
    • init(_:):使用一个 Sequence 创建堆。
  • 属性

    • count, isEmpty:基础计数属性。
    • unordered:返回底层数组的内容,顺序不会保证排序,但对调试或遍历有帮助 。
  • 查询

    • min(), max():返回当前最小/最大值,时间复杂度 O(1)。
  • 插入与删除

    • 插入单个元素(insert(_ element: Element)):O(log n);
    • 批量插入(insert(contentsOf newElements: some Sequence<Element>)):当批量规模较大时,会根据 n + k 的复杂度选择最优方式,即整体建堆或多次插入方式;
    • 删除最小/最大值:分别是 popMin() / popMax(),O(log n);
    • 强制移除(非可选性):removeMin() / removeMax()
    • 替换操作:replaceMin(with:), replaceMax(with:),将头部元素替换为新值并调整堆,返回原来的值。

核心特性

  • 支持双端操作

Heap 同时维护最小堆和最大堆两端功能,无需额外结构,这对于一些需求获取最两个极端值的场景非常高效。

  • O(log n) 插入和删除

这是经典堆性能的体现,插入或删除单个元素仅需调整路径高度次数即可。

  • 批量插入优化

对于批量插入 k 个元素,若 k 接近 nHeap 会优先采用 Floyd 构堆法,复杂度 O(n + k);否则逐一插入 O(k log n) 。

  • 值语义 + 写时复制(COW)

Heap 使用值语义,采用 Swift 的写时复制机制:当实例唯一时,原地变更;否则先复制后修改,与 ArraySet 等容器行为相同。

  • 不稳定、不保证 FIFO

相同值的元素会被视为相等,但出栈顺序不保证 FIFO。若需要这一特性,应主动添加辅助字段,如序号 。

  • Sequence 协议

Heap 本身不符合 Sequence/Collection,但可以通过 unordered 访问底层元素,意在提醒用户该顺序非排序状态。

使用示例

import HeapModule

var heap: Heap = [4, 5, 2, 2, 1, 3, 0, 0, 0, 2, 6]
print(heap.min!)  // 0
print(heap.max!)  // 6

heap.insert(7)
print(heap.max!)  // 7

while let x = heap.popMin() {
    print(x)
}
// 输出:0 0 0 1 2 2 2 3 4 5 6 7

上述示例展示了最基本的使用方式,包括批量建堆、插入、获取极值和弹出元素。

自定义用法与扩展

如果元素本身不 Comparable,可以通过 extension 来实现 Comparable,如:

class Person {
    let name: String
    let priority: Int
    init(name: String, priority: Int) {
        self.name = name
        self.priority = priority
    }
}

extension Person: Comparable {
    static func < (lhs: Person, rhs: Person) -> Bool {
        lhs.priority < rhs.priority
    }
    
    static func == (lhs: Person, rhs: Person) -> Bool {
        lhs.priority == rhs.priority
    }
}

比如上面的代码,Person 如何没有实现 Comparable 协议,下面的代码会编译报错:

var p1 = Person(name: "jack1", priority: 1)
var p2 = Person(name: "rose", priority: 2)
var p3 = Person(name: "jackson", priority: 3)
var personH: Heap<Person> = [p1, p2, p3] // 编译报错:Type 'Person' does not conform to protocol 'Comparable'

性能分析

  • 创建:O(n),优于 O(n log n) 的重复插入。
  • 插入弹出:O(log n),性能稳定。
  • 堆顶查询:O(1)。
  • 批量操作:根据批次规模,端到端优化效果明显。
  • 写时复制开销:COW 可以避免无意义复制,在 Swift 常见容器中适配良好。

与其他 Collection 类型比较

  • 对比 Array.sorted():适合一次性排序;若要动态插入,效率退化。
  • 对比 Set:无排序语义;
  • 对比 Deque:双端队列但无优先级;
  • 对比 OrderedSet:保证唯一性和顺序但不支持优先弹出;

Flutter Clean 架构中的 Riverpod 在哪里

作者 tangzzzfan
2025年6月25日 08:49

本篇文章与你深入探讨在 Flutter 中如何运用 Clean Architecture,并结合 Riverpod、Go_Router 和 Retrofit 这套现代化工具栈,打造一个高内聚、低耦合、可扩展、易维护的顶级应用架构。

我们将从“道”的层面(高屋建瓴的架构哲学)入手,再深入到“术”的层面(具体分层、组件和痛点解决方案),并最终通过一个实例来将理论落地。


第一部分:高屋建瓴的架构统领 (The "Why" and "How" at 30,000 Feet)

在引入任何具体技术之前,我们必须先确立架构的核心指导思想。对于 Clean Architecture + Riverpod + Go_Router + Retrofit 这套组合拳,其核心思想是:

“通过依赖倒置(Dependency Inversion),实现业务逻辑与外部世界的彻底解耦,并利用现代化的状态管理和路由框架,优雅地将它们粘合起来。”

这个思想可以拆解为三个关键原则:

  1. 同心圆法则 (The Dependency Rule): 这是 Clean Architecture 的灵魂。内层(Domain)绝对不能依赖外层(Infrastructure, Presentation)。所有依赖关系都指向内部。这意味着,你的核心业务逻辑(比如商品折扣如何计算)不应该知道它是被一个 Flutter App 使用,数据是来自一个 REST API,还是存储在 Hive 数据库里。

  2. 职责分离原则 (Separation of Concerns):

    • Clean Architecture 负责 “结构” 的分离:它定义了代码应该放在哪里(Domain, Application, Presentation, Infrastructure)。
    • Riverpod 负责 “状态与依赖” 的分离:它作为依赖注入(DI)容器和服务定位器(SL),将各层实现“注入”到需要它们的地方;同时,它作为状态管理(SM)框架,将 UI 状态与 UI 渲染分离。
    • Go_Router 负责 “UI 与导航” 的分离:它将页面导航逻辑从 UI 组件的调用中抽离出来,变为中心化的、基于路由地址的声明式导航,极大降低了页面间的耦合。
    • Retrofit/Dio 负责 “业务与数据获取” 的分离:它将 HTTP 请求的构造和解析细节封装起来,让我们的数据层实现更专注于“获取什么数据”,而不是“如何获取”。
  3. 面向接口编程 (Programming to an Interface): 这是实现依赖倒置的具体手段。应用层(Application)和基础设施层(Infrastructure)都依赖于领域层(Domain)中定义的抽象接口(Repository Interfaces),而不是具体的实现。这使得我们可以轻松地替换数据来源(比如从网络 API切换到 Mock 数据或本地数据库)而无需修改任何业务逻辑或 UI 代码。

一言以蔽之:我们的目标是构建一个稳定的“业务核心”(Domain + Application),外部的变化(UI框架升级、数据库更换、API变更)不会轻易撼动这个核心。


第二部分:深入阐述各关键点与痛点分析

现在,我们深入到具体的“术”。

2.1 Clean 架构分层详解

一个典型的 Flutter Clean Architecture 项目结构如下:

  • Domain Layer (领域层):

    • 职责: 包含最核心、最纯粹的业务逻辑和业务实体,完全独立于任何框架。
    • 包含内容:
      • Entities (实体): 代表业务对象的类,如 User, Product, Order。它们可以包含只依赖自身属性的业务逻辑(例如,一个 Order 实体可以有一个 isCompleted() 的 getter 方法)。
      • Repositories (仓库接口): 抽象接口,定义了获取和操作业务实体的数据契约。例如,abstract class ProductRepository { Future<Product> getProductById(String id); }。它只定义“做什么”,不定义“怎么做”。
      • Value Objects (值对象):Email, Password 等,用于封装验证逻辑,保证数据的有效性。
    • 特点: 无任何 Flutter/Dart 外部库依赖(除了可能的 equatable 等辅助库)。这是最稳定、最可移植的一层。
  • Application Layer (应用层 / Use Cases):

    • 职责: 编排和调度 Domain 层和 Infrastructure 层,执行具体的应用功能。它代表了“用户想要做什么”。
    • 包含内容:
      • Use Cases (或称 Interactors): 每个 Use Case 代表一个单一的应用功能点。例如 GetProductDetailsUseCase, AddToCartUseCase。它们会调用一个或多个 Repository 接口来完成任务。
    • 特点: 依赖于 Domain 层。它不知道 UI,也不知道数据来自网络还是本地。它只负责协调。例如,AddToCartUseCaseexecute 方法可能会先调用 ProductRepository 获取商品信息,再调用 UserRepository 检查用户资格,最后调用 CartRepository 将商品加入购物车。
  • Presentation Layer (表现层):

    • 职责: 显示 UI,响应用户交互,并将用户行为传递给 Application 层。
    • 包含内容:
      • Widgets/Pages (UI): Flutter 的 StatelessWidgetConsumerWidget。它们应该尽可能“笨”,只负责根据状态渲染 UI 和转发用户事件。
      • State Management (Providers/ViewModels): Riverpod Providers 就在这里扮演核心角色。通常我们会为每个页面或复杂组件创建一个 StateNotifierProviderAsyncNotifierProvider,它扮演着 ViewModel/Controller 的角色。这个 Provider 会调用相应的 Use Case,处理返回结果(成功、失败、加载中),并管理 UI 所需的状态。
      • Navigation (Go_Router): 路由配置和导航逻辑。
  • Infrastructure Layer (基础设施层):

    • 职责: 实现 Domain 层定义的接口,处理所有与外部世界的交互。
    • 包含内容:
      • Repository Implementations: 对 Domain 层 Repository 接口的具体实现。例如,ProductRepositoryImpl 会实现 ProductRepository 接口,其内部会调用一个或多个数据源。
      • Data Sources:
        • Remote Data Source: 使用 RetrofitDio 来访问网络 API。
        • Local Data Source: 使用 Hive, Isar, shared_preferences 等进行本地数据持久化。
      • Other Services: 封装其他平台相关的功能,如设备信息、权限管理、推送通知、分析服务等。

2.2 Riverpod 的角色与层次归属

这是一个常见困惑点。Riverpod 不属于任何单一层,而是贯穿各层的“粘合剂”和“电力系统”

  • 在 Presentation 层:

    • UI 组件 (ConsumerWidget) 通过 ref.watch 来订阅 Provider 暴露的 UI 状态,实现响应式刷新。
    • 用户事件(如按钮点击)通过 ref.readref.notifier 来调用 Provider 中的方法,从而触发业务逻辑。
  • 作为依赖注入 (DI) 容器:

    • 核心作用:main.dart 或应用启动时,我们通过 ProviderScopeoverride 来“组装”我们的应用。
    • 示例:
      // Domain Layer (product_repository.dart)
      abstract class ProductRepository { ... }
      
      // Infrastructure Layer (product_repository_impl.dart)
      class ProductRepositoryImpl implements ProductRepository { ... }
      
      // Application Layer (get_product_usecase.dart)
      class GetProductDetailsUseCase {
        final ProductRepository _repo;
        GetProductDetailsUseCase(this._repo);
        ...
      }
      
      // Riverpod Providers (providers.dart)
      // 1. 提供基础设施层的具体实现
      final productRepositoryProvider = Provider<ProductRepository>((ref) {
        // 在这里可以根据环境返回 Mock 或真实实现
        return ProductRepositoryImpl(ref.watch(dioProvider));
      });
      
      // 2. 提供应用层的 UseCase,并自动注入依赖
      final getProductDetailsUseCaseProvider = Provider<GetProductDetailsUseCase>((ref) {
        // Riverpod 自动处理依赖关系!
        final repository = ref.watch(productRepositoryProvider);
        return GetProductDetailsUseCase(repository);
      });
      
      // 3. 提供表现层的 StateNotifier/ViewModel
      final productDetailsViewModelProvider = StateNotifierProvider.family<...>((ref, productId) {
        // ViewModel 可以访问 UseCase
        final useCase = ref.watch(getProductDetailsUseCaseProvider);
        return ProductDetailsViewModel(useCase, productId);
      });
      
    • 通过这种方式,ProductDetailsViewModel 依赖 GetProductDetailsUseCase,后者又依赖 ProductRepository 接口。但在运行时,Riverpod 悄悄地将 ProductRepositoryImpl 这个具体实现注入了进来,完美实现了依赖倒置。

2.3 项目组件划分:以“四级存储”为例

在实际项目中,一个功能(比如获取用户信息)可能涉及多级数据源。我们可以设计一个经典的四级存储策略:

  1. 一级缓存 (Memory Cache):

    • 实现: Riverpod Provider 本身。使用 AsyncNotifierProvider.autoDispose 或普通的 AsyncNotifierProvider 配合 keepAlive 就可以实现。数据存在内存中,速度最快,但随 Provider 销毁而失效。
    • 场景: 页面内或短时间内的重复数据请求。
  2. 二级缓存 (Local Persistence Cache):

    • 实现: 在 Repository 实现中引入本地数据源(如 Hive/Isar)。
    • 场景: 跨应用会话的数据缓存,如用户配置、不常变动的列表数据。启动应用时可以先从这里加载,给用户即时反馈。
  3. 三级存储 (Network):

    • 实现: 远程数据源,通过 Retrofit 调用 API。
    • 场景: 数据的最终来源(Source of Truth)。
  4. 四级存储 (Bundled/Pre-populated Data):

    • 实现: 打包在 App assets 里的 JSON 或数据库文件。
    • 场景: 初始配置、地区列表、默认数据等。

RepositoryImpl 中的逻辑流可能是这样的:

Future<User> getUser(String userId) async {
  // 1. 尝试从内存缓存获取 (由 Riverpod 的 Provider 缓存策略管理)
  // Riverpod 自身就处理了这部分,如果 provider 还在,就不会重新执行 fetch 逻辑

  // 2. 尝试从本地数据库获取
  final localUser = await _localDataSource.getUser(userId);
  if (localUser != null && !isStale(localUser.timestamp)) {
    return localUser;
  }

  // 3. 从网络获取
  try {
    final remoteUser = await _remoteDataSource.fetchUser(userId);
    // 成功后,更新本地数据库
    await _localDataSource.saveUser(remoteUser);
    return remoteUser;
  } catch (e) {
    // 网络失败,如果本地有旧数据,也可以考虑返回
    if (localUser != null) return localUser;
    // 实在没有,就抛出异常
    throw e;
  }
}

第三部分:痛点识别与解决方案

这是架构实践中最具挑战性的部分。

痛点1:如何区分业务逻辑?(Domain Logic vs. Application Logic)

  • 困惑: 一段逻辑,是应该放在 Entity 里,还是放在 Use Case 里?
  • 解决方案与心法:
    • 问自己一个问题:“这个逻辑是否具有普适性,并且只依赖于实体自身的数据?”

      • 是 -> 放入 Domain Entity。 例如:Order 实体有一个 totalPrice 属性,一个 calculateTotalPrice() 方法根据其 lineItems 列表计算总价。这个计算逻辑是 Order 固有的,不依赖外部服务。
      • 否 -> 放入 Application Use Case。 例如:ApplyCouponToOrderUseCase。这个逻辑需要:1. 获取 Order;2. 获取 Coupon 信息(可能要调 CouponRepository);3. 验证优惠券是否适用于该订单和用户(可能要调 UserRepository);4. 最后计算出新的价格。这个过程是在协调多个实体和仓库,它是一个应用级别的操作,因此属于 Use Case。
    • 简单法则:

      • Domain Logic: 规则和计算。
      • Application Logic: 流程和编排。

痛点2:如何划分模块?(Monolith vs. Feature-based Modules)

  • 困惑: 项目变大后,lib 文件夹变得臃肿不堪。所有功能的代码混在一起,commonshared 文件夹成为“垃圾场”。
  • 解决方案:功能驱动的垂直切分 (Vertical Slicing by Feature)
    • 放弃按层级划分顶级目录 (data, domain, presentation)。 这种方式在小项目里还行,大项目里会导致你为了修改一个功能,不得不在三个相距甚远的文件夹里跳来跳去。
    • 拥抱按功能划分模块。 这是现代大型应用架构的趋势。

第四部分:以一个“可扩展的电商App”为例进行实战演练

项目背景: 一个电商 App,初期只有商品浏览、购物车、用户中心。未来需要快速迭代,加入直播带货、社区分享等新功能。

目标: 设计一个能够支撑这种演进的架构。

项目结构 (采用功能驱动模块化):

flutter_ecommerce_app/
├── lib/
│   ├── **features/**                   # 核心:按功能划分的模块
│   │   ├── **auth/**                   # 认证模块
│   │   │   ├── presentation/
│   │   │   │   ├── screens/login_screen.dart
│   │   │   │   └── providers/auth_providers.dart
│   │   │   ├── application/
│   │   │   │   └── usecases/login_usecase.dart
│   │   │   ├── domain/
│   │   │   │   ├── entities/auth_token.dart
│   │   │   │   └── repositories/auth_repository.dart (interface)
│   │   │   └── infrastructure/
│   │   │       ├── datasources/auth_remote_datasource.dart
│   │   │       └── repositories/auth_repository_impl.dart
│   │   ├── **products/**               # 商品模块 (列表、详情)
│   │   │   └── ... (同样遵循 presentation/app/domain/infra 结构)
│   │   ├── **cart/**                   # 购物车模块
│   │   │   └── ...
│   │   └── **profile/**                # 用户中心模块
│   │       └── ...
│   │
│   ├── **core/**                     # 跨功能共享的核心代码
│   │   ├── **domain/**                 # 共享的 Domain (e.g., User, AppError)
│   │   │   └── entities/user_entity.dart
│   │   ├── **ui/**                     # 共享的 UI 组件 (e.g., PrimaryButton, LoadingIndicator)
│   │   ├── **network/**                # 共享的网络配置 (Dio instance, interceptors)
│   │   │   └── dio_client.dart
│   │   ├── **navigation/**             # 共享的导航配置
│   │   │   └── app_router.dart (Go_Router 配置)
│   │   ├── **storage/**                # 共享的存储封装 (e.g., Hive helper)
│   │   └── **utils/**                  # 共享的工具类 (e.g., formatters, validators)
│   │
│   ├── **main.dart**                   # 应用入口,组装 ProviderScope 和 GoRouter
│   └── **injection_container.dart**    # (可选) 集中管理所有顶层 Provider 的声明
│
└── pubspec.yaml

这个结构的优势:

  1. 高内聚: auth 相关的所有代码都在 features/auth 目录下,从 UI 到数据源一目了然。
  2. 低耦合:
    • products 模块不直接依赖 cart 模块。如果需要交互(比如“添加到购物车”),products 模块的 AddToCartUseCase 会调用 CartRepository 接口。这个接口的实现在 cart 模块中,但依赖关系是 products -> cart/domain,而不是 products -> cart/infrastructure,耦合度很低。
    • 可移除性: 如果老板说“我们不要购物车功能了”,理论上你可以直接删除 features/cart 文件夹,修复一下路由和调用点,应用主体依然能运行。
  3. 可扩展性:
    • 当需要加入“直播带货”功能时,只需新建一个 features/live_streaming 模块,按照同样的结构进行开发。
    • 新模块可以复用 core 里的所有组件,如 core/network 的 Dio 实例,core/ui 的按钮等。
  4. 团队协作: 不同的团队可以并行开发不同的 feature 模块,冲突仅限于 corepubspec.yaml,大大提高了开发效率。

Go_Router 在此结构中的作用:core/navigation/app_router.dart 中,你会定义所有路由:

final GoRouter router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => ProductsListScreen(), // from products feature
    ),
    GoRoute(
      path: '/product/:id',
      builder: (context, state) => ProductDetailsScreen(id: state.pathParameters['id']!), // from products feature
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => LoginScreen(), // from auth feature
    ),
    GoRoute(
      path: '/cart',
      builder: (context, state) => CartScreen(), // from cart feature
    ),
  ],
);

这样,ProductsListScreen 只需调用 context.go('/product/123'),而无需知道 ProductDetailsScreen 的存在,实现了导航解耦。

总结

作为架构师,我们的工作不是选择最“时髦”的技术,而是构建一个能够应对未来不确定性的、有弹性的系统。

  • Clean Architecture 提供了抵御变化的“防火墙”。
  • 功能模块化 提供了应对业务增长的“扩展坞”。
  • Riverpod 提供了连接一切的、灵活高效的“能源和通信网络”。
  • Go_RouterRetrofit 则是这个网络中负责特定任务(导航、网络)的、可替换的“标准化插件”。

下一篇, 将在此基础上, 讲解如何开发 User 模块.

Matrix中WCBlockMonitorMgr 类详解以及流程

2025年6月25日 17:47

Matrix中WCBlockMonitorMgr 类详解以及流程


概述

WCBlockMonitorMgr 是微信开源性能监控框架 wechat-matrix 中用于监控 iOS/macOS 应用主线程卡顿(Block)和 CPU 高使用率的核心管理类。它通过监听主线程 RunLoop 状态,周期性采样调用栈,判断卡顿事件并触发回调,最终生成性能分析报告。该类设计了高效的监控线程,支持多种平台特性和动态配置,具备丰富的回调接口,方便集成和扩展。


目录


涉及到的相关模块

  • 依赖微信Matrix 框架内部多个模块,如:
    • WCMainThreadHandler(调用栈管理)
    • WCCPUHandler(CPU 使用率处理)
    • WCDumpInterface(卡顿报告生成)
    • WCPowerConsumeStackCollector(耗电堆栈采集)
    • WCBlockMonitorConfigHandler(配置管理)
    • WCFilterStackHandler(卡顿事件过滤)
    • 以及日志、设备信息等辅助模块。

CPU 频率测量

  • 仅在 ARM64 架构下实现,利用汇编指令循环计数结合 mach 时间戳,估算 CPU 频率。
  • 通过多次测量取中位数,保证稳定性。
  • 返回 CPU 频率的单位为 GHz。

全局静态变量说明

变量名 说明
g_RunLoopTimeOut 主线程 RunLoop 卡顿阈值,单位微秒,默认值由配置控制。
g_CheckPeriodTime 检测周期时间,单位微秒,通常为阈值的一半。
g_CPUUsagePercent CPU 使用率阈值,超过该值认为 CPU 过高。
g_PerStackInterval 单次调用栈采样间隔,单位微秒,默认约 50ms。
g_StackMaxCount 单次调用栈最大采样帧数。
g_bSensitiveRunloopHangDetection 是否启用敏感的 RunLoop 卡顿检测(iOS 11+)。
g_CurrentThreadCount 当前线程数统计。
g_MainThreadHandle 是否启用主线程调用栈采样。
g_MainThreadProfile 是否启用主线程调用栈性能分析。
g_MainThreadCount 一轮检测中采样调用栈的次数。
g_PointMainThreadArray 当前主线程调用栈快照数组指针。
g_PointMainThreadRepeatCountArray 调用栈重复计数数组。
g_PointMainThreadProfile 主线程调用栈性能分析数据。
g_PointCPUHighThreadArray CPU 使用率高线程调用栈快照数组。
g_PointCpuHighThreadCount CPU 高线程数量。
g_PointCpuHighThreadValueArray CPU 高线程对应的 CPU 使用率数组。
g_thermalState iOS 11+ 设备热状态。
g_tvRun RunLoop 最近运行时间戳。
g_bRun RunLoop 是否处于运行状态标志。
g_lastCheckTime 上次检测时间戳。
g_bLaunchOver 应用启动是否完成标志。
g_bBackgroundLaunch 是否为后台启动。

类接口与属性

@interface WCBlockMonitorMgr : NSObject <WCPowerConsumeStackCollectorDelegate>

@property (nonatomic, weak) id<WCBlockMonitorDelegate> delegate;
@property (nonatomic, strong) WCBlockMonitorConfigHandler *monitorConfigHandler;

#if TARGET_OS_OSX
@property (nonatomic, strong) NSApplicationEvent *eventHandler;
#endif

+ (WCBlockMonitorMgr *)shareInstance;

- (void)resetConfiguration:(WCBlockMonitorConfiguration *)bmConfig;
- (void)start;
- (void)stop;

// iOS 特有:后台启动及挂起处理
- (void)handleBackgroundLaunch;
- (void)handleSuspend;

// CPU 监控控制
- (void)startTrackCPU;
- (void)stopTrackCPU;
- (BOOL)isBackgroundCPUTooSmall;

// RunLoop 阈值动态调整
- (BOOL)setRunloopThreshold:(useconds_t)threshold;
- (BOOL)lowerRunloopThreshold;
- (BOOL)recoverRunloopThreshold;

// 是否挂起所有线程
- (void)setShouldSuspendAllThreads:(BOOL)shouldSuspendAllThreads;

// 自定义卡顿报告生成
- (void)generateLiveReportWithDumpType:(EDumpType)dumpType withReason:(NSString *)reason selfDefinedPath:(BOOL)bSelfDefined;

// 获取当前卡顿报告自定义用户信息
- (NSDictionary *)getUserInfoForCurrentDumpForDumpType:(EDumpType)dumpType;

#if TARGET_OS_OSX
+ (void)signalEventStart;
+ (void)signalEventEnd;
#endif

@end

核心功能详解

单例获取与初始化

  • 使用 GCD dispatch_once 保证单例线程安全。
  • 初始化时创建异步串行队列,设置默认停止状态。
  • 释放时释放 RunLoop 观察者,移除通知监听,释放调用栈缓存。

配置管理

  • 通过 resetConfiguration: 方法注入配置对象 WCBlockMonitorConfiguration
  • 配置包括卡顿阈值、CPU 使用率阈值、采样间隔、是否打印日志、是否启用耗电堆栈采集等。
  • 配置会影响内部静态变量和监控参数。

启动与停止监控

  • start 方法初始化参数,添加 RunLoop 观察者,创建监控线程并启动。
  • 监听应用生命周期通知(iOS),如进入后台、激活、终止等,调整监控策略。
  • stop 方法停止监控线程,移除 RunLoop 观察者,等待监控线程退出。

RunLoop 观察者管理

  • 通过 CFRunLoopObserverCreate 创建两个观察者,分别监听 RunLoop 入口和退出阶段。
  • 观察者回调更新全局运行状态 g_bRun 和时间戳 g_tvRun
  • 支持普通模式和初始化模式(iOS UIInitializationRunLoopMode)。
  • 在敏感模式下,RunLoop 即将等待时检测卡顿。

监控线程主循环

  • 监控线程执行 threadProc 方法,循环检测卡顿。
  • 先睡眠启动延迟,避免启动时干扰。
  • 每轮调用 check 判断卡顿类型。
  • 根据检测结果决定是否采样调用栈和生成报告。
  • 通过代理回调通知外部监控状态和报告文件路径。
  • 线程安全管理调用栈数据和状态。

卡顿检测逻辑

  • 判断 RunLoop 是否超时(当前时间 - 最近一次 RunLoop 活动时间 > 阈值)。
  • 判断应用是否处于后台,区别不同卡顿类型。
  • 判断 CPU 使用率是否超过阈值,结合耗电堆栈采集器状态决定是否生成 CPU 卡顿报告。
  • 打印 CPU 和内存使用情况,超过阈值触发回调。
  • 敏感模式下额外检测 RunLoop 挂起时长。

调用栈采样

  • 根据配置的采样间隔,周期性采样主线程调用栈。
  • 采样时调用 WCGetMainThreadUtil 获取当前调用栈,存入 WCMainThreadHandler 管理的循环队列。
  • 采样次数和间隔根据卡顿阈值动态调整。

卡顿事件过滤

  • 通过比较当前调用栈与上一次调用栈,判断是否重复。
  • 采用退火算法动态调整检测间隔,避免重复上报。
  • 通过 WCFilterStackHandler 控制每日上报次数,防止过度报警。
  • 过滤无效(调用栈长度过短)和重复卡顿事件。

卡顿报告生成

  • 通过 WCDumpInterface 生成卡顿报告文件,支持挂起所有线程确保快照一致。
  • 支持启动阶段卡顿特殊处理,保存启动卡顿记录。
  • 支持异步写文件和自定义路径。
  • 生成报告时通过代理回调通知外部。

后台与前台状态处理

  • 监听 UIApplication 生命周期通知,更新当前应用状态。
  • 后台启动时清理历史卡顿文件,避免误报。
  • 前台激活时重置启动标志,允许正常检测。
  • 支持后台启动和挂起事件的特殊处理。

CPU 和内存使用监控

  • 周期性采集应用和设备 CPU 使用率。
  • 通过 WCCPUHandler 统计 CPU 平均值,判断是否过高。
  • 采集内存占用,超过阈值触发回调。
  • 支持打印设备热状态(iOS 11+)。
  • 支持耗电堆栈采集,分析高耗电代码路径。

热状态监听

  • iOS 11+ 监听 NSProcessInfoThermalStateDidChangeNotification
  • 设备热状态升高时回调代理,方便开发者优化性能。

动态阈值调整

  • 支持动态调整 RunLoop 卡顿阈值,范围限制在 400ms 到 2s。
  • 阈值调整后更新检测周期和采样次数。
  • 支持降低阈值以提高检测灵敏度,或恢复默认阈值。

耗电堆栈采集委托

  • 实现 WCPowerConsumeStackCollectorDelegate,异步保存耗电堆栈报告。
  • 生成 JSON 格式报告,保存到指定路径。

代理回调接口说明

@protocol WCBlockMonitorDelegate <NSObject>

@required
- (void)onBlockMonitor:(WCBlockMonitorMgr *)bmMgr enterNextCheckWithDumpType:(EDumpType)dumpType;
- (void)onBlockMonitor:(WCBlockMonitorMgr *)bmMgr beginDump:(EDumpType)dumpType blockTime:(uint64_t)blockTime runloopThreshold:(useconds_t)runloopThreshold;
- (void)onBlockMonitor:(WCBlockMonitorMgr *)bmMgr dumpType:(EDumpType)dumpType filter:(EFilterType)filterType;
- (void)onBlockMonitor:(WCBlockMonitorMgr *)bmMgr getDumpFile:(NSString *)dumpFile withDumpType:(EDumpType)dumpType;
- (NSDictionary *)onBlockMonitor:(WCBlockMonitorMgr *)bmMgr getCustomUserInfoForDumpType:(EDumpType)dumpType;
- (void)onBlockMonitorCurrentCPUTooHigh:(WCBlockMonitorMgr *)bmMgr;
- (void)onBlockMonitorIntervalCPUTooHigh:(WCBlockMonitorMgr *)bmMgr;
- (void)onBlockMonitorThermalStateElevated:(WCBlockMonitorMgr *)bmMgr;
- (void)onBlockMonitorMainThreadBlock:(WCBlockMonitorMgr *)bmMgr;
- (void)onBlockMonitorMemoryExcessive:(WCBlockMonitorMgr *)bmMgr;
- (void)onBlockMonitor:(WCBlockMonitorMgr *)bmMgr runloopHangDetected:(uint64_t)duration;

@end
  • 通过这些方法,外部可以获得卡顿检测状态、报告生成通知、CPU 和内存异常警告、设备热状态变化等信息。
  • 方便业务侧根据性能异常做日志上报、用户提示或自动恢复措施。

辅助工具方法

  • + (unsigned long long)diffTime:(struct timeval *)tvStart endTime:(struct timeval *)tvEnd;
    计算两个时间戳之间的微秒差值。

  • 多个全局函数用于提供调用栈快照和性能分析数据给外部模块。

  • 支持 macOS 平台的事件信号通知。


大致流程

Mermaid Chart - Create complex, visual diagrams with text. A smarter way of creating diagrams.-2025-06-24-112321.png

总结

WCBlockMonitorMgr 是微信 Matrix 性能监控框架中极其重要的核心类,负责主线程卡顿检测和 CPU 监控。它设计了完善的监控流程和机制,具备丰富的配置选项和回调接口,支持 iOS/macOS 多平台,适用于复杂的性能监控场景。通过合理的采样、过滤和报告机制,能够有效捕获应用运行中的卡顿和高 CPU 使用问题,为性能优化提供有力支持。


参考:

matrix-wechat

iOS 使用 Objective-C 与蓝牙设备连接通信详解(CoreBluetooth)

作者 tangbin583085
2025年6月25日 16:37

在开发与硬件设备(如智能手环、健康监测设备、BLE模块)交互的 iOS 应用时,蓝牙低功耗(Bluetooth Low Energy, 简称 BLE)通信是最常见的方式之一。

本文将以 Objective-C 为基础语言,介绍我在工作中如何使用 CoreBluetooth 框架进行 BLE 设备连接、服务发现、数据通信等功能,适用于需要通过 iOS 设备连接蓝牙设备的开发者,有需要的朋友可以参考一下。

  • App 添加蓝牙权限(Info.plist 中配置):
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接外设</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>蓝牙功能用于连接设备</string>
  • 引入框架:
#import <CoreBluetooth/CoreBluetooth.h>

基本流程概览

  1. 初始化 CBCentralManager
  2. 扫描设备
  3. 连接设备
  4. 发现服务和特征
  5. 读写数据
  6. 接收通知

核心代码示例(基于 OC)

一、初始化和开始扫描设备

@interface BluetoothManager : NSObject <CBCentralManagerDelegate, CBPeripheralDelegate>

@property (nonatomic, strong) CBCentralManager *centralManager;
@property (nonatomic, strong) CBPeripheral *connectedPeripheral;

@end

@implementation BluetoothManager

- (instancetype)init {
    self = [super init];
    if (self) {
        // 初始化中心管理器
        self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil];
    }
    return self;
}

// 中心管理器状态更新回调
- (void)centralManagerDidUpdateState:(CBCentralManager *)central {
    if (central.state == CBManagerStatePoweredOn) {
        NSLog(@"蓝牙已开启,开始扫描设备");
        [self.centralManager scanForPeripheralsWithServices:nil options:nil];
    } else {
        NSLog(@"蓝牙不可用,状态:%ld", (long)central.state);
    }
}



二、发现并连接蓝牙设备


// 扫描到外设
- (void)centralManager:(CBCentralManager *)central
 didDiscoverPeripheral:(CBPeripheral *)peripheral
     advertisementData:(NSDictionary<NSString *, id> *)advertisementData
                  RSSI:(NSNumber *)RSSI {
    
    NSLog(@"发现设备:%@ - RSSI: %@", peripheral.name, RSSI);
    
    // 这里建议使用名称/UUID过滤设备
    if ([peripheral.name containsString:@"MyBLE"]) {
        self.connectedPeripheral = peripheral;
        self.connectedPeripheral.delegate = self;
        
        [self.centralManager stopScan];
        [self.centralManager connectPeripheral:peripheral options:nil];
    }
}

三、 连接成功并发现服务和特征

// 成功连接设备
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral {
    NSLog(@"已连接设备:%@", peripheral.name);
    [peripheral discoverServices:nil]; // 发现所有服务
}

// 发现服务后
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)error {
    for (CBService *service in peripheral.services) {
        NSLog(@"发现服务:%@", service.UUID);
        [peripheral discoverCharacteristics:nil forService:service];
    }
}

// 发现特征后
- (void)peripheral:(CBPeripheral *)peripheral
didDiscoverCharacteristicsForService:(CBService *)service
             error:(NSError *)error {
    
    for (CBCharacteristic *characteristic in service.characteristics) {
        NSLog(@"发现特征:%@", characteristic.UUID);
        
        // 订阅通知
        if (characteristic.properties & CBCharacteristicPropertyNotify) {
            [peripheral setNotifyValue:YES forCharacteristic:characteristic];
        }
        
        // 读取初始值
        if (characteristic.properties & CBCharacteristicPropertyRead) {
            [peripheral readValueForCharacteristic:characteristic];
        }
    }
}

四、 接收数据与写入数据

// 接收到外设返回的数据
- (void)peripheral:(CBPeripheral *)peripheral
 didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic
             error:(NSError *)error {
    
    NSData *data = characteristic.value;
    NSString *hexStr = [ParseDataTool transDataToHexString:data];
    NSLog(@"接收到数据: %@", hexStr);
}

// 向设备写入数据
- (void)writeData:(NSData *)data toCharacteristic:(CBCharacteristic *)characteristic {
    [self.connectedPeripheral writeValue:data
                        forCharacteristic:characteristic
                                     type:CBCharacteristicWriteWithResponse];
}

示例:发送指令并接收蓝牙数据包

// 假设你已有某个特征 characteristic
NSString *cmdHex = @"A102B3";
NSData *cmdData = [ParseDataTool transToDataWithString:cmdHex];
[self writeData:cmdData toCharacteristic:characteristic];

常见注意事项

  • 中央设备只能连接一个外设,多连接需管理连接状态。
  • 蓝牙通信中数据格式严格,建议用工具类(如 ParseDataTool)处理数据。 (我上一篇文章中写到的)
  • 注意连接断开要做清理操作,并考虑重连逻辑。

对于设备交互类 App(如智能穿戴、健康监测、工业控制)来说,这是非常不错的开发知识分享。如有说错的地方,欢迎大家指正。

iOS客户端(1)—charles无法抓包的问题

2025年6月25日 16:11

多年前charles抓包iOS的时候,记得只需要安装证书即可。最近重新android抓包遇到了高版本手机用户证书无法被信任,需要root,但又懒得去root了,所以用自用的iOS设备抓包试试,昨天试了一下午,发现始终无法抓包成功,所有https的请求都没成功。

解决过程记录一下:

1、iOS设备安装证书过程中无法访问chrls/pro

正常情况下我们需要用手机和电脑在一个局域网下面,点击如下所示项: image.png

然后会弹出弹框,让你在手机上配置代理地址和端口(默认是8888),然后浏览器访问chls.pro/ssl 去下载证书。

image.png

我这里遇到了无法访问chls.pro/ssl 的问题,想到是否端口冲突,改了一个端口8898依然无法访问。

搜一下一般还会让你直接去访问电脑的http://ip:端口/ssl 或者访问另一个chls地址(猜测是高版本charles软件中更新了一个地址)。

我都试了,iOS设备还是无法访问。

最后,其实可以在电脑上保存证书为.cer文件:

image.png

.cer的文件可以兼容iOS和安卓。

image.png

直接隔空投送给iOS设备,iOS设备即可安装该描述文件,安装完后,需要信任证书为已验证状态。

image.png

2、iOS设备安装了证书还是无法成功抓包

按照上述安装完后,再去charles,发现还是无法抓包成功,所有的https请求依然无法抓包。

原来高版本的iOS设备(比如我的17)安装了描述文件后,还需要在通用-关于本机-证书信任设置中,勾选上信任charles证书。

7d8f0c9e337704a79885e4221f06958a.jpg

3、Allow List配置移除

Allow List如果勾选,则只有这里面配置的域名才能访问。

image.png

昨天无意间发现勾选上了,所以这里移除掉:

image.png

4、charles不配置抓包域名无法抓包

然后再去抓包,发现还是无法成功,我又检查了一遍macos proxy勾选上了。

image.png

Proxy Settings里面也没问题: image.png

image.png

于是我又来到了ssl proxying settings里面,按理说我已经配置了 : 包含了所有的域名都抓取,不用再单独配置了,但我还是将要抓包的接口域名再次配置进去了,重新抓包成功了。

image.png

留下一个疑点

昨天我的Android13 小米抓包是成功过的,但是到了抓游戏内容的时候,失败了,然后再试,之前已经抓包成功的api也都无法再抓包成功了。

这里有两个问题:

1、我的Android13是没有root的,charles证书也是安装在用户证书里面的,按理说无法被信任即无法抓包,但是我昨天的确抓包成功了。

2、为什么某一个了内容抓包失败后,再次抓包,所有的api都无法访问成功了。

Swift 5.9 与 SwiftUI 5.0 中新 Observation 框架应用之深入浅出

2025年6月25日 16:03

在这里插入图片描述

0. 概览

Swift 5.9 一声炮响为我们带来全新的宏(Macro)机制,也同时带来了干霄凌云的 Observation 框架。

在这里插入图片描述

Observation 框架可以增强通用场景下的使用,也可以搭配 SwiftUI 5.0 而获得双剑合璧的更强威力。

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

  1. @Observable 宏
  2. 通用情境下如何观察 Observable 对象?
  3. Observable 对象与 SwiftUI 珠联璧合
  4. 被“抛弃的” @EnvironmentObject
  5. 在视图中将不可变 Observable 对象转换为可变对象的妙招

那么,就让我们赶快进入 Observation 奇妙的世界吧!

Let‘s go!!!;)


1. @Observable 宏

简单来说,Observation 框架为我们提供了集鲁棒性(robust)、安全性、高性能等三大特性为一身的 Swift 全新观察者设计模式。

它的核心功能在于:监视对象状态,并在改变时做出反应!

在这里插入图片描述

在 Swift 5.9 中,我们可以非常轻松的通过 @Observable 宏将普通类“转化为”可观察(Observable)类。自然,它们的实例都是可观察的:

@Observable
final class Hero {
    var name: String
    var power: Int
    
    init(name: String, power: Int) {
        self.name = name
        self.power = power
    }
}

@Observable
final class Model {
    var title: String
    var createAt: Date?
    var heros: [Hero]
    
    init(title: String, heros: [Hero]) {
        self.title = title
        self.createAt = Date.now
        self.heros = heros
    }
}

如上代码所示,我们定义了两个可观察类 Model 和 Hero,就是这么简单!

2. 通用情境下如何观察 Observable 对象?

在一个对象成为可观察之后,我们可以通过 withObservationTracking() 方法随时监听它状态的改变:

在这里插入图片描述

我们可以将对象需要监听的属性放在 withObservationTracking() 的 apply 闭包中,当且仅当( Hero 中其它属性的改变不予理会)这些属性发生改变时其 onChange 闭包将会被调用:

let hero = Hero(name: "大熊猫侯佩", power: 5)

func watching() {
    withObservationTracking({
        NSLog("力量参考值:\(hero.power)")
    }, onChange: {
        NSLog("改变之前的力量!:\(hero.power)")
        watching()
    })
}

watching()

hero.name = "地球熊猫"
hero.power = 11
hero.power = 121

以上代码输出如下:

在这里插入图片描述

使用 withObservationTracking() 方法有 3 点需要注意:

  1. 它默认只会被调用 1 次,所以上面为了能够重复监听,我们在 onChange 闭包里对 watching() 方法再次进行了调用;
  2. withObservationTracking() 方法的 apply 闭包不管如何都会被调用 1 次,即使其监听的属性从未改变过;
  3. 在监听闭包中只能得到属性改变前的旧值;

目前,上面测试代码在 Xcode 15 的 Playground 中编译会报错,提示如下:

error: test15.playground:8:13: error: external macro implementation type 'ObservationMacros.ObservableMacro' could not be found for macro 'Observable()' final class Hero { ^

Observation.Observable:2:180: note: 'Observable()' declared here @attached(member, names: named(_$observationRegistrar), named(access), named(withMutation)) @attached(memberAttribute) @attached(extension, conformances: Observable) public macro Observable() = #externalMacro(module: "ObservationMacros", type: "ObservableMacro")

小伙伴们可以把它们放在 Xcode 的 Command Line Tool 项目中进行测试:

在这里插入图片描述


3. Observable 对象与 SwiftUI 珠联璧合

要想发挥 Observable 对象的最大威力,我们需要 SwiftUI 来一拍即合。

在 SwiftUI 中,我们无需再显式调用 withObservationTracking() 方法来监听改变,如虎添翼的 SwiftUI 已为我们自动完成了所有这一切!

struct ContentView: View {
    let model = Model(title: "地球超级英雄", heros: [])

    var body: some View {        
        NavigationStack {
            Form {
                LabeledContent(content: {
                    Text(model.title)
                }, label: {
                    Text("藏匿点名称")
                })
                
                LabeledContent(content: {
                    Text(model.createAt?.formatted(date: .omitted, time: .standard) ?? "无")
                }, label: {
                    Text("更新时间")
                })
                
                Button("刷新") {
                    // SwiftUI 会自动监听可观察对象的改变,并刷新界面
                    model.title = "爱丽丝仙境兔子洞"
                    model.createAt = Date.now
                }
            }.navigationTitle(model.title)
        }
    }
}

注意,上面代码中 model 属性只是一个普通的 let 常量,即便如此 model 的改变仍会反映到界面上:

在这里插入图片描述

4. 被“抛弃的” @EnvironmentObject

有了 Swift 5.9 中新 Observation 框架加入游戏,在 SwiftUI 5.0 中 EnvironmentObject 再无用武之地,我们仅用 Environment 即可搞定一切!

早在 SwiftUI 1.0 版本时,其就已经提供了 Environment 对应的构造器:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper public struct Environment<Value> : DynamicProperty {...}

有了新 Observation 框架的入驻,结合其 Observable 可观察对象,Environment 可以再次大放异彩:

struct HeroListView: View {
    @Environment(Model.self) var model
    
    var body: some View {
        List(model.heros) { hero in
            HStack {
                Text(hero.name)
                    .font(.headline)
                Spacer()
                Text("\(hero.power)")
                    .font(.subheadline)
                    .foregroundStyle(.gray)
            }
        }
    }
}

struct ContentView: View {
    @State var model = Model(title: "地球超级英雄", heros: [
        .init(name: "大熊猫侯佩", power: 5),
        .init(name: "孙悟空", power: 1000),
        .init(name: "哪吒", power: 511)
    ])

    var body: some View {        
        NavigationStack {
            Form {
                NavigationLink(destination: HeroListView().environment(model)) {
                    Text("查看所有英雄")
                }
            }.navigationTitle(model.title)
        }
    }
}

现在,即使跨越多重层级关系我们也可以只通过 @Environment 而不用 @EnvironmentObject 来完成状态的间接传递了,是不是很赞呢?👍🏻

5. 在视图中将不可变 Observable 对象转换为可变对象的妙招

介绍了以上这许多,就还剩一个主题没有涉及:Observable 对象的可变性!

为了能够在子视图中更改对应的可观察对象,我们可以用 @Bindable 修饰传入的 Observable 对象:

struct HeroView: View {
    @Bindable var hero: Hero
    
    var body: some View {
        Form {
            TextField("名称", text: $hero.name)
            
            TextField("力量", text: .init(get: {
                String(hero.power) 
            }, set: {
                hero.power = Int($0) ?? 0
            }))
        }
    }
}

不过,对于之前 @Environment 那个例子来说,如何达到子视图能够修改传入的 @Environment 可观察对象呢?

别急,我们可以利用称为“临时可变(Temporary Variable)”的技术将原先不可变的可观察对象改为可变:

extension Hero: Identifiable {
    var id: String {
        name
    }
}

struct HeroListView: View {
    @Environment(Model.self) var model
    
    var body: some View {
        // 在 body 内将 model 改为可变
        @Bindable var model = model
        
        VStack {
            List(model.heros) { hero in
                HStack {
                    Text(hero.name)
                        .font(.headline)
                    Spacer()
                    Text("\(hero.power)")
                        .font(.subheadline)
                        .foregroundStyle(.gray)
                }
            }.safeAreaInset(edge: .bottom) {
            // 绑定可变 model 中的状态以修改英雄名称
                TextField("", text: $model.heros[0].name)
                    .padding()
            } 
        }
    }
}

运行效果如下:

在这里插入图片描述

“临时可变”这一技术可以用于视图中任何化“不变”为“可变”的场景中,当然对于直接视图间对象的传递,我们可以使用 @Bindable 这一更为“正统”的方法。

6. 总结

在本篇博文中,我们讨论了在 Swift 5.0 和 SwiftUI 5.0 中大放异彩 Observation 框架的使用,并就诸多技术细节问题给与了详细的介绍,愿君喜欢。

感谢观赏,再会!8-)

SwiftUI 4.0:两种方式实现子视图导航功能

2025年6月25日 15:58

在这里插入图片描述

0. 概览

从 SwiftUI 4.0 开始,觉悟了的苹果毅然抛弃了已“药石无效”的 NavigationView,改为使用全新的 NavigationStack 视图。

诚然,NavigationStack 从先进性来说比 NavigationView 有不小的提升,若要如数家珍得单开洋洋洒洒的一篇来介绍。


关于 SwiftUI 中旧 NavigationView 视图种种人神共愤的“诟病”和弊端,请移步我的其它专题博文观赏:


不过,这些都不是本篇的主旨。在本篇中我们将尝试一起另辟蹊径来完成两种截然不同的导航机制。

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

  1. NavigationStack
  2. NavigationSplitView 导航之“假象”
  3. 洞若观火:在 iPad 上的比较

无需等待,Let’s go!!!;)


1. NavigationStack

从 SwiftUI 4.0 开始, 引入的新 NavigationStack 导航器终于不再以分散杂乱的数据作为导航触发媒介,而是将有序的数据集合作为导航跳转的核心来对待!(所以,一步跳回根视图成了雕虫小技)

其中,NavigationStack 导航器重要的组成部分 NavigationLink 除了为了兼容性暂时保留的传统跳转方式以外,主打以状态本身的值作为导航的基石。这样,对于不同类型状态触发的跳转,我们可以干净而从容的分别处理:

下面举一例。

首先,定义简单的数据结构,Alliance 中包含若干 Hero:

@Observable
final class Hero {
    var name: String
    var power: Int
    
    init(name: String, power: Int) {
        self.name = name
        self.power = power
    }
}

extension Hero: Identifiable {
    var id: String {
        name
    }
}

extension Hero: Hashable {
    static func == (lhs: Hero, rhs: Hero) -> Bool {
        lhs.name == rhs.name && lhs.power == rhs.power
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
        hasher.combine(power)
    }
}


@Observable
final class Alliance: Hashable {
    static func == (lhs: Alliance, rhs: Alliance) -> Bool {
        lhs.title == rhs.title
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(title)
    }
    
    var title: String
    var createAt: Date?
    var heros: [Hero]
    
    init(title: String, heros: [Hero]) {
        self.title = title
        self.createAt = Date.now
        self.heros = heros
    }
}

接下来是与之对应的子视图,注意驱动 NavigationLink 导航的是 Hero 本身,而不是什么“莫名奇妙”的条件变量:

struct HeroDetailView: View {
    let hero: Hero
    
    var body: some View {
        VStack {
            Text("力量: \(hero.power)")
                .font(.largeTitle)
            
        }.navigationTitle(hero.name)
    }
}

struct HeroListView: View {
    @Environment(Alliance.self) var model
    
    var body: some View {        
        VStack {
            List(model.heros) { hero in
                NavigationLink(value: hero) {
                    HStack {
                        Text(hero.name)
                            .font(.headline)
                        Spacer()
                        Text("\(hero.power)")
                            .font(.subheadline)
                            .foregroundStyle(.gray)
                    }
                }
            }
        }
    }
}

接着是主视图:

struct ContentView: View {
    @State var model = Alliance(title: "地球超级英雄", heros: [
        .init(name: "大熊猫侯佩", power: 5),
        .init(name: "孙悟空", power: 1000),
        .init(name: "哪吒", power: 511)
    ])

    var body: some View {
        NavigationStack {
            Form {
                NavigationLink("查看所有英雄", value: model)
            }
            .navigationDestination(for: Alliance.self) { model in
                HeroListView()
                    .environment(model)
            }
            .navigationDestination(for: Hero.self) { hero in
                HeroDetailView(hero: hero)
            }
            .navigationTitle(model.title)
        }
    }
}

从上面源代码中,我们可以看到几处有趣的地方:

  1. 子视图 HeroListView 和主视图 ContentView 都包含了 NavigationLink,但它们驱动状态的类型不一样(分别是 Hero 和 Model),这样不同的驱动源被清晰的区分开了;
  2. 放置 NavigationLink 和实际发生导航跳转的目标位置是分开的(通过 navigationDestination() 修改器),后者被放在了一起便于集中管理;

正是这些新的导航特性确保了导航逻辑代码清楚且集中,为日后自己或其它秃头码农来维护打下夯实基础:

在这里插入图片描述

以上就是第一种导航方法,即利用 NavigationStack + navigationDestination() 修改器方法来合作完成跳转功能。

2. NavigationSplitView 导航之“假象”

可能有的小伙伴们没太在意,SwiftUI 4.0 除了 NavigationStack 以外还新加入了另一个鲜为人知的导航器 NavigationSplitView!使用它,我们可以抛弃 navigationDestination() 去实现完全相同的导航功能。

我们对之前代码略作修改,看看能促成什么新奇的“玩法”:

struct HeroListView: View {
    @Environment(Alliance.self) var model
    @Binding var selection: Hero?
    
    var body: some View {        
        VStack {
            List(model.heros, selection: $selection) { hero in
                NavigationLink(value: hero) {
                    HStack {
                        Text(hero.name)
                            .font(.headline)
                        Spacer()
                        Text("\(hero.power)")
                            .font(.subheadline)
                            .foregroundStyle(.gray)
                    }
                }
            }
        }
    }
}

struct ContentView: View {
    @State var model = Alliance(title: "地球超级英雄", heros: [
        .init(name: "大熊猫侯佩", power: 5),
        .init(name: "孙悟空", power: 1000),
        .init(name: "哪吒", power: 511)
    ])
    
    @State private var selection: Hero?

    var body: some View {
        NavigationSplitView(sidebar: {
            HeroListView(selection: $selection)
                .environment(model)
                .navigationTitle("新导航方式")
        }, detail: {
            if let selection {
                HeroDetailView(hero: selection)
            } else {
                ContentUnavailableView("No Hero", systemImage: "person.badge.key.fill", description: Text("还未选中任何英雄!"))

            }
        })
    }
}

可以看到,修改后的代码与之前有几处不同:

  1. 使用 NavigationSplitView 而不是 NavigationStack;
  2. 没有使用任何 navigationDestination() 修改器方法;
  3. 向 List 构造器传入了 selection 参数,以判断用户选择了哪个 Hero;
  4. 根据 selection 的值驱动 NavigationSplitView 构造器 detail 闭包完成跳转功能;

简单来说:当用户选中任意 Hero 后,通过设置 NavigationSplitView 构造器 detail 闭包中的视图,我们完成了导航机制。

代码执行结果和之前几乎完全相同,这么神奇!?

可惜,你们看到的全是“假象”!!!

3. 洞若观火:在 iPad 上的比较

其实,设置 NavigationSplitView 构造器 detail 闭包的内容原本并不会造成导航跳转,它的原意是在大屏设备上更方便的利用大尺寸屏幕来浏览内容。

编译上面 NavigationSplitView 的实现,在 iPad 上运行看看效果:

在这里插入图片描述

看到了吗?第二种导航机制在大屏设备上原本并不是真正用来导航跳转的,只是在 iPhone 等小屏设备上它的行为退化成了导航!

而第一种导航实现是彻头彻尾、如假包换的“真”导航:

在这里插入图片描述

到底哪种方法更好一些?小伙伴们自有“仁者见仁智者见智”的看法,欢迎大家随后的讨论。

至此,我们完成了文章开头的目标,棒棒哒!!!💯

4. 总结

在本篇博文中,我们在 SwiftUI 4.0 里通过两种不同方式实现了相同的子视图导航功能,任君选择。

感谢观赏,再会!8-)

SwiftUI 代码调试之都是“变心”惹的祸

2025年6月25日 15:53

在这里插入图片描述

0. 概览

这是一段非常简单的 SwiftUI 代码,我们将 Item 数组传递到子视图并在子视图中对其进行修改,修改的结果会立即在主视图中反映出来。

在这里插入图片描述

不幸的是,当我们修改 Item 名称时却发现不能连续输入:每次敲一个字符键盘都会立即收起并且原输入焦点会马上丢失,这是怎么回事呢?

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

  1. 不该发生的错误
  2. 无效的尝试:用子视图包装
  3. 寻根究底
  4. 解决之道

该问题这是初学者在 SwiftUI 开发中常常会犯的一个错误,不过看完本篇之后相信大家都会对此自胸有成竹!

废话不再,Let‘s fix it!!!;)


1. 不该发生的错误

照例我们先看一下源代码。

例子中我们创建了 Item 结构用来作为 Model 中的“真相之源”。


想要了解更多 SwiftUI 编程和“真相之源”奥秘的小伙伴们,请观赏我专题专栏中的如下文章:


注意,我们让 Item 遵守了 Identifiable 协议,这样可以更好的适配 SwiftUI 列表中的显示:

struct Item: Identifiable {
    
    var id: String {
        name
    }
    
    var name: String
    var count: Int
}

let g_items: [Item] = [
    .init(name: "宇宙魔方", count: 11),
    .init(name: "宝石手套", count: 1),
    .init(name: "大黄蜂", count: 1)
]

接下来是主视图 ItemListView,可以看到我们将 items 状态传递到子视图的 ForEach 循环中去了:

struct ItemListView: View {
    @State var items = g_items
    
    private var total: Int {
        items.reduce(0) { $0 + $1.count}
    }
    
    private var desc: [String] {
        items.reduce([String]()) { $0 + [$1.name]}
    }
    
    var body: some View {
        NavigationStack {
            // 子视图 ForEach 循环...
            ForEach($items) { $item in
// 代码马上来...
}
            
            VStack {
                Text(desc.joined(separator: ","))
                    .font(.title3)
                    .foregroundStyle(.pink)
                HStack {
                    Text("宝贝总数量:\(total)")
                        .font(.headline)
                    
                    Spacer().frame(width: 20)
                    
                    Button("所有 +1"){
                        for idx in items.indices {
                            guard items[idx].count < 100 else { continue}
                            
                            items[idx].count += 1
                        }
                    }
                    .font(.headline)
                    .buttonStyle(.borderedProminent)
                }
            }.offset(y: 200)
        }
    }
}

最后是 ForEach 循环中的内容,如下所示我们用单个 item 的值绑定来实现修改其内容的目的:

ForEach($items) { $item in
    HStack {
        
        TextField("输入项目名称", text: $item.name)
            .font(.title2.weight(.heavy))
        
        
        Text("数量:\(item.count)")
            .foregroundStyle(.gray)
        
        Slider(value: .init(get: {
            Double(item.count)
        }, set: {
            item.count = Int($0)
        }), in: 0.0...100.0)
    }
}
.padding()

这样一段看起来“天衣无缝”的代码为什么会出现在更改 Item 名称时键盘反复关闭、输入焦点丢失的问题呢?

2. 无效的尝试:用子视图包装

我们首先猜测是子视图中 Item 名称的更改导致了父视图的“冗余”刷新,从而引起键盘不正确被重置。


更多 SwiftUI 和 Swift 代码调试的例子,请观赏我专题专栏中的博文:


因为键盘所属的视图发生重建所以键盘本身也会被重置,那么如何验证我们的猜测呢?一种方式是使用如下的调试技术:

在这里我们假设病根果真如此。那么一种常用的解决办法立即浮现于脑海:我们可以将引起刷新的子视图片段包装在新的 View 结构中,这样做到原因是 SwiftUI 渲染器足够智能可以只刷新子视图而不是父视图中大段内容的更改。


更详细的原理请参考如下链接:


So,让我撸起袖子开动起来!

首先,将 ForEach 循环中编辑单个 Item 的 View 包装为一个新的视图 ItemEditView:

struct ItemEditView: View {
    @Binding var item: Item
    
    var body: some View {
        HStack {
            
            TextField("输入项目名称", text: $item.name)
                .font(.title2.weight(.heavy))
            
            
            Text("数量:\(item.count)")
                .foregroundStyle(.gray)
            
            Slider(value: .init(get: {
                Double(item.count)
            }, set: {
                item.count = Int($0)
            }), in: 0.0...100.0)
        }
    }
}

接着,我们将 ForEach 循环本身用一个新视图取代:

struct EditView: View {
    
    @Binding var items: [Item]
    
    var body: some View {
        ForEach($items) { $item in
            ItemEditView(item: $item)
        }
        .padding()
    }
}

最后,我们所要做的就是将父视图 ItemListView 中的 ForEach 循环变为 EditView 视图:

NavigationStack {
    EditView(items: $items)

    // 其它代码不变...
}

再次运行代码...不幸的是问题依旧:

在这里插入图片描述

看来这并不是简单父视图“过度”刷新的问题,一定是有什么不应有的行为触发了父视图的刷新,到底是什么呢?

3. 寻根究底

问题一定出在 ForEach 循环里!

回顾之前 Item 的定义,我们用 Identifiable 协议满足 ForEach 对子项目唯一性的挑剔,我们用 Item.name 构建了 id 属性。

当 Model 元素遵守 Identifiable 协议时,应该确保在任意时刻所有 Item 的 id 属性值都是唯一的!从目前来看,上述代码在修改 Item 名称时并没有发生重名的情况(虽然可能发生),所以对于唯一性是没有问题的。


当然在实际代码中用户很可能会输入重复的 Item 名称,所以还是不可接收的。

不过,这段代码在这里只是作为例子来向大家展示解决问题的推理过程,所以不必深究 ;)


但是 id 还有另一个重要的特征:稳定性

一般的,当 Identifiable 实体对象的 id 属性改变时,SwiftUI 会认为其不再是同一个对象,而立即刷新其所对应的视图界面。

所以,正如大家所看到的那样:每次用户输入 name 中的新字符时,键盘会被立即关闭焦点也随即丢失!

4. 解决之道

知道了问题原因,解决起来就很容易了。

我们只需要在 Item 生命周期中保证 id 的稳定性就可以了,这意味着不能再用 name 值作为 id 的“关联”值:

struct Item: Identifiable {
    let id = UUID()
    
    var name: String
    var count: Int
}

如上代码所示,我们在 Item 创建时为 id 生成一个唯一的 UUID 对象,这可以保证两点:

  • 任意时刻 Item 的唯一性;
  • 任意 Item 在其生命周期中的稳定性;

有了如上修改之后,我们再来运行代码看看结果:

在这里插入图片描述

可以看到,现在我们可以毫无问题的连续输入 Item 的名字了,焦点不会再丢失,一切回归正常,棒棒哒!!!💯

总结

在本篇博文中,我们讨论了 SwiftUI 开发中一个非常常见的问题,并借助一步步溯本回原的推理找到症结根本之所在,最后一发入魂将其完美解决!相信小伙伴们都能由此受益匪浅。

感谢观赏,再会!8-)

❌
❌