普通视图

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

SwiftUI 支持呼吸动画的图片切换小控件

作者 我唔知啊
2025年11月5日 15:18

先看效果:

1.gif

一个基于 SwiftUI + UIKit 实现的优雅图片切换控件,支持呼吸式缩放动画和流畅的切换过渡效果

前言

在开发 iOS 应用时,我们经常需要展示图片轮播或切换效果。虽然市面上有很多成熟的图片轮播库,但有时候我们只需要一个简单、优雅且带有动画效果的图片切换控件。本文将介绍如何实现一个带有呼吸式缩放动画和平滑切换效果的图片展示控件。

✨ 核心特性

  • 🎬 呼吸式缩放动画:图片在展示时会有类似 Ken Burns 效果的缓慢缩放动画
  • 🔄 流畅切换过渡:切换图片时,旧图放大淡出,新图缩小淡入,视觉效果自然流畅
  • 🌐 双重图片支持:同时支持网络图片和本地资源图片
  • 防抖机制:内置防抖逻辑,避免快速切换导致的动画混乱
  • 🎨 SwiftUI 集成:通过 UIViewRepresentable 封装,可无缝集成到 SwiftUI 项目中

🎯 效果预览

控件在运行时具有以下动画效果:

  1. 待机状态:图片缓慢放大再缩小,循环播放(14秒一个周期)
  2. 切换动画
    • 当前图片放大 + 淡出(0.2秒)
    • 新图片从小到大 + 淡入(0.35秒)
    • 切换完成后,新图片继续播放呼吸动画

🏗️ 实现原理

整体架构

控件由以下几个核心部分组成:

AnimatedImageView (UIView)
├── currentImgView (当前显示的图片)
├── willShowImgView (即将显示的图片)
├── 缩放动画逻辑
├── 切换动画逻辑
└── 图片加载机制

关键技术点

1. 双 ImageView 架构

使用两个 UIImageView 来实现平滑的切换效果:

private var currentImgView = UIImageView()  // 当前显示的图片
private var willShowImgView = UIImageView() // 待切换的图片

这种设计让我们可以在切换时同时对两张图片应用不同的动画,从而实现自然的过渡效果。

2. 三种尺寸状态

为了实现缩放动画,控件定义了三种尺寸状态:

private var originalBounds: CGRect = .zero  // 原始尺寸
private var smallBounds: CGRect = .zero     // 小尺寸(90%)
private var bigBounds: CGRect = .zero       // 大尺寸(125%)

图片会在这些尺寸之间进行动画过渡:

// 计算缩放尺寸
let sigleScale = 0.05
let doubleScale = 1.0 + sigleScale * 2

// 图片比视图大 10%,用于缩放动画时不露出边缘
let imgWidth = width * doubleScale
let imgHeight = height * doubleScale

3. 呼吸式缩放动画

使用 CABasicAnimation 实现无限循环的呼吸效果:

private func addScaleAnimation() {
    guard shouldContinueScaling else { return }
    
    let anim = CABasicAnimation(keyPath: "bounds")
    anim.fromValue = originalBounds
    anim.toValue = bigBounds
    anim.duration = scaleDuration  // 14秒
    anim.autoreverses = true        // 自动反向
    anim.repeatCount = .infinity    // 无限循环
    anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    currentImgView.layer.add(anim, forKey: "scaleLoop")
}

4. 切换动画组合

切换时同时执行四个动画:

private func animateSwitch(completion: @escaping () -> Void) {
    // 当前图片:放大 + 淡出
    let shrinkAnim = CABasicAnimation(keyPath: "bounds")
    shrinkAnim.fromValue = originalBounds
    shrinkAnim.toValue = bigBounds
    shrinkAnim.duration = switchDuration - 0.15
    
    let fadeAnim = CABasicAnimation(keyPath: "opacity")
    fadeAnim.fromValue = 1
    fadeAnim.toValue = 0
    fadeAnim.duration = switchDuration - 0.15
    
    // 新图片:缩小到放大 + 淡入
    let expandAnim = CABasicAnimation(keyPath: "bounds")
    expandAnim.fromValue = smallBounds
    expandAnim.toValue = originalBounds
    expandAnim.duration = switchDuration
    
    let unfadeAnim = CABasicAnimation(keyPath: "opacity")
    unfadeAnim.fromValue = 0
    unfadeAnim.toValue = 1.0
    unfadeAnim.duration = switchDuration
    
    // 使用 CATransaction 确保动画同步
    CATransaction.begin()
    CATransaction.setCompletionBlock {
        // 切换完成后的清理工作
        self.currentImgView.image = self.willShowImgView.image
        // ... 重置状态
        completion()
    }
    
    currentImgView.layer.add(shrinkAnim, forKey: "shrinkAnim")
    currentImgView.layer.add(fadeAnim, forKey: "fadeAnim")
    willShowImgView.layer.add(expandAnim, forKey: "expandAnim")
    willShowImgView.layer.add(unfadeAnim, forKey: "unfadeAnim")
    
    CATransaction.commit()
}

5. 防抖机制

为了避免快速切换造成的动画混乱,实现了防抖和队列机制:

private var debounceWorkItem: DispatchWorkItem?
private let debounceDelay: TimeInterval = 0.15
private var pendingImages: [String] = []

func setImage(_ source: String) {
    // 取消之前的防抖任务
    debounceWorkItem?.cancel()
    
    // 清空队列,只保留最新的图片
    pendingImages.removeAll()
    pendingImages.append(source)
    
    // 延迟执行
    let workItem = DispatchWorkItem { [weak self] in
        guard let self = self else { return }
        if !self.isSwitching {
            self.showNextImage()
        }
    }
    debounceWorkItem = workItem
    DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: workItem)
}

6. 图片加载(支持网络和本地)

自动识别图片源类型并使用对应的加载方式:

private func loadImage(from source: String, completion: @escaping (UIImage?) -> Void) {
    if isNetworkURL(source) {
        // 加载网络图片
        guard let url = URL(string: source) else {
            completion(nil)
            return
        }
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data, let image = UIImage(data: data) else {
                completion(nil)
                return
            }
            completion(image)
        }.resume()
    }
    else {
        // 加载本地图片
        DispatchQueue.global(qos: .userInitiated).async {
            let image = UIImage(named: source)
            completion(image)
        }
    }
}

private func isNetworkURL(_ string: String) -> Bool {
    return string.hasPrefix("http://") || string.hasPrefix("https://")
}

💻 代码实现

核心控件:AnimatedImageView

完整的 AnimatedImageView.swift 实现:

import UIKit

public final class AnimatedImageView: UIView {
    private var switchDuration: CGFloat = 0.35  // 切换动画时长
    private var scaleDuration: CGFloat = 14     // 缩放动画时长
    
    private var currentImgView = UIImageView()
    private var willShowImgView = UIImageView()
    private var shouldContinueScaling = false
    private var originalBounds: CGRect = .zero
    private var smallBounds: CGRect = .zero
    private var bigBounds: CGRect = .zero
    
    private var pendingImages: [String] = []
    var isSwitching = false
    var firstImgSource = ""
    var hasFirstImgSource = false
    private var debounceWorkItem: DispatchWorkItem?
    private let debounceDelay: TimeInterval = 0.15
    
    /// 设置图片(支持网络URL或本地图片名称)
    func setImage(_ source: String) {
        if hasFirstImgSource == false {
            firstImgSource = source
            hasFirstImgSource = true
            return
        }
        
        debounceWorkItem?.cancel()
        pendingImages.removeAll()
        pendingImages.append(source)
        
        let workItem = DispatchWorkItem { [weak self] in
            guard let self = self else { return }
            if !self.isSwitching {
                self.showNextImage()
            }
        }
        debounceWorkItem = workItem
        DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: workItem)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        initImages()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        initImages()
    }
    
    // 创建图片视图
    private func initImages() {
        willShowImgView.contentMode = .scaleAspectFill
        willShowImgView.clipsToBounds = true
        addSubview(willShowImgView)
        
        currentImgView.contentMode = .scaleAspectFill
        currentImgView.clipsToBounds = true
        addSubview(currentImgView)
    }
    
    // 设置图片大小
    public override func layoutSubviews() {
        super.layoutSubviews()
        
        let sigleScale = 0.05
        let doubleScale = 1.0 + sigleScale * 2
        let width = bounds.width
        let height = bounds.height
        
        let x = -width * sigleScale
        let y = -height * sigleScale
        let imgWidth = width * doubleScale
        let imgHeight = height * doubleScale
        
        currentImgView.frame = CGRect(x: x, y: y, width: imgWidth, height: imgHeight)
        willShowImgView.frame = currentImgView.frame
        
        // 记录初始 bounds
        if originalBounds == .zero {
            originalBounds = currentImgView.frame
            
            // 小尺寸(90%)
            let smallScale = 0.10
            smallBounds = originalBounds.insetBy(
                dx: originalBounds.width * (smallScale / 2.0),
                dy: originalBounds.height * (smallScale / 2.0)
            )
            
            // 大尺寸(125%)
            let bigScale = 0.25
            bigBounds = originalBounds.insetBy(
                dx: -originalBounds.width * (bigScale / 2.0),
                dy: -originalBounds.height * (bigScale / 2.0)
            )
            
            // 加载首张图片
            if firstImgSource.isEmpty {
                currentImgView.image = getDefaultImage()
                startScaleAnimation()
            } else {
                loadImage(from: firstImgSource) { [weak self] image in
                    guard let self = self else { return }
                    DispatchQueue.main.async {
                        self.currentImgView.image = image ?? self.getDefaultImage()
                        self.startScaleAnimation()
                    }
                }
            }
        }
    }
    
    // ... 其他方法(图片加载、动画等)
}

SwiftUI 封装

通过 UIViewRepresentable 将 UIKit 控件桥接到 SwiftUI:

public struct SwiftUIAnimatedImageView: UIViewRepresentable {
    let image: String
    
    public func makeUIView(context: Context) -> AnimatedImageView {
        let view = AnimatedImageView()
        return view
    }
    
    public func updateUIView(_ uiView: AnimatedImageView, context: Context) {
        uiView.setImage(image)
    }
}

🚀 使用方法

基础使用

import SwiftUI

struct ContentView: View {
    @State private var currentIndex: Int = 1
    
    var body: some View {
        SwiftUIAnimatedImageView(image: "\(currentIndex)")
            .ignoresSafeArea()
    }
}

完整示例(带切换按钮)

struct ContentView: View {
    @State private var currentIndex: Int = 1
    private let minIndex = 1
    private let maxIndex = 5
    
    var body: some View {
        SwiftUIAnimatedImageView(image: String(currentIndex))
            .ignoresSafeArea()
            .overlay {
                HStack(spacing: 30) {
                    // 上一张按钮
                    Button {
                        previousImage()
                    } label: {
                        HStack(spacing: 8) {
                            Image(systemName: "chevron.left")
                            Text("上一张")
                        }
                        .font(.system(size: 16, weight: .medium))
                        .foregroundColor(.white)
                        .padding(.horizontal, 20)
                        .padding(.vertical, 12)
                        .background(
                            RoundedRectangle(cornerRadius: 10)
                                .fill(Color.blue.gradient)
                        )
                    }
                    
                    // 下一张按钮
                    Button {
                        nextImage()
                    } label: {
                        HStack(spacing: 8) {
                            Text("下一张")
                            Image(systemName: "chevron.right")
                        }
                        .font(.system(size: 16, weight: .medium))
                        .foregroundColor(.white)
                        .padding(.horizontal, 20)
                        .padding(.vertical, 12)
                        .background(
                            RoundedRectangle(cornerRadius: 10)
                                .fill(Color.blue.gradient)
                        )
                    }
                }
            }
    }
    
    private func previousImage() {
        if currentIndex <= minIndex {
            currentIndex = maxIndex
        } else {
            currentIndex -= 1
        }
    }
    
    private func nextImage() {
        if currentIndex >= maxIndex {
            currentIndex = minIndex
        } else {
            currentIndex += 1
        }
    }
}

使用网络图片

SwiftUIAnimatedImageView(image: "https://example.com/image.jpg")

🎨 自定义配置

你可以根据需求调整以下参数:

参数 说明 默认值
switchDuration 切换动画时长 0.35秒
scaleDuration 呼吸缩放动画时长 14秒
debounceDelay 防抖延迟 0.15秒
smallScale 小尺寸缩放比例 0.10 (90%)
bigScale 大尺寸缩放比例 0.25 (125%)

修改示例:

// 在 AnimatedImageView 中
private var switchDuration: CGFloat = 0.5  // 切换更慢
private var scaleDuration: CGFloat = 10    // 呼吸更快

📝 技术要点总结

  1. 动画分层:将呼吸动画和切换动画分离,互不干扰
  2. 状态管理:使用 isSwitching 标志避免动画冲突
  3. 内存优化:使用 weak self 避免循环引用
  4. 视觉连续性:图片比容器大 10%,缩放时不露边
  5. 时序控制:使用 CATransaction 确保动画同步
  6. 用户体验:防抖机制避免快速点击造成的混乱

💡 进阶优化建议

  1. 图片缓存:集成 SDWebImage 或 Kingfisher 提升网络图片加载性能
  2. 自定义动画:开放动画参数,允许外部自定义动画效果
  3. 手势支持:添加左右滑动手势切换图片
  4. 预加载:提前加载下一张图片,减少等待时间
  5. 性能监控:添加帧率监控,确保动画流畅度

🎉 总结

本文实现的图片切换控件具有以下优势:

  • 优雅的视觉效果:呼吸式动画 + 平滑切换
  • 良好的性能:使用 CAAnimation,GPU 加速
  • 易于集成:SwiftUI 友好,一行代码即可使用
  • 灵活可扩展:支持本地和网络图片,易于定制

如果你的项目需要一个简洁但不失优雅的图片展示控件,不妨试试这个方案。代码简洁,效果出众,相信能为你的 App 增色不少!


相关技术栈:SwiftUI、UIKit、Core Animation、CABasicAnimation、UIViewRepresentable

适用场景:背景图片展示、产品轮播、引导页、登录页背景等

源码地址FMAnimatedImageView


👍 如果觉得有帮助,欢迎点赞收藏!有问题欢迎在评论区讨论~

Swift 扩展(Extension)指南——给现有类型“加外挂”的正规方式

作者 unravel2025
2025年11月5日 13:59

什么是 Extension

  1. 定义

    extension 是 Swift 提供的一种纵向扩展机制:“不修改原始代码、不创建子类”的前提下,给任意类型(class / struct / enum / protocol)追加功能。

  2. 与 OC Category 的区别

    • OC Category 需要名字,Swift 扩展无名字。
    • OC Category 能“声明”属性但不能“实现”存储属性;Swift 扩展同样只能写计算属性,但编译期直接报错而非运行时崩溃。
    • Swift 扩展支持协议遵守、泛型 where 约束,OC 做不到。

语法模板

extension 已有类型 [:协议1, 协议2] {
    // 新增功能
}

注意:

  • 扩展体里不能写 stored property(存储属性)。
  • 扩展体里不能给类新增deinit designated init
  • 扩展会全局生效,只要模块被 import,功能就可见;因此务必把“只内部用”的扩展标记为internalprivate

7 大能力逐一拆解

  1. 计算属性(只读 & 读写)
extension Double {
    // 以下都是“计算型属性”,底层无存储,每次实时计算
    var m: Double { self }              // 米
    var km: Double { self * 1_000.0 }   // 千米 → 米
    var ft: Double { self / 3.28084 }   // 英尺 → 米
    var cm: Double { self / 100.0 }     // 厘米 → 米
}

// 用法:链式调用、参与运算
let runWay = 3.5.km + 200.m           // 3 700 米
print("跑道长度:\(runWay)m")

常见坑:

  • set 时必须同时提供 get
  • 计算属性如果算法复杂,考虑用方法替代,避免“看起来像属性却耗时”的歧义。
  1. 方法(实例 & 类型)
extension Int {
    /// 将当前数值作为次数,重复执行无参闭包
    func repetitions(task: () -> Void) {
        for _ in 0..<self { task() }
    }
}

3.repetitions {
    print("Hello extension")
}

可变方法(mutating)

扩展里修改值类型自身时,必须加 mutating

extension Int {
    mutating func square() {
        self = self * self
    }
}

var num = 9
num.square()        // 81
  1. 便利构造器(convenience init)

规则:

  • 只能给类加 convenience init
  • 必须横向调用同类中的 designated init
  • 值类型(struct/enum)扩展可写任意 init,只要“所有属性有默认值”或“最终横向调到原 init”。
struct Size { var width = 0.0, height = 0.0 }
struct Point { var x = 0.0, y = 0.0 }

struct Rect {
    var origin = Point()
    var size = Size()
}

extension Rect {
    /// 通过中心点和尺寸创建矩形
    init(center: Point, size: Size) {
        let originX = center.x - size.width / 2
        let originY = center.y - size.height / 2
        // 横向调用原成员构造器
        self.init(origin: Point(x: originX, y: originY), size: size)
    }
}

let rect = Rect(center: Point(x: 5, y: 5), size: Size(width: 20, height: 10))
  1. 下标(Subscript)
extension Int {
    subscript(digitIndex: Int) -> Int {
        // 位数不足左侧补 0
        var decimal = 1
        for _ in 0..<digitIndex { decimal *= 10 }
        return (self / decimal) % 10
    }
}

123456789[0]   // 9
123456789[3]   // 6
  1. 嵌套类型
extension Int {
    enum Kind { case negative, zero, positive }
    
    var kind: Kind {
        switch self {
        case 0: return .zero
        case let x where x > 0: return .positive
        default: return .negative
        }
    }
}

// 使用
let nums = [-3, 0, 5]
for n in nums {
    print(n.kind)
}
  1. 协议遵守(Retroactive Modeling)

场景:第三方库定义了 User,你需要让 User 支持 Codable,但源码不可改。

做法:写扩展即可。

// 假设 User 是别人的类型
struct User { let name: String }

// 我让它直接支持 Codable
extension User: Codable { }

// 现在可以
let data = try JSONEncoder().encode(User(name: "Kim"))
  1. 扩展泛型 + where 约束
extension Array where Element == Int {
    /// 仅当数组元素是 Int 时可用
    func sum() -> Int { reduce(0, +) }
}

[1, 2, 3].sum()   // 6
["a", "b"].sum()  // 编译错误,方法不可见

扩展不能做的事

  1. 不能写 stored property(不会分配内存)。
  2. 不能给类新增 deinit / designated init
  3. 不能覆盖(override)已有方法——但可以重载(overload)或使用协议默认实现“屏蔽”。
  4. 扩展中声明的私有属性/方法,作用域遵循 Swift 访问级别规则;跨模块扩展时,默认无法访问 internal 成员,除非使用 @testable

实践总结

  1. 命名与作用域

    • 扩展文件统一命名 类型+功能.swift,例如 Double+Distance.swift
    • 只在本文件使用的扩展,用 private extension 包起来,避免“全局污染”。
  2. 计算属性 vs 方法

    • 无副作用、O(1) 返回,用属性;
    • 有 IO、算法复杂、可能抛异常,用方法。
  3. 协议优先

    如果功能具备“通用性”,先定义协议,再用扩展提供默认实现,例如:

    protocol ReusableView: AnyObject { static var reuseID: String { get } }
    extension ReusableView {
        static var reuseID: String { String(describing: self) }
    }
    // 所有 UITableViewCell 一键获得 reuseID
    
  4. 避免“上帝扩展”

    一个文件里动辄几百行的扩展,后期维护成本极高。按“能力维度”拆文件:

    UIView+Shadow.swift

    UIView+Gradient.swift

    UIView+Snapshot.swift

可落地的 3 个业务场景

  1. 路由参数解析

    URL 扩展计算属性,快速取 query 值:

    extension URL {
        var queryParameters: [String: String] {
            guard let q = query else { return [:] }
            return q.split(separator: "&").reduce(into: [:]) { result, pair in
                let kv = pair.split(separator: "=", maxSplits: 1)
                result[String(kv[0])] = kv.count > 1 ? String(kv[1]) : ""
            }
        }
    }
    
  2. 错误日志统一

    Error 扩展 log() 方法,一键上报:

    extension Error {
        func log(file: String = #file, line: Int = #line) {
            let msg = "\(Self.self) in \(file.split(separator: "/").last ?? ""):\(line)\(localizedDescription)"
            Logger.shared.error(msg)
        }
    }
    
  3. 商城 SKU 模型

    后端返回的 SKU 结构体缺少“是否缺货”字段,用扩展追加计算属性,避免改原始模型:

    extension SKU {
        var isOutOfStock: Bool { stock <= 0 }
    }
    

结语

扩展是 Swift “开闭原则”的最佳注脚:

  • 对修改封闭(不动源码),对扩展开放(任意追加)。

  • 用好扩展,可以让主类型保持简洁、让功能按“维度”聚类、让团队协作不打架。

但切记:

  • “能力越大,责任越大”——不加节制地全局扩展,会让调用链难以追踪、命名冲突概率增大。

  • 先想清楚“这是共性能力还是业务补丁”,再决定“用扩展、用包装器、用继承还是用组合”。

【Swift 错误处理全解析】——从 throw 到 typed throws,一篇就够

作者 unravel2025
2025年11月5日 11:30

为什么“错误处理”不能被忽略

  1. 可选值(Optional)只能表达“有没有值”,却无法说明“为什么没值”。
  2. 网络、磁盘、用户输入等真实世界操作,失败原因往往有多种:文件不存在、权限不足、格式错误、余额不足……
  3. 如果调用方不知道具体原因,就只能“一刀切”地崩溃或返回 nil,用户体验和可维护性都大打折扣。

Swift 把“错误”抽象成一套类型系统级机制:

  • 任何类型只要遵守 Error 协议,就可以被抛出、传播、捕获。
  • 编译器强制你处理或继续传播,不会出现“忘记检查错误”的漏洞。

Error 协议与枚举——给错误“建模”

Swift 的 Error 是一个空协议,作用类似“标记”。

最常用做法是枚举 + 关联值,把“错误场景”列清楚:

// 自动贩卖机可能发生的三种错误
enum VendingMachineError: Error {
    case invalidSelection            // 选品不存在
    case insufficientFunds(coinsNeeded: Int) // 钱不够,还差多少
    case outOfStock                  // 售罄
}

抛出错误:throw

throw 会立即结束当前执行流,把错误“往上扔”。

throw VendingMachineError.insufficientFunds(coinsNeeded: 5)

传播错误:throws / rethrows

  1. 在函数声明后写 throws,表示“这个函数可能抛出任何 Error”。
  2. 如果参数里含有 throwing 闭包,可用 rethrows 表明“我只传递闭包的错误,自己不会主动抛”。
// 返回 String,但可能抛出错误
func canThrowErrors() throws -> String { throw VendingMachineError.outOfStock }

// 不会抛
func cannotThrowErrors() -> String {""}

捕获与处理:4 种策略

  1. do-catch(最常用)

    • 可以精确匹配到具体 case,也可以用通配符。
    • 没有匹配时,错误继续向外传播。
var vm = VendingMachine()
vm.coinsDeposited = 8

do {
    try buyFavoriteSnack(person: "Alice", vendingMachine: vm)
    print("购买成功!咔嚓咔嚓~")
} catch VendingMachineError.insufficientFunds(let need) {
    print("余额不足,还需投入 \(need) 枚硬币")
} catch {
    // 兜住所有剩余错误
    print("其他意外错误:\(error)")
}
  1. try? —— 把错误变成可选值

    • 只要抛错,结果就是 nil,适合“失败就拉倒”的场景。
let data = try? loadDataFromDisk()   // 失败返回 nil,不care原因
  1. try! —— 禁用错误传播(相当于断言)

    • 仅当你 100% 确定不会抛时才用,否则运行期崩溃。
let img = try! loadImage(atPath: "App.bundle/avatar.png")
  1. 继续向上抛

    • 调用方也是 throws 函数,直接写 try 即可,错误自动上浮。

带类型的 throws(Swift 5.x 起)

以前只能写 throws,意味着“任何 Error”;现在可以写 throws(具体类型),让编译器帮你检查:

  • 只能抛声明的类型,抛其他类型直接编译失败。
  • 内存分配更可预测,适合嵌入式或超高性能场景。
  • 库作者可以把“内部错误”隐藏起来,避免暴露实现细节。
enum StatisticsError: Error {
    case noRatings
    case invalidRating(Int)
}

// 明确只抛 StatisticsError
func summarize(_ ratings: [Int]) throws(StatisticsError) {
    guard !ratings.isEmpty else { throw .noRatings }

    var counts = [1:0, 2:0, 3:0]
    for r in ratings {
        guard (1...3).contains(r) else { throw .invalidRating(r) }
        counts[r, default: 0] += 1
    }
    print("星数分布:*=\(counts[1]!) **=\(counts[2]!) ***=\(counts[3]!)")
}

调用侧:

do {
    try summarize([])               // 会抛 .noRatings
} catch {
    // 编译器知道 error 就是 StatisticsError,可穷举 switch
    switch error {
    case .noRatings: print("没有任何评分")
    case .invalidRating(let r): print("非法评分值:\(r)")
    }
}

清理资源:defer

无论作用域是正常 return 还是抛错,defer 都会“倒序”执行,常用来关闭文件、释放锁、回滚事务。

func processFile(path: String) throws {
    guard exists(path) else { throw CocoaError.fileNoSuchFile }
    let fd = open(path)              // 获取文件描述符
    defer { close(fd) }              // 保证最后一定关闭

    while let line = try fd.readline() {
        /* 处理行,可能抛出错误 */
    }
}   // 离开作用域时,defer 自动执行

实战:一个“网络镜像下载器”错误链

需求:

  1. 根据 URL 下载镜像;
  2. 可能失败:网络超时 / HTTP 非 200 / 本地无法写入;
  3. 调用方只想知道“成功文件路径”或“具体失败原因”。
enum DownloaderError: Error {
    case timeout
    case httpStatus(Int)
    case ioError(Error)
}

func downloadImage(url: String, to localPath: String) throws(DownloaderError) {
    // 伪代码:网络请求
    guard let data = try? Network.syncGet(url, timeout: 10) else {
        throw .timeout
    }
    guard data.response.status == 200 else {
        throw .httpStatus(data.response.status)
    }
    do {
        try data.body.write(to: localPath)
    } catch {
        throw .ioError(error)   // 把底层 IO 错误包装一层
    }
}

// 调用者
do {
    let path = try downloadImage(url: "https://example.com/a.jpg",
                                 to: "/tmp/a.jpg")
    print("下载完成,文件在:\(path)")
} catch DownloaderError.timeout {
    print("下载超时,请检查网络")
} catch DownloaderError.httpStatus(let code) {
    print("服务器异常,状态码:\(code)")
} catch {
    // 剩余唯一可能是 .ioError
    print("磁盘写入失败:\(error)")
}

总结与建议

  1. 优先用“枚举 + 关联值”给错误建模,调用者易读、易穷举。
  2. 对外 API 先写普通 throws,等接口稳定、错误范围确定后再考虑 throws(具体类型),避免早期过度承诺。
  3. 不要把“用户可恢复错误”与“程序逻辑错误”混为一谈:
    • 可恢复 → Error
    • 逻辑错误 → assert / precondition / fatalError
  4. 写库时,把“内部实现错误”用 throws(MyInternalError) 隐藏,对外统一转译成公共 Error,可降低耦合。
  5. defer 不要滥用,能早释放就早释放;写多个 defer 时注意“倒序”执行顺序。

【Swift 并发编程入门】——从 async/await 到 Actor,一文看懂结构化并发

作者 unravel2025
2025年11月5日 10:08

为什么官方要重做并发模型?

  1. 回调地狱

    过去写网络层,三步操作(读配置→请求→刷新 UI)要嵌套三层 closure,改起来像“剥洋葱”。

  2. 数据竞争难查

    多个线程同时写同一个 var,80% 崩溃出现在用户设备,本地调试复现不了。

  3. 结构化生命周期

    GCD 的 queue 没有“父-子”关系,任务飞出 App 生命周期后还在跑,造成野任务。

Swift 5.5 引入的 结构化并发(Structured Concurrency) 把“异步”和“并行”收编进语言层:

  • 编译期即可发现数据竞争(Data Race)
  • 所有异步路径必须标记 await,一眼看出挂起点
  • 任务自动形成树形层级,父任务取消,子任务必取消

核心语法 6 连击

关键字 作用 记忆口诀
async 声明函数“可能中途睡觉” 写在参数表后、-> 前
await 调用 async 函数时“可能卡这里” 必须写,不然编译器报错
async let 并行启动子任务,先跑后等 “先开枪后瞄准”
TaskGroup 动态产生 n 个任务 批量下载最爱
Actor 让“可变状态”串行访问 自带一把串行锁
@MainActor 让代码只在主线程跑 UI 必用

async/await 最简闭环

// 1️⃣ 把耗时函数标记为 async
func listPhotos(inGallery gallery: String) async throws -> [String] {
    try await Task.sleep(for: .milliseconds(Int.random(in: 0...500)))          // 模拟网络
    return ["img1", "img2", "img3"]
}

// 2️⃣ 调用方用 await 挂起
Task {
    let photos = try await listPhotos(inGallery: "Vacation")
    print("拿到 \(photos.count) 张图片")
}

注意点

  • 只有 async 上下文才能调用 async 函数——同步函数永远写不了 await
  • 没有 dispatch_async 那种“偷偷后台跑”的魔法,挂起点 100% 显式。

异步序列 —— 一次拿一条

传统回调“一口气全回来”,内存压力大;AsyncSequence 支持“来一个处理一个”。

import Foundation

// 自定义异步序列:每 0.5s 吐一个整数
struct Counter: AsyncSequence {
    typealias Element = Int
    struct AsyncIterator: AsyncIteratorProtocol {
        var current = 1
        mutating func next() async -> Int? {
            guard current <= 5 else { return nil }
            try? await Task.sleep(for: .seconds(0.5))
            defer { current += 1 }
            return current
        }
    }
    func makeAsyncIterator() -> AsyncIterator { AsyncIterator() }
}

// 使用 for-await 循环
Task {
    for await number in Counter() {
        print("收到数字", number)   // 1 2 3 4 5,间隔 0.5s
    }
}

并行下载:async let vs TaskGroup

场景:一次性拉取前三张大图,互不等待。

  1. async let 写法(任务数量固定)
func downloadPhoto(named: String) async throws -> String {
    try await Task.sleep(for: .milliseconds(Int.random(in: 0...500)))
    return named
}
func downloadThree() async throws -> [String] {
    // 同时启动 3 个下载
    async let first  = downloadPhoto(named: "1")
    async let second = downloadPhoto(named: "2")
    async let third  = downloadPhoto(named: "3")
    
    // 到这里才真正等待
    return try await [first, second, third]
}
  1. TaskGroup 写法(数量运行时决定)
func downloadAll(names: [String]) async throws -> [String] {
    return try await withThrowingTaskGroup(of: String.self) {
        group in
        for name in names {
            group.addTask {
                try await downloadPhoto(named: name)
            }
        }
        var results: [String] = []
        // 顺序无所谓,先下完先返回
        for try await data in group {
            results.append(data)
        }
        return results
    }
}

任务取消 —— 合作式模型

Swift 不会“硬杀”线程,任务要自己检查取消标志:

Task {
    let task = Task {
        for i in 1...100 {
            try Task.checkCancellation()   // 被取消会抛 CancellationError
            try await Task.sleep(for: .milliseconds(1))
            print("第 \(i) 毫秒")
        }
    }
    // 120 毫秒后取消
    try await Task.sleep(for: .milliseconds(120))
    task.cancel()
}

子任务会大概执行到60毫秒左右,是因为Task开启需要时间

Actor —— 让“可变状态”串行化

actor TemperatureLogger {
    private(set) var max: Double = .leastNormalMagnitude
    private var measurements: [Double] = []
    
    func update(_ temp: Double) {
        measurements.append(temp)
        max = Swift.max(max, temp)   // 内部无需 await
    }
}

// 使用
let logger = TemperatureLogger()
Task {
    await logger.update(30.5)      // 外部调用需要 await
    let currentMax = await logger.max
    print("当前最高温", currentMax)
}

编译器保证:

  • 任意时刻最多 1 个任务在 logger 内部执行
  • 外部访问自动加 await,天然线程安全

MainActor —— 专为 UI 准备的“主线程保险箱”

@MainActor
func updateUI(with image: UIImage) {
    imageView.image = image      // 100% 主线程
}

// 在后台任务里调用
Task {
    let img = await downloadPhoto(named: "cat")
    await updateUI(with: img)    // 编译器提醒写 await
}

也可以直接给整个类/结构体加锁:

@MainActor
class PhotoGalleryViewModel: ObservableObject {
    @Published var photos: [UIImage] = []
    // 所有属性 & 方法自动主线程
}

Sendable —— 跨并发域的“通行证”

只有值类型(struct/enum)且内部所有属性也是 Sendable,才允许在任务/actor 之间自由传递;

class 默认不 Sendable,除非手动加 @MainActor 或自己实现同步。

struct TemperatureReading: Sendable {   // 编译器自动推断
    var uuid: UUID
    var celsius: Double
}

class NonSafe: @unchecked Sendable {    // 自己保证线程安全
    private let queue = DispatchQueue(label: "lock")
    private var _value: Int = 0
    var value: Int {
        queue.sync { _value }
    }
    func increment() {
        queue.async { self._value += 1 }
    }
}

实战套路小结

  1. 入口用 Task {} 创建异步上下文
  2. 有依赖关系 → 顺序 await
  3. 无依赖关系 → async letTaskGroup
  4. 可变状态 → 收进 actor
  5. UI 刷新 → 贴 @MainActor
  6. 跨任务传值 → 先检查 Sendable

容易踩的 4 个坑

现象 官方建议
在同步函数里强行 await 编译直接报错 从顶层入口开始逐步 async 化
把大计算放进 async 函数 仍然卡住主线程 Task.detached 丢到后台
Actor 里加 await 造成重入 状态不一致 把“读-改-写”做成同步方法
忘记处理取消 用户返回页面还在下载 周期 checkCancellation

扩展场景:SwiftUI + Concurrency 一条龙

struct ContentView: View {
    @StateObject var vm = PhotoGalleryViewModel()
    
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(vm.photos.indices, id: \.self) { i in
                    Image(uiImage: vm.photos[i])
                        .resizable()
                        .scaledToFit()
                }
            }
        }
        .task {                      // SwiftUI 提供的并发生命周期
            await vm.loadGallery()   // 离开页面自动取消
        }
    }
}

@MainActor
class PhotoGalleryViewModel: ObservableObject {
    @Published var photos: [UIImage] = []
    
    func loadGallery() async {
        let names = await api.listPhotos()
        let images = await withTaskGroup(of: UIImage.self) { group -> [UIImage] in
            for name in names {
                group.addTask { await api.downloadPhoto(named: name) }
            }
            return await group.reduce(into: []) { $0.append($1) }
        }
        self.photos = images
    }
}

总结 & 展望

Swift 的并发设计把“容易写错”的地方全部做成编译期错误:

  • 忘写 await → 编译失败
  • 数据竞争 → 编译失败
  • 跨域传非 Sendable → 编译失败

这让大型项目的并发代码第一次拥有了“可维护性”——读代码时,只要看见 await 就知道这里会挂起;看见 actor 就知道内部状态绝对安全;看见 @MainActor 就知道 UI 操作不会蹦到后台线程。

《Flutter全栈开发实战指南:从零到高级》- 11 -状态管理Provider

2025年11月5日 09:24

Provider状态管理

本文是《Flutter全栈开发实战指南》系列的第11篇,将带你深入掌握Flutter中最流行的状态管理方案——Provider,通过实战案例彻底理解其核心原理和高级用法。

为什么需要状态管理?

在开始学习Provider之前,我们先来思考一个基本问题:为什么Flutter应用需要状态管理?

想象一下有这样一个场景:你的应用中有多个页面都需要显示用户信息,当用户在"设置"页面修改了个人信息后,你希望其他所有页面都能立即更新显示最新的信息。如果没有状态管理,你就需要在各个页面之间手动传递回调函数,或者使用全局变量,这样会导致代码耦合度高、难以维护。

状态管理解决了以下核心问题:

  • 数据共享:多个组件访问同一份数据
  • 状态同步:数据变化时自动更新所有依赖的组件
  • 关注点分离:将业务逻辑与UI逻辑解耦
  • 可测试性:更容易编写单元测试和集成测试

一、Provider的发展史

1.1 Flutter状态管理演进史

为了更好地理解Provider的价值,让我们简单了解下Flutter状态管理的演进过程:

基础期 (2018以前)    →    InheritedWidget + setState
                      ↓
爆发期 (2018-2019)   →    Redux、BLoC、Scoped Model  
                      ↓
成熟期 (2019-2020)   →    Provider成为官方推荐
                      ↓
现代期 (2020至今)    →    Riverpod、GetX等新兴方案

1.2 为什么选择Provider?

Provider之所以能成为官方推荐的状态管理方案,主要基于以下优势:

特性 说明 优点
简单易学 基于Flutter原生机制 学习曲线平缓
性能优秀 精确重建机制 避免不必要的Widget重建
代码精简 减少样板代码 提高开发效率
调试方便 强大的开发工具 便于问题排查
生态完善 丰富的扩展包 满足各种复杂场景

二、Provider核心概念

2.1 Provider的三大核心要素

Provider的核心架构可以概括为三个关键要素,它们共同构成了完整的状态管理解决方案:

// Provider架构的核心三要素示意图
// 1. 数据模型 (Model) - 存储状态数据
// 2. 提供者 (Provider) - 提供数据访问
// 3. 消费者 (Consumer) - 使用数据并响应变化

让我们通过一个简单的UML类图来理解它们之间的关系:

classDiagram
    class ChangeNotifier {
        <<abstract>>
        +addListener(listener)
        +removeListener(listener) 
        +notifyListeners()
        +hasListeners
    }
    
    class MyModel {
        -_data
        +getData()
        +setData()
        +dispose()
    }
    
    class Provider~T~ {
        +value T
        +of(context) T
        +create(covariant Provider~T~ create)
    }
    
    class Consumer~T~ {
        +builder(BuildContext, T, Widget) Widget
    }
    
    ChangeNotifier <|-- MyModel
    Provider <|-- ChangeNotifierProvider
    Consumer --> Provider : 依赖
    MyModel --> Provider : 封装

各组件职责说明:

  1. ChangeNotifier - 观察者模式的核心实现,负责管理监听器列表和通知变化
  2. Provider - 数据容器的包装器,负责在Widget树中提供数据实例
  3. Consumer - 数据消费者,在数据变化时自动重建对应的UI部分

2.2 Provider的工作原理

为了更直观地理解Provider的工作流程,我们来看一个完整的状态更新流程图:

sequenceDiagram
    participant U as User
    participant C as Consumer Widget
    participant P as Provider
    participant M as Model
    participant CN as ChangeNotifier
    
    C->>P: 注册监听
    U->>M: 执行数据变更
    M->>CN: 调用notifyListeners()
    CN->>P: 通知所有监听器
    P->>C: 触发重建
    C->>C: 使用新数据重建UI
  1. 初始化阶段:Consumer Widget在build方法中向Provider注册监听
  2. 用户交互阶段:用户操作触发Model中的数据变更方法
  3. 通知阶段:Model调用notifyListeners()通知所有注册的监听器
  4. 重建阶段:Provider接收到通知,触发所有依赖的Consumer重建
  5. 更新UI阶段:Consumer使用新的数据重新构建Widget,完成UI更新

三、ChangeNotifier使用介绍

3.1 创建数据Model

我们依然以一个计数器例子开始,深入了解ChangeNotifier的使用:

/// 计数器数据模型
/// 继承自ChangeNotifier,具备通知监听器的能力
class CounterModel extends ChangeNotifier {
  // 私有状态变量,外部不能直接修改
  int _count = 0;
  
  /// 获取当前计数值
  int get count => _count;
  
  /// 增加计数
  void increment() {
    _count++;
    // 通知所有监听器状态已改变
    notifyListeners();
    print('计数器增加至: $_count'); // 调试日志
  }
  
  /// 减少计数
  void decrement() {
    _count--;
    notifyListeners();
    print('计数器减少至: $_count'); // 调试日志
  }
  
  /// 重置计数器
  void reset() {
    _count = 0;
    notifyListeners();
    print('计数器已重置'); // 调试日志
  }
}

关键点解析:

  • 封装性_count是私有变量,只能通过提供的公共方法修改
  • 响应式:任何状态变更后都必须调用notifyListeners()
  • 可观测:getter方法提供只读访问,确保数据安全

3.2 在应用顶层提供数据

在Flutter应用中,我们通常需要在顶层提供状态管理实例:

void main() {
  runApp(
    /// 在应用顶层提供CounterModel实例
    /// ChangeNotifierProvider会自动处理模型的创建和销毁
    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: CounterPage(),
    );
  }
}

Provider的放置策略:

  • 全局状态:放在main()函数中,MaterialApp之上
  • 页面级状态:放在具体页面的顶层
  • 局部状态:放在需要使用状态的Widget子树中

3.3 在UI中访问和使用状态

方法一:使用Consumer(推荐)
class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Provider计数器')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('当前计数:', style: TextStyle(fontSize: 20)),
            
            /// Consumer会在数据变化时自动重建
            /// 只有这个部分会在计数器变化时重建,性能高效!
            Consumer<CounterModel>(
              builder: (context, counter, child) {
                print('Consumer重建: ${counter.count}'); // 调试日志
                return Text(
                  '${counter.count}',
                  style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
                );
              },
            ),
            
            SizedBox(height: 20),
            _buildControlButtons(),
          ],
        ),
      ),
    );
  }
  
  /// 构建控制按钮
  Widget _buildControlButtons() {
    return Consumer<CounterModel>(
      builder: (context, counter, child) {
        return Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: counter.decrement,
              child: Icon(Icons.remove),
            ),
            SizedBox(width: 20),
            ElevatedButton(
              onPressed: counter.reset,
              child: Text('重置'),
            ),
            SizedBox(width: 20),
            ElevatedButton(
              onPressed: counter.increment,
              child: Icon(Icons.add),
            ),
          ],
        );
      },
    );
  }
}
方法二:使用Provider.of(简洁方式)
class CounterText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    /// 使用Provider.of获取CounterModel实例
    /// 注意:listen: true 表示这个Widget会在数据变化时重建
    final counter = Provider.of<CounterModel>(context, listen: true);
    
    return Text(
      '${counter.count}',
      style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
    );
  }
}

class IncrementButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    /// listen: false 表示这个Widget不需要在数据变化时重建
    /// 因为我们只是调用方法,不依赖数据显示
    final counter = Provider.of<CounterModel>(context, listen: false);
    
    return ElevatedButton(
      onPressed: counter.increment,
      child: Icon(Icons.add),
    );
  }
}

两种方式的对比总结:

特性 Consumer Provider.of
重建范围 仅builder函数 整个Widget
性能优化 精确控制重建范围 整个Widget重建
适用场景 复杂UI 简单UI、按钮操作

四、Consumer与Selector高级用法

4.1 Consumer的多种变体

Provider提供了多种Consumer变体,用于不同的使用场景:

/// 多Provider消费示例
class UserProfilePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('用户资料')),
      body: Consumer2<UserModel, ThemeModel>(
        builder: (context, user, theme, child) {
          return Container(
            color: theme.backgroundColor,
            child: Column(
              children: [
                // 用户信息部分
                _buildUserInfo(user),
                // 使用child优化性能
                child!,
              ],
            ),
          );
        },
        /// child参数:不会重绘的部分
        child: _buildStaticContent(),
      ),
    );
  }
  
  Widget _buildUserInfo(UserModel user) {
    return Column(
      children: [
        Text(user.name, style: TextStyle(fontSize: 24)),
        Text(user.email),
      ],
    );
  }
  
  /// 静态内容,不会因为状态变化而重建
  Widget _buildStaticContent() {
    return Expanded(
      child: Container(
        padding: EdgeInsets.all(16),
        child: Text('这是静态内容,不会因为状态变化而重建'),
      ),
    );
  }
}

Consumer系列总结:

  • Consumer<T> - 消费单个Provider
  • Consumer2<T1, T2> - 消费两个Provider
  • Consumer3<T1, T2, T3> - 消费三个Provider
  • Consumer4<T1, T2, T3, T4> - 消费四个Provider
  • Consumer5<T1, T2, T3, T4, T5> - 消费五个Provider
  • Consumer6<T1, T2, T3, T4, T5, T6> - 消费六个Provider

4.2 Selector精确控制重建

Selector是Consumer的高性能版本,它可以精确控制什么情况下需要重建:

/// 用户列表项组件
class UserListItem extends StatelessWidget {
  final String userId;
  
  UserListItem({required this.userId});
  
  @override
  Widget build(BuildContext context) {
    /// Selector会在selectedUser变化时进行比较
    /// 只有when返回true时才会重建Widget
    return Selector<UserModel, User?>(
      selector: (context, userModel) => userModel.getUserById(userId),
      shouldRebuild: (previous, next) {
        /// 精确控制重建条件
        /// 只有用户数据真正发生变化时才重建
        return previous?.name != next?.name || 
               previous?.avatar != next?.avatar;
      },
      builder: (context, selectedUser, child) {
        if (selectedUser == null) {
          return ListTile(title: Text('用户不存在'));
        }
        
        return ListTile(
          leading: CircleAvatar(
            backgroundImage: NetworkImage(selectedUser.avatar),
          ),
          title: Text(selectedUser.name),
          subtitle: Text('最后活跃: ${selectedUser.lastActive}'),
          trailing: _buildOnlineIndicator(selectedUser.isOnline),
        );
      },
    );
  }
  
  Widget _buildOnlineIndicator(bool isOnline) {
    return Container(
      width: 12,
      height: 12,
      decoration: BoxDecoration(
        color: isOnline ? Colors.green : Colors.grey,
        shape: BoxShape.circle,
      ),
    );
  }
}

/// 用户模型扩展
class UserModel extends ChangeNotifier {
  final Map<String, User> _users = {};
  
  User? getUserById(String userId) => _users[userId];
  
  void updateUser(String userId, User newUser) {
    _users[userId] = newUser;
    notifyListeners();
  }
}

Selector的优势:

  1. 只在特定数据变化时重建,避免不必要的Widget重建
  2. 支持自定义比较逻辑,完全控制重建条件

4.3 Consumer vs Selector性能对比

通过一个实际测试来理解以下两者的性能差异:

class PerformanceComparison extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 方法1: 使用Consumer - 每次count变化都会重建
        Consumer<CounterModel>(
          builder: (context, counter, child) {
            print('Consumer重建: ${DateTime.now()}');
            return Text('计数: ${counter.count}');
          },
        ),
        
        // 方法2: 使用Selector - 只有count为偶数时重建
        Selector<CounterModel, int>(
          selector: (context, counter) => counter.count,
          shouldRebuild: (previous, next) {
            // 只有偶数时才重建
            return next % 2 == 0;
          },
          builder: (context, count, child) {
            print('Selector重建: ${DateTime.now()}');
            return Text('偶数计数: $count');
          },
        ),
      ],
    );
  }
}

测试结果:

  • 点击增加按钮时,Consumer每次都会重建
  • Selector只在计数为偶数时重建

五、多Provider协同工作

在实际项目中,我们经常需要多个Provider协同工作。让我们通过一个电商应用的例子来学习这种高级用法。

5.1 复杂数据模型设计

首先,我们设计几个核心的数据模型:

/// 用户认证模型
class AuthModel extends ChangeNotifier {
  User? _currentUser;
  bool _isLoading = false;
  
  User? get currentUser => _currentUser;
  bool get isLoading => _isLoading;
  bool get isLoggedIn => _currentUser != null;
  
  Future<void> login(String email, String password) async {
    _isLoading = true;
    notifyListeners();
    
    try {
      // 接口调用
      await Future.delayed(Duration(seconds: 2));
      _currentUser = User(id: '1', email: email, name: '用户$email');
    } catch (error) {
      throw Exception('登录失败: $error');
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
  
  void logout() {
    _currentUser = null;
    notifyListeners();
  }
}

/// 购物车模型
class CartModel extends ChangeNotifier {
  final List<CartItem> _items = [];
  double _totalPrice = 0.0;
  
  List<CartItem> get items => List.unmodifiable(_items);
  double get totalPrice => _totalPrice;
  int get itemCount => _items.length;
  
  void addItem(Product product, {int quantity = 1}) {
    final existingIndex = _items.indexWhere((item) => item.product.id == product.id);
    
    if (existingIndex >= 0) {
      // 商品已存在,增加数量
      _items[existingIndex] = _items[existingIndex].copyWith(
        quantity: _items[existingIndex].quantity + quantity
      );
    } else {
      // 添加新商品
      _items.add(CartItem(product: product, quantity: quantity));
    }
    
    _updateTotalPrice();
    notifyListeners();
  }
  
  void removeItem(String productId) {
    _items.removeWhere((item) => item.product.id == productId);
    _updateTotalPrice();
    notifyListeners();
  }
  
  void clear() {
    _items.clear();
    _totalPrice = 0.0;
    notifyListeners();
  }
  
  void _updateTotalPrice() {
    _totalPrice = _items.fold(0.0, (total, item) {
      return total + (item.product.price * item.quantity);
    });
  }
}

/// 商品模型
class ProductModel extends ChangeNotifier {
  final List<Product> _products = [];
  bool _isLoading = false;
  String? _error;
  
  List<Product> get products => List.unmodifiable(_products);
  bool get isLoading => _isLoading;
  String? get error => _error;
  
  Future<void> loadProducts() async {
    _isLoading = true;
    _error = null;
    notifyListeners();
    
    try {
      // Api调用
      await Future.delayed(Duration(seconds: 2));
      _products.addAll([
        Product(id: '1', name: 'Flutter实战指南', price: 69.0),
        Product(id: '2', name: 'Dart编程语言', price: 49.0),
        Product(id: '3', name: '移动应用设计', price: 59.0),
      ]);
    } catch (error) {
      _error = '加载商品失败: $error';
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

5.2 多Provider的配置和初始化

在应用顶层配置多个Provider:

void main() {
  runApp(
    /// MultiProvider可以同时提供多个Provider
    MultiProvider(
      providers: [
        // 用户认证状态
        ChangeNotifierProvider(create: (_) => AuthModel()),
        // 购物车状态
        ChangeNotifierProvider(create: (_) => CartModel()),
        // 商品状态
        ChangeNotifierProvider(create: (_) => ProductModel()),
      ],
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '电商应用',
      theme: ThemeData(primarySwatch: Colors.blue),
      
      /// 使用Consumer监听认证状态,决定显示哪个页面
      home: Consumer<AuthModel>(
        builder: (context, auth, child) {
          if (auth.isLoading) {
            return SplashScreen();
          }
          return auth.isLoggedIn ? HomePage() : LoginPage();
        },
      ),
    );
  }
}

5.3 Provider之间的交互与通信

在复杂的应用中,不同的Provider可能需要相互交互。我们来看几种常见的交互模式:

模式一:直接访问其他Provider
/// 订单模型 - 需要访问用户和购物车信息
class OrderModel extends ChangeNotifier {
  Future<void> createOrder() async {
    // 获取BuildContext
    final navigatorKey = GlobalKey<NavigatorState>();
    final context = navigatorKey.currentContext!;
    
    // 访问其他Provider
    final auth = Provider.of<AuthModel>(context, listen: false);
    final cart = Provider.of<CartModel>(context, listen: false);
    
    if (auth.currentUser == null) {
      throw Exception('用户未登录');
    }
    
    if (cart.items.isEmpty) {
      throw Exception('购物车为空');
    }
    
    // 创建订单逻辑...
    print('为用户 ${auth.currentUser!.name} 创建订单');
    print('订单商品: ${cart.items.length} 件');
    print('总金额: \$${cart.totalPrice}');
    
    // 清空购物车
    cart.clear();
  }
}
模式二:使用回调函数进行通信
/// 商品项组件
class ProductItem extends StatelessWidget {
  final Product product;
  
  ProductItem({required this.product});
  
  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(
        children: [
          Image.network(product.imageUrl),
          Text(product.name, style: TextStyle(fontSize: 18)),
          Text('\$${product.price}'),
          Consumer<CartModel>(
            builder: (context, cart, child) {
              final isInCart = cart.items.any((item) => item.product.id == product.id);
              
              return ElevatedButton(
                onPressed: () {
                  if (isInCart) {
                    cart.removeItem(product.id);
                  } else {
                    cart.addItem(product);
                  }
                },
                child: Text(isInCart ? '从购物车移除' : '加入购物车'),
              );
            },
          ),
        ],
      ),
    );
  }
}

5.4 复杂的UI交互案例

以一个购物车页面为例,展示多Provider的协同工作:

class CartPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('购物车')),
      body: Column(
        children: [
          // 购物车商品列表
          Expanded(
            child: Consumer<CartModel>(
              builder: (context, cart, child) {
                if (cart.items.isEmpty) {
                  return Center(child: Text('购物车为空'));
                }
                
                return ListView.builder(
                  itemCount: cart.items.length,
                  itemBuilder: (context, index) {
                    final item = cart.items[index];
                    return _buildCartItem(item, cart);
                  },
                );
              },
            ),
          ),
          
          // 购物车底部汇总
          _buildCartSummary(),
        ],
      ),
    );
  }
  
  Widget _buildCartItem(CartItem item, CartModel cart) {
    return Dismissible(
      key: Key(item.product.id),
      direction: DismissDirection.endToStart,
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: EdgeInsets.only(right: 20),
        child: Icon(Icons.delete, color: Colors.white),
      ),
      onDismissed: (direction) {
        cart.removeItem(item.product.id);
        
        // 显示删除提示
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('已删除 ${item.product.name}')),
        );
      },
      child: ListTile(
        leading: CircleAvatar(
          backgroundImage: NetworkImage(item.product.imageUrl),
        ),
        title: Text(item.product.name),
        subtitle: Text('单价: \$${item.product.price}'),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            IconButton(
              icon: Icon(Icons.remove),
              onPressed: () {
                if (item.quantity > 1) {
                  cart.addItem(item.product, quantity: -1);
                } else {
                  cart.removeItem(item.product.id);
                }
              },
            ),
            Text('${item.quantity}'),
            IconButton(
              icon: Icon(Icons.add),
              onPressed: () => cart.addItem(item.product),
            ),
          ],
        ),
      ),
    );
  }
  
  Widget _buildCartSummary() {
    return Container(
      padding: EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.grey[100],
        border: Border(top: BorderSide(color: Colors.grey[300]!)),
      ),
      child: Consumer2<CartModel, AuthModel>(
        builder: (context, cart, auth, child) {
          return Column(
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text('商品数量:', style: TextStyle(fontSize: 16)),
                  Text('${cart.itemCount} 件'),
                ],
              ),
              SizedBox(height: 8),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text('总计:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
                  Text('\$${cart.totalPrice.toStringAsFixed(2)}', 
                       style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
                ],
              ),
              SizedBox(height: 16),
              
              if (auth.isLoggedIn) ...[
                ElevatedButton(
                  onPressed: cart.items.isEmpty ? null : () => _createOrder(context),
                  child: Text('立即下单', style: TextStyle(fontSize: 16)),
                  style: ElevatedButton.styleFrom(
                    minimumSize: Size(double.infinity, 48),
                  ),
                ),
              ] else ...[
                Text('请先登录以完成下单', style: TextStyle(color: Colors.red)),
                SizedBox(height: 8),
                ElevatedButton(
                  onPressed: () => Navigator.push(context, 
                      MaterialPageRoute(builder: (_) => LoginPage())),
                  child: Text('去登录'),
                ),
              ],
            ],
          );
        },
      ),
    );
  }
  
  void _createOrder(BuildContext context) async {
    final orderModel = Provider.of<OrderModel>(context, listen: false);
    
    try {
      await orderModel.createOrder();
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('订单创建成功!')),
      );
    } catch (error) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('订单创建失败: $error')),
      );
    }
  }
}

六、Provider高级技巧

6.1 性能优化

使用child参数优化重建
class OptimizedUserList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<UserModel>(
      builder: (context, userModel, child) {
        // 只有用户列表变化时,这个部分会重建
        return ListView.builder(
          itemCount: userModel.users.length,
          itemBuilder: (context, index) {
            return UserListItem(user: userModel.users[index]);
          },
        );
      },
      // child参数中的Widget不会重建
      child: _buildHeader(),
    );
  }
  
  Widget _buildHeader() {
    return Container(
      padding: EdgeInsets.all(16),
      child: Text('用户列表', style: TextStyle(fontSize: 24)),
    );
  }
}
使用select进行精确订阅
class UserProfile extends StatelessWidget {
  final String userId;
  
  UserProfile({required this.userId});
  
  @override
  Widget build(BuildContext context) {
    /// 使用select精确订阅特定用户的特定属性
    final userName = context.select<UserModel, String>(
      (userModel) => userModel.getUserById(userId)?.name ?? '未知用户'
    );
    
    final userAvatar = context.select<UserModel, String>(
      (userModel) => userModel.getUserById(userId)?.avatar ?? ''
    );
    
    return Column(
      children: [
        CircleAvatar(backgroundImage: NetworkImage(userAvatar)),
        Text(userName),
      ],
    );
  }
}

6.2 状态持久化

/// 支持持久化的购物车模型
class PersistentCartModel extends ChangeNotifier {
  final SharedPreferences _prefs;
  List<CartItem> _items = [];
  
  PersistentCartModel(this._prefs) {
    _loadFromPrefs();
  }
  
  Future<void> _loadFromPrefs() async {
    final cartData = _prefs.getString('cart');
    if (cartData != null) {
      // 解析存储的购物车数据
      _items = _parseCartData(cartData);
      notifyListeners();
    }
  }
  
  Future<void> _saveToPrefs() async {
    final cartData = _encodeCartData();
    await _prefs.setString('cart', cartData);
  }
  
  void addItem(Product product, {int quantity = 1}) {
    // ... 添加商品逻辑
    
    // 保存到持久化存储
    _saveToPrefs();
    notifyListeners();
  }
  
  // ... 其他方法
}

七、常见问题

7.1 ProviderNotFoundError错误

问题描述:

Error: Could not find the correct Provider<CounterModel> above this Consumer<CounterModel> Widget

解决方案:

  1. 检查Provider是否在Widget树的上层
  2. 确认泛型类型匹配
  3. 使用Builder组件获取正确的context
// 错误做法
Widget build(BuildContext context) {
  return Consumer<CounterModel>( // 错误:Provider不在上层
    builder: (context, counter, child) => Text('${counter.count}'),
  );
}

// 正确做法
Widget build(BuildContext context) {
  return ChangeNotifierProvider(
    create: (_) => CounterModel(),
    child: Consumer<CounterModel>( // 正确:Provider在上层
      builder: (context, counter, child) => Text('${counter.count}'),
    ),
  );
}

7.2 不必要的重建问题

问题现象: UI响应缓慢,性能不佳

解决方案:

  1. 使用Selector替代Consumer
  2. 合理使用child参数
  3. 拆分细粒度的Consumer
// 性能优化前
Consumer<CartModel>(
  builder: (context, cart, child) {
    return Column(
      children: [
        Header(), // 不依赖购物车数据
        ProductList(products: cart.items), // 依赖购物车数据
        Footer(), // 不依赖购物车数据
      ],
    );
  },
);

// 性能优化后
Column(
  children: [
    Header(), // 不重建
    Consumer<CartModel>(
      builder: (context, cart, child) {
        return ProductList(products: cart.items); // 精确重建
      },
    ),
    Footer(), // 不重建
  ],
);

结语

通过以上内容学习,我们掌握了Provider状态管理的核心概念和高级用法。总结一下关键知识点:

  1. Provider三大要素:数据模型、提供者、消费者构成完整状态管理体系
  2. ChangeNotifier原理:基于观察者模式,通过notifyListeners()通知变化
  3. Consumer优势:精确控制重建范围,提升应用性能
  4. Selector高级用法:通过条件重建实现极致性能优化
  5. 多Provider协同:使用MultiProvider管理复杂应用状态

如果觉得本文对你有帮助,请一键三连(点赞、关注、收藏)支持一下!

你的支持是我持续创作高质量教程的最大动力!如果有任何问题或建议,欢迎在评论区留言讨论。


参考资料:


版权声明:本文为《Flutter全栈开发实战指南》系列原创文章,转载请注明出处。

欧陆风云 5 的经济系统

作者 云风
2025年11月5日 06:07

很早从“舅舅”那里拿到了《欧陆风云 5》的试玩版。因为开发期的缘故,更新版本后需要重玩,所以一开始只是陆陆续续玩了十几个小时。前段时间从阳朔攀岩回来,据说已经是发售前最后一版了,便投入精力好好玩了 50 小时,感觉非常好。

我没有玩过这个系列的前作,但有 800 小时《群星》的经验,还有维多利亚 2/3 以及十字军之王 2/3 的近百小时游戏时间,对 P 社的大战略游戏的套路还是比较了解的。这一作中有很多似曾相识的机制,但玩进去又颇为新鲜,未曾在其它游戏中体验过。

我特别喜欢 P 社这种在微观上使用简洁公式,宏观展现出深度的游戏设计。我试着对游戏的一小部分设计作一些分析,记录一下它的经济系统是如何构建的。

这里有一篇官方的开发日志:贸易与经济 其实说得很清楚。但没自己玩恐怕无法理解。

我在玩了几十小时后,也只模糊勾勒出经济系统的大轮廓。下面是我自己的理解,可能还存在不少错误。

EU5 的经济系统由人口/货币/商品构成,市场为其中介。

游戏世界由无数地区构成。地区在一起可以构成国家,也能构成市场。一个国家可以对应一个市场,也可以由多个市场构成,也可以和其它国家共享市场。

每个市场都会以一个地区为市场中心,这反应了这个地区的经济影响力,它不同于国家以首都为中心的政治版图。市场自身会产生影响力,而市场中心地区的所属国家则因其政治影响力而产生市场保护力,两相作用决定了市场向外辐射的范围。每个地区在每个时刻都会根据受周围不同市场的影响强弱,最终归属到一个唯一市场。

每个地区有单一的原产物资(商品)。原产在版图上不会改变,可以被人口开发,计入所属市场供给。

地区上可以修建生产建筑,生产建筑通过人口把若干种商品转换为一种商品。转换效率受地区的市场接入率影响。市场中心地区的接入率为 100% ,远离市场的地区接入率下降,在边远地区甚至为 0 (这降低了同样人口的工作效率)。注:原产不受市场接入率的影响。

市场每种商品有供给和需求。每种商品有一个额定价格。需求和供给的多寡决定了目标价格和额定价格的差距,目标价格在 10% 到 500% 间变化。实际价格每个月会向目标价格变动,变动速度受物价波动率影响。

商品的价格减去原料成本(原产物资没有原料成本)为其利润,利润以货币形式归属生产人口,并产生税基。

负利润的生产建筑会逐渐减员,削减产量,除非政府提供补贴。缺少原料的生产建筑会减产。

人口会对商品产生需求。食物类型的商品是最基本的需求。 不能满足食物需求的人口会饿死,不能满足其它需求的人口会产生不满。

对于单一市场:

  1. 人口在市场上产生商品需求。
  2. 生产建筑通过人口生产出商品供给市场。
  3. 市场上的供给和需求影响了商品价格。
  4. 人口通过生产获取货币形式的利润,并产生税基。

税基中的一部分货币留给人口,一部分以税收形式收归国库。

货币用来投资新增生产建筑,或对其升级。建筑升级需要商品,这部分商品以需求形式出现在市场。人口会用自己的钱自动投资建筑,玩家可以动用国库升级建筑。

多个市场间以贸易形式交换商品:

每个市场有一个贸易容量,贸易容量由市场中地区中的建筑获得。贸易容量用来向其它市场进出口商品。

市场所属国家拥有贸易竞争力,贸易竞争力决定了向市场交易的优先级。高优先级贸易竞争力的市场先消耗贸易容量达成交易。

商品在不同市场中的价差构成了贸易利润,其中需要扣除贸易成本(通常由两个市场中心间的距离决定)。贸易利润的一部分(由王权力度决定)进入国库,其它部分变为税基。

在国家主动进行贸易外,人口也有单独的贸易容量,自动在市场间贸易平衡供需。


我觉得颇为有趣的部分是这个经济系统中货币和商品的关系。

游戏中的生态其实是用商品构成的:人口提供了商品的基本需求,同时人口也用来生产它们。在生产过程中,转换关系又产生了对原料的需求。为了提高生产力,需要建造和升级建筑,这些建筑本身又是由商品转换而来。所以这么看来,是这些商品构成了这个世界,从这个角度完全不涉及货币。

但货币是什么呢?货币是商品扭转的中介。因为原产是固定在世界的各个角落的,必须通过市场和贸易通达各处。

建立一个超大的单一市场可以避免贸易,它们都直接计入市场中心。但远离市场中心生产出来的商品(非原产)受市场接入度的影响而削弱生产效率,所以这个世界只能本分割成若干市场。不同市场由于供需关系不同而造成了物价波动。价差形成贸易的利润,让商品流动。这很好的体现了货币的本质:商品流动的中介。

政府通过税收和其主导的国家贸易行为获得货币,同时也可以通过铸币获取额外收益(并制造通货膨胀)。再用这些货币去投资引导世界的发展:建造和升级生产建筑光有钱是不行的,必须市场上有足够的商品;没有钱也是不行的,得负担得起市场上对应商品的价格。

昨天 — 2025年11月4日iOS

《Flutter全栈开发实战指南:从零到高级》- 10 -状态管理setState与InheritedWidget

2025年11月4日 16:49

状态管理:setState与InheritedWidget

深入理解Flutter状态管理的基石,掌握setState与InheritedWidget的核心原理与应用场景

在Flutter应用开发中,状态管理是一个无法回避的核心话题。无论是简单的计数器应用,还是复杂的企业级应用,都需要有效地管理应用状态。下面我们将深入探讨Flutter状态管理的两个基础但极其重要的概念:setStateInheritedWidget

1. 什么是状态管理?

在开始具体的技术细节之前,我们先理解一下什么是状态管理。简单来说,状态就是应用中会发生变化的数据。比如:

  • 用户点击按钮的次数
  • 从网络加载的数据列表
  • 用户的登录信息
  • 应用的主题设置

状态管理就是如何存储、更新和传递这些变化数据的一套方法和架构

为什么需要状态管理?

想象一下,如果没有良好的状态管理,我们的代码会变成什么样子:

// 反面案例
class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  int _counter = 0;
  String _userName = '';
  bool _isDarkMode = false;
  List<String> _items = [];
  
  // 多个状态变量和方法混在一起
  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }
  
  void _loadUserData() {
    // 加载用户数据
  }
  
  void _toggleTheme() {
    setState(() {
      _isDarkMode = !_isDarkMode;
    });
  }
  
  // ... 更多方法
  
  @override
  Widget build(BuildContext context) {
    // 构建UI,传递状态到各个子组件
    return Container(
      child: Column(
        children: [
          CounterDisplay(counter: _counter, onIncrement: _incrementCounter),
          UserProfile(name: _userName),
          ThemeToggle(isDark: _isDarkMode, onToggle: _toggleTheme),
          // ... 更多组件
        ],
      ),
    );
  }
}

这种方式的问题在于:

  1. 代码耦合度高:所有状态逻辑都集中在同一个类中
  2. 难以维护:随着功能增加,代码变得越来越复杂
  3. 状态共享困难:需要在组件树中层层传递状态和回调
  4. 测试困难:业务逻辑和UI渲染紧密耦合

2. setState:最基础的状态管理

2.1 setState的基本用法

setState是Flutter中最基础、最常用的状态管理方式。它是StatefulWidget的核心方法,用于通知框架状态已发生变化,需要重新构建UI。

让我们通过一个经典的计数器示例来理解setState

import 'package:flutter/material.dart';

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--;
    });
  }
  
  void _resetCounter() {
    setState(() {
      _counter = 0;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('计数器示例'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              '当前计数:',
              style: Theme.of(context).textTheme.headline4,
            ),
            Text(
              '$_counter', // 显示状态
              style: Theme.of(context).textTheme.headline2,
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _decrementCounter, // 绑定状态修改方法
                  child: Text('减少'),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _resetCounter,
                  child: Text('重置'),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: _incrementCounter,
                  child: Text('增加'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

2.2 setState的工作原理

为了更好地理解setState的工作原理,先看一下其内部机制:

// 简化的setState源码理解
@protected
void setState(VoidCallback fn) {
  // 1. 执行回调函数,更新状态
  fn();
  
  // 2. 标记当前Element为dirty(脏状态)
  _element.markNeedsBuild();
  
  // 3. 调度新的构建帧
  SchedulerBinding.instance!.scheduleFrame();
}

setState执行流程

┌─────────────────┐     ┌──────────────────┐     ┌──────────────────┐
│   调用setState  │───▶ │ 执行回调更新状态  │───▶│ 标记Element为dirty│
└─────────────────┘     └──────────────────┘     └──────────────────┘
         │                                              │
         │                                              ▼
         │                                    ┌──────────────────┐
         │                                    │ 调度新的构建帧    │
         │                                    └──────────────────┘
         │                                              │
         ▼                                              ▼
┌─────────────────┐                            ┌──────────────────┐
│  状态已更新      │                            │ 下一帧重建Widget  │
│  但UI未更新      │                            │    更新UI        │
└─────────────────┘                            └──────────────────┘

2.3 setState的适用场景

setState最适合以下场景:

  1. 局部状态管理:只在当前组件内部使用的状态
  2. 简单的UI交互:如按钮点击、表单输入等
  3. 原型开发:快速验证想法和功能
  4. 小型应用:组件数量少、状态简单的应用

2.4 setState的局限性

虽然setState简单易用,但在复杂应用中会暴露出很多问题:

// setState局限性
class ComplexApp extends StatefulWidget {
  @override
  _ComplexAppState createState() => _ComplexAppState();
}

class _ComplexAppState extends State<ComplexApp> {
  // 问题1:状态变量过多,难以管理
  int _counter = 0;
  String _userName = '';
  String _userEmail = '';
  bool _isLoggedIn = false;
  List<String> _products = [];
  bool _isLoading = false;
  String _errorMessage = '';
  
  // 问题2:业务逻辑混杂在UI代码中
  void _loginUser(String email, String password) async {
    setState(() {
      _isLoading = true;
      _errorMessage = '';
    });
    
    try {
      // 模拟接口请求
      final user = await AuthService.login(email, password);
      setState(() {
        _isLoggedIn = true;
        _userName = user.name;
        _userEmail = user.email;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
        _errorMessage = '登录失败: $e';
      });
    }
  }
  
  // 问题3:需要在组件树中层层传递回调
  Widget _buildUserProfile() {
    return UserProfile(
      userName: _userName,
      userEmail: _userEmail,
      onUpdate: (String newName, String newEmail) {
        setState(() {
          _userName = newName;
          _userEmail = newEmail;
        });
      },
    );
  }
  
  @override
  Widget build(BuildContext context) {
    // 构建方法变得极为复杂
    return Container(
      // ... 大量UI代码
    );
  }
}

setState的主要局限性

  1. 状态分散:多个无关状态混杂在同一个类中
  2. 逻辑耦合:业务逻辑和UI渲染代码紧密耦合
  3. 传递麻烦:需要手动将状态和回调传递给子组件
  4. 测试困难:很难单独测试业务逻辑
  5. 性能问题:每次setState都会重新build整个子树

3. 状态提升

3.1 什么是状态提升?

状态提升是React和Flutter中常见的设计模式,指的是将状态从子组件移动到其父组件中,使得多个组件可以共享同一状态。

3.2 让我们通过一个温度转换器的例子来理解状态提升

// 温度输入组件 - 无状态组件
class TemperatureInput extends StatelessWidget {
  final TemperatureScale scale;
  final double temperature;
  final ValueChanged<double> onTemperatureChanged;

  const TemperatureInput({
    Key? key,
    required this.scale,
    required this.temperature,
    required this.onTemperatureChanged,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return TextField(
      decoration: InputDecoration(
        labelText: scale == TemperatureScale.celsius ? '摄氏度' : '华氏度',
      ),
      keyboardType: TextInputType.number,
      onChanged: (value) {
        final temperature = double.tryParse(value);
        if (temperature != null) {
          onTemperatureChanged(temperature);
        }
      },
    );
  }
}

// 温度显示组件 - 无状态组件
class TemperatureDisplay extends StatelessWidget {
  final double celsius;
  final double fahrenheit;

  const TemperatureDisplay({
    Key? key,
    required this.celsius,
    required this.fahrenheit,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('摄氏度: ${celsius.toStringAsFixed(2)}°C'),
        Text('华氏度: ${fahrenheit.toStringAsFixed(2)}°F'),
        _getTemperatureMessage(celsius),
      ],
    );
  }
  
  Widget _getTemperatureMessage(double celsius) {
    if (celsius >= 100) {
      return Text('水会沸腾', style: TextStyle(color: Colors.red));
    } else if (celsius <= 0) {
      return Text('水会结冰', style: TextStyle(color: Colors.blue));
    } else {
      return Text('水是液态', style: TextStyle(color: Colors.green));
    }
  }
}

// 主组件 - 管理状态
class TemperatureConverter extends StatefulWidget {
  @override
  _TemperatureConverterState createState() => _TemperatureConverterState();
}

class _TemperatureConverterState extends State<TemperatureConverter> {
  // 状态提升:温度值由父组件管理
  double _celsius = 0.0;

  // 转换方法
  double get _fahrenheit => _celsius * 9 / 5 + 32;
  
  void _handleCelsiusChange(double celsius) {
    setState(() {
      _celsius = celsius;
    });
  }
  
  void _handleFahrenheitChange(double fahrenheit) {
    setState(() {
      _celsius = (fahrenheit - 32) * 5 / 9;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('温度转换器')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 摄氏度输入
            TemperatureInput(
              scale: TemperatureScale.celsius,
              temperature: _celsius,
              onTemperatureChanged: _handleCelsiusChange,
            ),
            SizedBox(height: 20),
            // 华氏度输入
            TemperatureInput(
              scale: TemperatureScale.fahrenheit,
              temperature: _fahrenheit,
              onTemperatureChanged: _handleFahrenheitChange,
            ),
            SizedBox(height: 20),
            // 温度显示
            TemperatureDisplay(
              celsius: _celsius,
              fahrenheit: _fahrenheit,
            ),
          ],
        ),
      ),
    );
  }
}

enum TemperatureScale { celsius, fahrenheit }

状态提升的架构图

┌─────────────────────────────────────┐
│        TemperatureConverter          │
│                                      │
│  ┌─────────────────────────────────┐ │
│  │           State                 │ │
│  │   double _celsius               │ │
│  │                                 │ │
│  │   void _handleCelsiusChange()   │ │
│  │   void _handleFahrenheitChange()│ │
│  └─────────────────────────────────┘ │
│              │              │        │
│              ▼              ▼        │
│  ┌────────────────┐ ┌────────────────┐
│  │TemperatureInput│ │TemperatureInput│
│  │(Celsius)       │ │(Fahrenheit)    │
└──┼────────────────┘ └────────────────┘
   │
   ▼
┌─────────────────┐
│TemperatureDisplay│
└─────────────────┘

3.3 状态提升的优势

  1. 单一数据源:所有子组件使用同一个状态源
  2. 数据一致性:避免状态不同步的问题
  3. 易于调试:状态变化的位置集中,易追踪
  4. 组件复用:子组件成为无状态组件,易复用

当组件层次较深时,状态提升会导致"prop drilling"问题:

// 问题示例
class App extends StatefulWidget {
  @override
  _AppState createState() => _AppState();
}

class _AppState extends State<App> {
  User _user = User();
  
  @override
  Widget build(BuildContext context) {
    return UserProvider(
      user: _user,
      child: HomePage(
        user: _user, // 需要层层传递
        onUserUpdate: (User newUser) {
          setState(() {
            _user = newUser;
          });
        },
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  final User user;
  final ValueChanged<User> onUserUpdate;
  
  const HomePage({required this.user, required this.onUserUpdate});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Header(
        user: user, // 继续传递
        onUserUpdate: onUserUpdate, // 继续传递
        child: Content(
          user: user, // 还要传递
          onUserUpdate: onUserUpdate, // 还要传递
        ),
      ),
    );
  }
}

// 中间可能还有多层组件...

这正是InheritedWidget要解决的问题。

4. InheritedWidget:状态共享

4.1 InheritedWidget的基本概念

InheritedWidget是Flutter中用于在组件树中高效向下传递数据的特殊Widget。它允许子组件直接访问祖先组件中的数据,而无需显式地通过构造函数传递。

4.2 InheritedWidget的工作原理

先创建一个简单的InheritedWidget

// InheritedWidget示例
class SimpleInheritedWidget extends InheritedWidget {
  // 要共享的数据
  final int counter;
  final VoidCallback onIncrement;

  const SimpleInheritedWidget({
    Key? key,
    required this.counter,
    required this.onIncrement,
    required Widget child,
  }) : super(key: key, child: child);

  // 静态方法,方便子组件获取实例
  static SimpleInheritedWidget of(BuildContext context) {
    final SimpleInheritedWidget? result = 
        context.dependOnInheritedWidgetOfExactType<SimpleInheritedWidget>();
    assert(result != null, 'No SimpleInheritedWidget found in context');
    return result!;
  }

  // 决定是否通知依赖的组件重建
  @override
  bool updateShouldNotify(SimpleInheritedWidget oldWidget) {
    // 只有当counter发生变化时,才通知依赖的组件重建
    return counter != oldWidget.counter;
  }
}

InheritedWidget的工作流程

┌──────────────────┐
│InheritedWidget   │
│                  │
│ - 存储共享数据   │
│ - updateShouldNotify│
└─────────┬────────┘
          │
          │ 1. 提供数据
          ▼
┌──────────────────┐
│   BuildContext   │
│                  │
│ - inheritFromWidgetOfExactType │
│ - dependOnInheritedWidgetOfExactType │
└─────────┬────────┘
          │
          │ 2. 注册依赖
          ▼
┌──────────────────┐
│   子组件         │
│                  │
│ - 通过of方法获取数据│
│ - 自动注册为依赖者 │
└──────────────────┘

4.3 使用InheritedWidget重构计数器

让我们用InheritedWidget重构之前的计数器应用:

// 计数器状态类
class CounterState {
  final int count;
  final VoidCallback increment;
  final VoidCallback decrement;
  final VoidCallback reset;

  CounterState({
    required this.count,
    required this.increment,
    required this.decrement,
    required this.reset,
  });
}

// 计数器InheritedWidget
class CounterInheritedWidget extends InheritedWidget {
  final CounterState counterState;

  const CounterInheritedWidget({
    Key? key,
    required this.counterState,
    required Widget child,
  }) : super(key: key, child: child);

  static CounterInheritedWidget of(BuildContext context) {
    final CounterInheritedWidget? result = 
        context.dependOnInheritedWidgetOfExactType<CounterInheritedWidget>();
    assert(result != null, 'No CounterInheritedWidget found in context');
    return result!;
  }

  @override
  bool updateShouldNotify(CounterInheritedWidget oldWidget) {
    return counterState.count != oldWidget.counterState.count;
  }
}

// 计数器显示组件 - 无需传递props
class CounterDisplay extends StatelessWidget {
  const CounterDisplay({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 直接通过InheritedWidget获取状态
    final counterState = CounterInheritedWidget.of(context).counterState;
    
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text(
          '当前计数:',
          style: Theme.of(context).textTheme.headline4,
        ),
        Text(
          '${counterState.count}',
          style: Theme.of(context).textTheme.headline2,
        ),
      ],
    );
  }
}

// 计数器按钮组件 - 无需传递回调
class CounterButtons extends StatelessWidget {
  const CounterButtons({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 直接通过InheritedWidget获取方法
    final counterState = CounterInheritedWidget.of(context).counterState;
    
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        ElevatedButton(
          onPressed: counterState.decrement,
          child: Text('减少'),
        ),
        SizedBox(width: 20),
        ElevatedButton(
          onPressed: counterState.reset,
          child: Text('重置'),
        ),
        SizedBox(width: 20),
        ElevatedButton(
          onPressed: counterState.increment,
          child: Text('增加'),
        ),
      ],
    );
  }
}

// 主组件
class CounterAppWithInherited extends StatefulWidget {
  @override
  _CounterAppWithInheritedState createState() => 
      _CounterAppWithInheritedState();
}

class _CounterAppWithInheritedState extends State<CounterAppWithInherited> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  void _decrement() {
    setState(() {
      _count--;
    });
  }

  void _reset() {
    setState(() {
      _count = 0;
    });
  }

  @override
  Widget build(BuildContext context) {
    // 创建状态对象
    final counterState = CounterState(
      count: _count,
      increment: _increment,
      decrement: _decrement,
      reset: _reset,
    );

    // 使用InheritedWidget包装整个子树
    return CounterInheritedWidget(
      counterState: counterState,
      child: Scaffold(
        appBar: AppBar(title: Text('InheritedWidget计数器')),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CounterDisplay(), // 无需传递任何参数
              SizedBox(height: 20),
              CounterButtons(), // 无需传递任何参数
            ],
          ),
        ),
      ),
    );
  }
}

4.4 InheritedWidget的深层含义

4.4.1 dependOnInheritedWidgetOfExactType vs getElementForInheritedWidgetOfExactType

Flutter提供了两种获取InheritedWidget的方法:

// 方法1:注册依赖关系,当InheritedWidget更新时会重建
static CounterInheritedWidget of(BuildContext context) {
  return context.dependOnInheritedWidgetOfExactType<CounterInheritedWidget>()!;
}

// 方法2:不注册依赖关系,只是获取引用
static CounterInheritedWidget of(BuildContext context) {
  final element = context.getElementForInheritedWidgetOfExactType<CounterInheritedWidget>();
  return element?.widget as CounterInheritedWidget;
}

区别

  • dependOnInheritedWidgetOfExactType建立依赖关系,当InheritedWidget更新时,调用该方法的组件会重建
  • getElementForInheritedWidgetOfExactType不建立依赖关系,只是获取当前值的引用,适合在回调或初始化时使用
4.4.2 updateShouldNotify的优化

updateShouldNotify方法对于性能优化至关重要:

@override
bool updateShouldNotify(CounterInheritedWidget oldWidget) {
  // 优化前:任何变化都通知
  // return true;
  
  // 优化后:只有count变化才通知
  return counterState.count != oldWidget.counterState.count;
  
  // 更精细的控制
  // return counterState.count != oldWidget.counterState.count ||
  //        counterState.someOtherProperty != oldWidget.counterState.someOtherProperty;
}

5. 实战案例:构建主题切换应用

通过一个完整的主题切换应用来综合运用以上所学知识:

import 'package:flutter/material.dart';

// 主题数据类
class AppTheme {
  final ThemeData themeData;
  final String name;

  const AppTheme({
    required this.themeData,
    required this.name,
  });
}

// 预定义主题
class AppThemes {
  static final light = AppTheme(
    name: '浅色主题',
    themeData: ThemeData.light().copyWith(
      primaryColor: Colors.blue,
      colorScheme: ColorScheme.light(
        primary: Colors.blue,
        secondary: Colors.green,
      ),
    ),
  );

  static final dark = AppTheme(
    name: '深色主题',
    themeData: ThemeData.dark().copyWith(
      primaryColor: Colors.blueGrey,
      colorScheme: ColorScheme.dark(
        primary: Colors.blueGrey,
        secondary: Colors.green,
      ),
    ),
  );

  static final custom = AppTheme(
    name: '自定义主题',
    themeData: ThemeData(
      primaryColor: Colors.purple,
      colorScheme: ColorScheme.light(
        primary: Colors.purple,
        secondary: Colors.orange,
      ),
      brightness: Brightness.light,
    ),
  );
}

// 应用状态类
class AppState {
  final AppTheme currentTheme;
  final Locale currentLocale;
  final bool isLoggedIn;
  final String userName;

  const AppState({
    required this.currentTheme,
    required this.currentLocale,
    required this.isLoggedIn,
    required this.userName,
  });

  // 拷贝更新方法
  AppState copyWith({
    AppTheme? currentTheme,
    Locale? currentLocale,
    bool? isLoggedIn,
    String? userName,
  }) {
    return AppState(
      currentTheme: currentTheme ?? this.currentTheme,
      currentLocale: currentLocale ?? this.currentLocale,
      isLoggedIn: isLoggedIn ?? this.isLoggedIn,
      userName: userName ?? this.userName,
    );
  }
}

// 应用InheritedWidget
class AppInheritedWidget extends InheritedWidget {
  final AppState appState;
  final ValueChanged<AppTheme> onThemeChanged;
  final ValueChanged<Locale> onLocaleChanged;
  final VoidCallback onLogin;
  final VoidCallback onLogout;

  const AppInheritedWidget({
    Key? key,
    required this.appState,
    required this.onThemeChanged,
    required this.onLocaleChanged,
    required this.onLogin,
    required this.onLogout,
    required Widget child,
  }) : super(key: key, child: child);

  static AppInheritedWidget of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<AppInheritedWidget>()!;
  }

  @override
  bool updateShouldNotify(AppInheritedWidget oldWidget) {
    return appState.currentTheme != oldWidget.appState.currentTheme ||
           appState.currentLocale != oldWidget.appState.currentLocale ||
           appState.isLoggedIn != oldWidget.appState.isLoggedIn ||
           appState.userName != oldWidget.appState.userName;
  }
}

// 主题切换组件
class ThemeSwitcher extends StatelessWidget {
  const ThemeSwitcher({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final app = AppInheritedWidget.of(context);
    
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '主题设置',
              style: Theme.of(context).textTheme.headline6,
            ),
            SizedBox(height: 10),
            Wrap(
              spacing: 10,
              children: [
                _buildThemeButton(
                  context,
                  AppThemes.light,
                  app.appState.currentTheme.name == AppThemes.light.name,
                  app.onThemeChanged,
                ),
                _buildThemeButton(
                  context,
                  AppThemes.dark,
                  app.appState.currentTheme.name == AppThemes.dark.name,
                  app.onThemeChanged,
                ),
                _buildThemeButton(
                  context,
                  AppThemes.custom,
                  app.appState.currentTheme.name == AppThemes.custom.name,
                  app.onThemeChanged,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildThemeButton(
    BuildContext context,
    AppTheme theme,
    bool isSelected,
    ValueChanged<AppTheme> onChanged,
  ) {
    return FilterChip(
      label: Text(theme.name),
      selected: isSelected,
      onSelected: (selected) {
        if (selected) {
          onChanged(theme);
        }
      },
      backgroundColor: isSelected 
          ? theme.themeData.primaryColor 
          : Theme.of(context).chipTheme.backgroundColor,
      labelStyle: TextStyle(
        color: isSelected ? Colors.white : null,
      ),
    );
  }
}

// 用户信息组件
class UserInfo extends StatelessWidget {
  const UserInfo({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final app = AppInheritedWidget.of(context);
    
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '用户信息',
              style: Theme.of(context).textTheme.headline6,
            ),
            SizedBox(height: 10),
            if (app.appState.isLoggedIn) ...[
              Text('用户名: ${app.appState.userName}'),
              SizedBox(height: 10),
              ElevatedButton(
                onPressed: app.onLogout,
                child: Text('退出登录'),
              ),
            ] else ...[
              Text('未登录'),
              SizedBox(height: 10),
              ElevatedButton(
                onPressed: app.onLogin,
                child: Text('模拟登录'),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('主题切换应用'),
        elevation: 0,
      ),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 欢迎信息
            Card(
              child: Padding(
                padding: EdgeInsets.all(16.0),
                child: Column(
                  children: [
                    Text(
                      '欢迎使用Flutter主题切换示例',
                      style: Theme.of(context).textTheme.headline5,
                    ),
                    SizedBox(height: 10),
                    Text(
                      '这是一个演示setState和InheritedWidget的综合示例应用。'
                      '您可以通过下方的控件切换应用主题和查看用户状态。',
                      style: Theme.of(context).textTheme.bodyText2,
                    ),
                  ],
                ),
              ),
            ),
            SizedBox(height: 20),
            // 主题切换
            ThemeSwitcher(),
            SizedBox(height: 20),
            // 用户信息
            UserInfo(),
            SizedBox(height: 20),
            // 内容示例
            _buildContentExample(context),
          ],
        ),
      ),
    );
  }

  Widget _buildContentExample(BuildContext context) {
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '内容示例',
              style: Theme.of(context).textTheme.headline6,
            ),
            SizedBox(height: 10),
            Text('这里展示了当前主题下的各种UI元素样式。'),
            SizedBox(height: 20),
            Wrap(
              spacing: 10,
              runSpacing: 10,
              children: [
                ElevatedButton(
                  onPressed: () {},
                  child: Text('主要按钮'),
                ),
                OutlinedButton(
                  onPressed: () {},
                  child: Text('边框按钮'),
                ),
                TextButton(
                  onPressed: () {},
                  child: Text('文本按钮'),
                ),
              ],
            ),
            SizedBox(height: 20),
            LinearProgressIndicator(
              value: 0.7,
              backgroundColor: Colors.grey[300],
            ),
            SizedBox(height: 10),
            CircularProgressIndicator(),
          ],
        ),
      ),
    );
  }
}

// 主应用
class ThemeSwitcherApp extends StatefulWidget {
  @override
  _ThemeSwitcherAppState createState() => _ThemeSwitcherAppState();
}

class _ThemeSwitcherAppState extends State<ThemeSwitcherApp> {
  AppState _appState = AppState(
    currentTheme: AppThemes.light,
    currentLocale: const Locale('zh', 'CN'),
    isLoggedIn: false,
    userName: '',
  );

  void _changeTheme(AppTheme newTheme) {
    setState(() {
      _appState = _appState.copyWith(currentTheme: newTheme);
    });
  }

  void _changeLocale(Locale newLocale) {
    setState(() {
      _appState = _appState.copyWith(currentLocale: newLocale);
    });
  }

  void _login() {
    setState(() {
      _appState = _appState.copyWith(
        isLoggedIn: true,
        userName: 'Flutter用户',
      );
    });
  }

  void _logout() {
    setState(() {
      _appState = _appState.copyWith(
        isLoggedIn: false,
        userName: '',
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return AppInheritedWidget(
      appState: _appState,
      onThemeChanged: _changeTheme,
      onLocaleChanged: _changeLocale,
      onLogin: _login,
      onLogout: _logout,
      child: MaterialApp(
        title: '主题切换示例',
        theme: _appState.currentTheme.themeData,
        locale: _appState.currentLocale,
        home: HomePage(),
        debugShowCheckedModeBanner: false,
      ),
    );
  }
}

6. 性能优化

6.1 避免不必要的重建

使用InheritedWidget时,要注意避免不必要的组件重建:

// 优化前:整个子树都会重建
@override
bool updateShouldNotify(AppInheritedWidget oldWidget) {
  // 总是通知重建
  return true; 
}

// 优化后:只有相关数据变化时才重建
@override
bool updateShouldNotify(AppInheritedWidget oldWidget) {
  return appState.currentTheme != oldWidget.appState.currentTheme;
  // 或者其他需要监听的状态变化
}

6.2 使用Consumer模式

对于复杂的应用,可以使用Consumer模式来进一步优化:

// 自定义Consumer组件
class ThemeConsumer extends StatelessWidget {
  final Widget Function(BuildContext context, AppTheme theme) builder;

  const ThemeConsumer({
    Key? key,
    required this.builder,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final theme = AppInheritedWidget.of(context).appState.currentTheme;
    return builder(context, theme);
  }
}

// 示例
ThemeConsumer(
  builder: (context, theme) {
    return Container(
      color: theme.themeData.primaryColor,
      child: Text(
        '使用Consumer模式',
        style: theme.themeData.textTheme.headline6,
      ),
    );
  },
)

6.3 组合使用setState和InheritedWidget

在实际应用中,很多组件都是组合使用的。

class HybridApp extends StatefulWidget {
  @override
  _HybridAppState createState() => _HybridAppState();
}

class _HybridAppState extends State<HybridApp> {
  // 全局状态 - 使用InheritedWidget共享
  final GlobalAppState _globalState = GlobalAppState();
  
  // 局部状态 - 使用setState管理
  int _localCounter = 0;

  @override
  Widget build(BuildContext context) {
    return GlobalStateInheritedWidget(
      state: _globalState,
      child: Scaffold(
        body: Column(
          children: [
            // 使用全局状态的组件
            GlobalUserInfo(),
            // 使用局部状态的组件
            LocalCounter(
              count: _localCounter,
              onIncrement: () {
                setState(() {
                  _localCounter++;
                });
              },
            ),
          ],
        ),
      ),
    );
  }
}

7. 总结与对比

7.1 setState vs InheritedWidget 对比

特性 setState InheritedWidget
适用场景 局部状态、简单交互 全局状态、跨组件共享
使用复杂度 简单直接 相对复杂
性能影响 重建整个子树 精确控制重建范围
测试难度 相对困难 相对容易

7.2 如何选择?

使用setState

  • 状态只在单个组件内部使用
  • 应用简单,组件层次浅
  • 状态变化频率低

使用InheritedWidget

  • 状态需要在多个组件间共享
  • 组件层次深,避免prop drilling
  • 需要精确控制重建范围

7.3 更高级的状态管理

  1. Provider:基于InheritedWidget的封装,更易用的状态管理
  2. Bloc/RxDart:响应式编程模式的状态管理
  3. Riverpod:Provider的改进版本,编译安全的状态管理
  4. GetX:轻量级但功能全面的状态管理解决方案

通过以上内容,我们掌握了Flutter状态管理的基础:setStateInheritedWidget。这两种方案虽然基础,但它们是理解更复杂状态管理方案的基础。记住:一定要多写!!!一定要多写!!!一定要多写!!! 希望本文对你理解Flutter状态管理有所帮助!如果你觉得有用,请一键三连(点赞、关注、收藏)

Swift 泛型深度指南 ——从“交换两个值”到“通用容器”的代码复用之路

作者 unravel2025
2025年11月4日 09:08

为什么需要泛型

  1. 无泛型时代的“粘贴式”编程
// 只能交换 Int
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temp = a
    a = b
    b = temp
}

// 复制粘贴,改个类型名
func swapTwoStrings(_ a: inout String, _ b: inout String) {
    let temp = a
    a = b
    b = temp
}

问题:

  • 代码完全一样,仅类型不同
  • 维护 N 份副本,牵一发而动全身

泛型函数:写一次,跑所有类型

  1. 语法与占位符 T
// <T> 声明一个“占位符类型”
// 编译器每次调用时把 T 替换成真实类型
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}
  1. 调用方式:编译器自动推断 T
var x = 3, y = 5
swapTwoValues(&x, &y)          // T 被推断为 Int

var s1 = "A", s2 = "B"
swapTwoValues(&s1, &s2)        // T 被推断为 String

要点:

  • 占位符名可以是任意合法标识符,习惯单字母 TUV
  • 同一函数签名里所有 T 必须是同一真实类型,不允许 swapTwoValues(3, "hello")

泛型类型:自定义可复用容器

  1. 非泛型版 Int 栈
struct IntStack {
    private var items: [Int] = []
    mutating func push(_ item: Int) { items.append(item) }
    mutating func pop() -> Int { items.removeLast() }
}
  1. 泛型版 Stack
struct Stack<Element> {               // Element 为占位符
    private var items: [Element] = []
    mutating func push(_ item: Element) { items.append(item) }
    mutating func pop() -> Element { items.removeLast() }
}

// 用法
var intStack = Stack<Int>()
intStack.push(1)
intStack.push(2)
print(intStack.pop())   // 2

var strStack = Stack<String>()
strStack.push("🍎")
strStack.push("🍌")
print(strStack.pop())   // 🍌

类型约束:给“任意类型”划边界

  1. 场景:在容器里查找元素索引
// 编译失败版:并非所有 T 都支持 ==
func findIndex<T>(of valueToFind: T, in array: [T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {          // ❌ 错误:T 不一定能比较
            return index
        }
    }
    return nil
}
  1. 加入 Equatable 约束
func findIndex<T: Equatable>(of valueToFind: T, in array: [T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {          // ✅ 现在合法
            return index
        }
    }
    return nil
}

小结:

  • 语法 <T: SomeProtocol><T: SomeClass>
  • 可同时约束多个占位符:<T: Equatable, U: Hashable>

关联类型(associatedtype):协议里的“泛型”

  1. 定义容器协议
protocol Container {
    associatedtype Item                // 占位名
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(index: Int) -> Item { get }
}
  1. 让泛型 Stack 直接 conform
extension Stack: Container {
    // 编译器自动推断:
    // append 参数 item 类型 = Element
    // subscript 返回值类型 = Element
    // 故 Item == Element,无需手写 typealias
}
  1. 给关联类型加约束
protocol EquatableContainer: Container where Item: Equatable { }
// 现在所有 Item 必须支持 ==,可直接在扩展里使用 ==/!=

泛型 where 子句:更细粒度的约束

  1. 函数级 where
// 检查两个容器是否完全一致(顺序、元素、数量)
func allItemsMatch<C1: Container, C2: Container>
    (_ someContainer: C1, _ anotherContainer: C2) -> Bool
    where C1.Item == C2.Item, C1.Item: Equatable
{
    if someContainer.count != anotherContainer.count { return false }
    for i in 0..<someContainer.count {
        if someContainer[i] != anotherContainer[i] { return false }
    }
    return true
}
  1. 扩展级 where
extension Stack where Element: Equatable {
    // 仅当元素可比较时才出现该方法
    func isTop(_ item: Element) -> Bool {
        guard let top = items.last else { return false }
        return top == item
    }
}
  1. 下标也能泛型 + where
extension Container {
    // 接收一组索引,返回对应元素数组
    subscript<Indices: Sequence>(indices: Indices) -> [Item]
        where Indices.Element == Int
    {
        var result: [Item] = []
        for index in indices {
            result.append(self[index])
        }
        return result
    }
}

隐式约束与 Copyable(Swift 5.9+)

Swift 自动为泛型参数加上 Copyable 约束,以便值能被多次使用。

若你明确允许“可复制或不可复制”,用前缀 ~ 抑制隐式约束:

func consumeOnce<T>(_ x: consuming T) where T: ~Copyable {
    // x 只能被移动一次,不能再复制
}

示例代码

把下面代码一次性粘进 Playground,逐行跑通:

import Foundation

// 1. 通用交换
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let t = a; a = b; b = t
}

// 2. 泛型栈
struct Stack<Element> {
    private var items: [Element] = []
    mutating func push(_ e: Element) { items.append(e) }
    mutating func pop() -> Element { items.removeLast() }
}
extension Stack: Container {
    typealias Item = Element
    mutating func append(_ item: Element) { push(item) }
    var count: Int { items.count }
    subscript(i: Int) -> Element { items[i] }
}

// 3. 协议
protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(index: Int) -> Item { get }
}

// 4. 扩展约束
extension Stack where Element: Equatable {
    func isTop(_ e: Element) -> Bool { items.last == e }
}

// 5. 测试
var s = Stack<Int>()
s.push(10)
s.push(20)
print(s.isTop(20))        // true
print(s.pop())            // 20

var a: [String] = ["A","B","C"]
var b: Array<String> = ["A","B","C"]
print(allItemsMatch(a, b)) // true

总结与实战扩展

  1. 泛型 = 代码的“模板引擎”

    把“类型”也当成参数,一次性书写,多处复用,减少 BUG

  2. 约束是“接口隔离”的利器

    与其写长篇文档说明“请传能比较的值”,不如让编译器帮你拦下来:<T: Equatable>

  3. 关联类型让协议“带泛型”

    协议不再只能定义“行为”,还能定义“元素类型”,使协议与泛型容器无缝衔接。

  4. where 子句是“需求说明书”

    函数、扩展、下标都能写 where,把“调用前提”写进类型系统,用不合法的状态直接编译不过,比运行时断言更早暴露问题。

  5. 实战场景举例

    • JSON 解析层:写一个 Decoder<T: Decodable>,一份代码支持所有模型

    -缓存框架:定义 Cache<Key: Hashable, Value>,Key 约束为 Hashable,Value 任意类型

    • 网络请求:Request<Resp: Decodable>,响应体自动解析为任意模型

Swift 中的不透明类型与装箱协议类型:概念、区别与实践

作者 unravel2025
2025年11月4日 09:04

前言

Swift 提供了两种隐藏类型信息的方式:不透明类型(opaque type) 和 装箱协议类型(boxed protocol type)。

它们都用于隐藏具体类型,但在类型身份、性能、灵活性等方面有本质区别。

不透明类型(Opaque Types)

基本概念

不透明类型允许函数返回一个遵循某个协议的具体类型,但调用者无法知道具体是什么类型。编译器知道类型信息,但调用者不知道。

使用关键字 some 来声明不透明类型。

protocol Shape {
    func draw() -> String
}

struct Square: Shape {
    func draw() -> String {
        return "■"
    }
}

func makeSquare() -> some Shape {
    return Square()
}

调用者知道返回的是一个 Shape,但不知道它是 Square

与泛型的区别

泛型是调用者决定类型,不透明类型是实现者决定类型。

// 泛型:调用者决定类型
func maxNum<T: Comparable>(_ x: T, _ y: T) -> T {
    // 实现者,不知道T是什么类型,只知道T可以进行比较
    return x > y ? x : y
}

// 调用者传入1,3。编译器会自动推断T为Int
// 也可以补全写法 maxNum<Int>(1, 3)
let maxInt = maxNum(1,3)


// 不透明类型:实现者决定类型
func makeShape() -> some Shape {
    // 实现者 决定最终返回的具体类型
    return Square()
}
// 下面的写法报错,调用者不能指定类型
// Cannot convert value of type 'some Shape' to specified type 'Square'
//let shape: Square = makeShape()
// 只能用some Shape
let shape: some Shape = makeShape()

不透明类型的限制:必须返回单一类型

struct FlippedShape: Shape {
    var shape: Shape
    init(_ shape:  Shape) {
        self.shape = shape
    }
    func draw() -> String {
        let lines = shape.draw().split(separator: "\n")
        return lines.reversed().joined(separator: "\n")
    }
}
// ❌ 错误:返回了不同类型
// Function declares an opaque return type 'some Shape', but the return statements in its body do not have matching underlying types
func invalidFlip<T: Shape>(_ shape: T) -> some Shape {
    if shape is Square {
        return shape // 返回的是 T
    }
    return FlippedShape(shape) // 返回的是 FlippedShape<T>
}

✅ 正确做法:统一返回类型

func flip<T: Shape>(_ shape: T) -> some Shape {
    return FlippedShape(shape) // 总是返回 FlippedShape<T>
}

泛型与不透明类型结合使用

struct RepeatedShape: Shape {
    let shape: Shape
    let count: Int
    
    func draw() -> String {
        return (0..<count).map { _ in shape.draw() }.joined(separator: "\n")
    }
}

func repeatShape<T: Shape>(shape: T, count: Int) -> some Shape {
    return RepeatedShape(shape: shape, count: count)
}

虽然 T 是泛型,但返回类型始终是 RepeatedShape<T>,满足单一类型要求。

装箱协议类型(Boxed Protocol Types)

基本概念

装箱协议类型使用 any 关键字,表示“任意遵循该协议的类型”,类型信息在运行时确定,类型身份不保留。

let shape: any Shape = Square()

any Shape 可以存储任何遵循 Shape 的类型,类似于 Objective-C 的 id<Protocol>

使用示例

struct VerticalShapes {
    var shapes: [any Shape] // 可以存储不同类型的 Shape

    func draw() -> String {
        return shapes.map { $0.draw() }.joined(separator: "\n")
    }
}

类型擦除与运行时类型检查

struct Triangle: Shape {
    func draw() -> String {
        return "🔺"
    }
}

let shapes: [any Shape] = [Square(), Triangle()]

for shape in shapes {
    if let square = shape as? Square {
        print("这是一个方块:\(square.draw())")
    }
}

不透明类型 vs 装箱协议类型

特性 不透明类型 some 装箱协议类型 any
类型身份 保留(编译期已知) 不保留(运行时确定)
灵活性 低(只能返回一种类型) 高(可返回多种类型)
性能 更好(无运行时开销) 有装箱开销
是否支持协议关联类型 ✅ 支持 ❌ 不支持

代码示例对比

不透明类型版本

func flip<T: Shape>(_ shape: T) -> some Shape {
    return FlippedShape(shape)
}

let flipped = flip(Square())
let doubleFlipped = flip(flipped) // ✅ 支持嵌套

装箱协议类型版本

func protoFlip(_ shape: any Shape) -> any Shape {
    return FlippedShape(shape)
}

let flipped = protoFlip(Square())
let doubleFlipped = protoFlip(flipped)  // ✅ 支持嵌套

不透明参数类型(some 作为参数)

func drawTwice(_ shape: some Shape) {
    print(shape.draw())
    print(shape.draw())
}

等价于泛型函数:

func drawTwice<S: Shape>(_ shape: S) {
    print(shape.draw())
    print(shape.draw())
}

总结与最佳实践

使用场景 推荐类型
隐藏实现细节,返回单一类型 some Protocol
存储多种协议类型 any Protocol
需要运行时类型判断 any Protocol

在实际项目中的应用

SwiftUI 中的不透明类型

var body: some View {
    VStack {
        Text("Hello")
        Image(systemName: "star")
    }
}

body 返回的是某个具体类型(如 _VStack<TupleView<(Text, Image)>>),但调用者无需关心。

网络层返回模型抽象

protocol Model {
    static func parse(from data: Data) -> Self?
}

func fetch<T: Model>(_ type: T.Type) -> some Model {
    // 隐藏具体模型类型,返回某个遵循 Model 的类型
}

结语

不透明类型和装箱协议类型是 Swift 类型系统中非常强大的工具,它们各自解决了不同层面的抽象问题:

  • some 更适合封装实现细节,提供类型安全的抽象接口;
  • any 更适合运行时灵活性,但牺牲类型信息和性能。

理解它们的本质区别,能帮助你在设计 API、构建模块时做出更合理的架构决策。

参考资料

惊险但幸运,两次!| 肘子的 Swift 周报 #0109

作者 东坡肘子
2025年11月4日 07:59

issue109.webp

📮 想持续关注 Swift 技术前沿?

每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。

一起构建更好的 Swift 应用!🚀

惊险但幸运,两次!

我的岳父岳母的身体一向很好,尤其是岳父,八十多岁了从未住过院。但在短短的时间里,他们先后经历了两场惊魂时刻——好在结果都十分幸运。

先是岳母。那天我们刚从机场接她回家,发现她脸色不好,自述昨晚没睡好,似乎又有肾结石发作的迹象。正当我们决定取消外出用餐时,她突然开始剧烈发抖,但额头仍是凉的。测量血氧后发现指标在快速下降——即使立即开始吸氧,最低时也掉到了 78。

救护车到达时(约 40 分钟后),体温已升至 39.8℃;送医时更是接近 41℃。人的状态非常差。幸运的是,在此前她提到过肾结石的不适,医生据此快速展开检查,确诊为尿源性脓毒症——结石堵住输尿管,细菌在尿液中迅速繁殖,最终进入血液。 医生直言,若再晚些送来,后果可能不堪设想。想到她刚下飞机,我们也不禁后怕——幸亏航班没有延误,幸亏那天决定乘坐早班机。经过紧急手术引流后,她的状况在第二天便明显好转,数日后顺利出院。

不久之后,又轮到岳父经历一次“惊险但幸运”的考验。 在照顾岳母期间,他出现咳嗽并伴随低热。CT 显示只是支气管扩张,但考虑到年龄,我们坚持让他住院观察。 在例行心电图检查中,医生意外发现他有房颤,心率在 130–170 之间剧烈波动。令人吃惊的是,他本人毫无不适感。事后他说,今年 5 月体检时医生就提醒过有房颤,但他没当回事,也没有告诉我们。 由于心率过快,治疗重心立即转为控制心律。幸运再次眷顾——在使用胺碘酮(Amiodarone)数小时后,心率逐渐恢复至 80 以下,成功复律。如今虽然仍在住院,但情况稳定,只需出院后按医嘱服用控制心率与抗凝药物即可。这次的阴差阳错,也让他在不经意间避免了房颤可能带来的更严重后果。

短短一周内的两次意外,让人既感叹生命的脆弱,也更懂得珍惜当下。幸运往往属于那些及时行动、认真对待健康警示的人。愿我们都能对自己和家人的身体多一分关注,少一分侥幸。

前一期内容全部周报列表

近期推荐

让 onChange 同时监听多个值 onChange(anyOf:initial:_:)

onChange 是 SwiftUI 中常用的状态监听工具,开发者可以通过它在视图中针对某个值的变化执行逻辑。不过,尽管该修饰符历经多次改进,它仍然只能同时观察一个值。若要在多个状态变更时执行相同操作,就需要重复添加多个内容相同的 onChange 闭包。在本文中,Matt Comi 借助 variadic generics(可变参数泛型),让 onChange 能够同时监听多个值,从而构建出一个更高效、更优雅的解决方案。


SwiftUI 中的滚动吸附 (ScrollView snapping in SwiftUI)

从 iOS 17 开始,开发者可以通过 scrollTargetBehavior 控制 ScrollView 的滚动行为,比如按页面吸附或按视图吸附。Natalia Panferova 在这篇文章中详细探讨了该 API 的用法,并分享了实践经验和需要注意的陷阱:视图对齐模式要求每个吸附目标适配可见区域;当单个项目尺寸超过容器大小时,滚动会感觉"卡住";对于超大项目,需要考虑自定义 ScrollTargetBehavior

精确掌控 SwiftUI 滚动:自定义 Paging 实现 一文中,我也介绍了如何通过自定义 ScrollTargetBehavior 解决横屏模式(Landscape)下滚动偏移、页面对齐不精确的问题。


Swift 类型检查器改进路线图 (Roadmap for improving the type checker)

作为 Swift 开发者,你一定不会对那句 "unable to type-check this expression in reasonable time" 感到陌生。为什么会出现这个编译错误?Swift 又将如何减少这种情况的发生?Swift 核心团队成员 Slava Pestov 在这份路线图中给出了详细的解释与改进方向。

该问题源自约束求解(constraint solving)的指数时间复杂度。为防止编译器陷入无休止的回溯,Swift 设定了两项限制:析取选择次数上限(100 万次) 和 求解器内存上限(512 MB)。Swift 6.2 通过优化底层算法实现了初步提速,而 Swift 6.3 引入的新析取选择算法与内存优化带来了显著改进:同一测试项目的类型检查时间从 42 秒降至 10 秒。Swift 团队的目标是在不牺牲语言表达力的前提下,通过算法与实现层的持续优化,将“指数最坏情况”压缩到更少、更边缘的实际场景中。


iOS Sheet 的现代化演进 (Playing with Sheet (on iOS))

在 iOS 上,Sheet 曾经意味着“全屏接管”——从底部滑出、阻挡一切、等待用户点击“完成”。但如今,这种模式已经过去。Apple 重新定义了内容的呈现方式:在新的设计哲学下,Sheet 不再是中断,而是节奏的一部分——它可以滑动、漂浮、扩展、折叠,让界面保持连贯与呼吸感。Danny Bolella 从多个层面展示了现代 Sheet 的可定制特性,并通过实例演示了这些特性如何让 Sheet 从“流程中断”转变为“上下文扩展”。

针对 Sheet 无法自动适应内容高度的问题,开发者可以通过 GeometryReader 测量内容高度并结合 .id() 刷新的技巧来实现动态高度调整。


iOS 性能优化:Apple 工程师问答精选 (Optimize Your App's Speed and Efficiency: Q&A)

Anton Gubarenko 整理了 Apple “Optimize your app’s speed and efficiency” 活动中的 Q&A 内容,涵盖 SwiftUI 性能(闭包捕获优化、Observable 使用、@Binding vs let)、Liquid Glass 容器(GlassEffectContainer 的最佳实践)、Foundation Models 框架(多语言支持、并发使用、延迟优化)以及 Instruments 工具(Hitch vs Hang、新增 Power Profiler)等关键领域。

本次 Q&A 展现了 Apple 工程团队在性能调优层面的实践取向:通过细节分析与工具驱动,让优化从“黑盒经验”转变为“可度量、可验证的工程流程”。


Swift 中禁用单个弃用警告的技巧 (Workaround: how to silence individual deprecation warnings in Swift)

开发中难免必须使用一些被软废弃(deprecated)的 API。在开启“警告即错误”(-warnings-as-errors)后,将面临无法编译的窘境。不同于 Objective-C 可以使用 #pragma clang diagnostic 针对特定代码段禁用警告,Swift 至今没有等效机制。

Jesse Squires 分享了一个巧妙的解决方案:定义一个协议包装弃用 API,在扩展中实现时标记为 @available(*, deprecated),然后通过类型转换为协议类型来调用。编译器会通过协议见证表查找方法,从而“隐藏”弃用警告。虽然方案略显冗长,但对于必须使用遗留 API 的纯 Swift 项目很实用。


深入 Swift 底层:从二进制优化到逆向工程

以下几篇文章都偏硬核,适合关注工具链、运行时与二进制层面细节的读者。

工具

Swift Stream IDE - 跨平台 Swift 开发扩展

Swift Stream IDE 是由 Mikhail Isaev 开发的功能强大的 VS Code 扩展,旨在让开发者能够流畅地构建面向非 Apple 平台的 Swift 项目。它基于 VS Code 的 Dev Containers 扩展,将编译器、依赖与工具链完整封装在 Docker 容器中,实现了真正一致、可移植的跨平台 Swift 开发体验。目前,Swift Stream 已支持多种项目类型,包括 Web(WebAssembly)、服务器(Vapor/Hummingbird)、Android(库开发)、嵌入式(ESP32-C6、树莓派等)等多种项目类型。

The Swift Android Setup I Always Wanted 一文中,Mikhail 还演示了如何结合 Swift Stream IDE、swift-android-sdk 与 JNIKit,高效构建 Android 原生库。

容器化开发的优势包括:保持宿主机环境整洁、确保旧项目的可编译性、支持跨平台一致的开发体验,以及可通过 SSH 远程连接到更强大的机器进行开发。


AnyLanguageModel - 统一 Swift 大模型开发范式

或许许多开发者不会在第一时间使用 WWDC 2025 上推出的 Foundation Models 框架,但大多数人都对它的 API 印象深刻——这一框架显著简化了与大模型的交互流程,并充分发挥了 Swift 语言特性。

Mattt 开发的 AnyLanguageModel 实现了与 Foundation Models 框架完全兼容的 API:只需将 import FoundationModels 换成 import AnyLanguageModel,就能在不改动上层业务代码的前提下,接入多家云端及本地模型后端(OpenAI、Anthropic、Ollama、llama.cpp、Core ML、MLX 以及 Foundation Models)。此外,该库还利用 Swift 6.1 的 Traits 特性,可按需引入重依赖,从而显著降低二进制体积并提升构建速度。


Metal Lab - 基于 Apple Metal API 生态的中文图形学社区

在中文互联网上,关于 Metal 的教程资源非常稀少,尽管 Apple 官方提供了详尽的文档和示例代码,但这些文档对于国内开发者而言往往语言晦涩、结构复杂,缺乏通俗易懂的入门指导,初学者常常感到难以上手。Metal Lab 提供的教程文档旨在填补这一空白,为中文开发者提供一份系统性、易懂、循序渐进的 Metal 入门资料,帮助他们从零开始掌握 Metal 编程的精髓。无论是游戏开发、图形渲染,还是计算机视觉应用,这都是一份值得收藏的中文资源。

往期内容

THANK YOU

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

📮 想持续关注 Swift 技术前沿?

每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。

一起构建更好的 Swift 应用!🚀

《Flutter全栈开发实战指南:从零到高级》- 09 -常用UI组件库实战

2025年11月3日 10:21

《Flutter全栈开发实战指南:从零到高级》- 09 -常用UI组件库深度解析与实战

1. 前言:UI组件库在Flutter开发中的核心地位

在Flutter应用开发中,UI组件库构成了应用界面的基础版块块。就像建筑工人使用标准化的砖块、门窗和楼梯来快速建造房屋一样,Flutter开发者使用组件库来高效构建应用界面。

组件库的核心价值:

  • 提高开发效率,减少重复代码
  • 保证UI一致性
  • 降低设计和技术门槛
  • 提供最佳实践和性能优化

2. Material Design组件

2.1 Material Design设计架构

Material Design是Google推出的设计语言,它的核心思想是将数字界面视为一种特殊的"材料" 。这种材料具有物理特性:可以滑动、折叠、展开,有阴影和深度,遵循真实的物理规律。

Material Design架构层次:

┌─────────────────┐
     动效层         提供有意义的过渡和反馈
├─────────────────┤
   组件层           按钮卡片对话框等UI元素
├─────────────────┤
   颜色/字体层      色彩系统和字体层级
├─────────────────┤
   布局层           栅格系统和间距规范
└─────────────────┘

2.2 核心布局组件详解

2.2.1 Scaffold:应用骨架组件

Scaffold是Material应用的基础布局结构,它协调各个视觉元素的位置关系。

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('应用标题'),
        actions: [
          IconButton(icon: Icon(Icons.search), onPressed: () {})
        ],
      ),
      drawer: Drawer(
        child: ListView(
          children: [/* 抽屉内容 */]
        ),
      ),
      body: Center(child: Text('主要内容')),
      bottomNavigationBar: BottomNavigationBar(
        items: [/* 导航项 */],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: Icon(Icons.add),
      ),
    );
  }
}

Scaffold组件关系图:

Scaffold
├── AppBar (顶部应用栏)
├── Drawer (侧边抽屉)
├── Body (主要内容区域)
├── BottomNavigationBar (底部导航)
└── FloatingActionButton (悬浮按钮)
2.2.2 Container:多功能容器组件

Container是Flutter中最灵活的布局组件,可以理解为HTML中的div元素。

Container(
  width: 200,
  height: 100,
  margin: EdgeInsets.all(16),
  padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
  decoration: BoxDecoration(
    color: Colors.blue[50],
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Colors.grey.withOpacity(0.5),
        blurRadius: 5,
        offset: Offset(0, 3),
      )
    ],
  ),
  child: Text('容器内容'),
)

Container布局流程:

graph TD
    A[Container创建] --> B{有子组件?}
    B -->|是| C[包裹子组件]
    B -->|否| D[填充可用空间]
    C --> E[应用约束条件]
    D --> E
    E --> F[应用装饰效果]
    F --> G[渲染完成]

2.3 表单组件深度实战

表单是应用中最常见的用户交互模式,Flutter提供了完整的表单解决方案。

2.3.1 表单验证架构
class LoginForm extends StatefulWidget {
  @override
  _LoginFormState createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(
              labelText: '邮箱',
              border: OutlineInputBorder(),
            ),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return '邮箱不能为空';
              }
              if (!RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$')
                  .hasMatch(value)) {
                return '请输入有效的邮箱地址';
              }
              return null;
            },
          ),
          SizedBox(height: 16),
          TextFormField(
            controller: _passwordController,
            obscureText: true,
            decoration: InputDecoration(
              labelText: '密码',
              border: OutlineInputBorder(),
            ),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return '密码不能为空';
              }
              if (value.length < 6) {
                return '密码至少6位字符';
              }
              return null;
            },
          ),
          SizedBox(height: 24),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                _performLogin();
              }
            },
            child: Text('登录'),
          ),
        ],
      ),
    );
  }

  void _performLogin() {
    // 执行登录逻辑
  }
}

表单验证流程图:

sequenceDiagram
    participant U as 用户
    participant F as Form组件
    participant V as 验证器
    participant S as 提交逻辑

    U->>F: 点击提交按钮
    F->>V: 调用验证器
    V->>V: 检查每个字段
    alt 验证通过
        V->>F: 返回null
        F->>S: 执行提交逻辑
        S->>U: 显示成功反馈
    else 验证失败
        V->>F: 返回错误信息
        F->>U: 显示错误提示
    end

3. Cupertino风格组件:iOS原生体验

3.1 Cupertino

Cupertino设计语言基于苹果的Human Interface Guidelines,强调清晰、遵从和深度。

Cupertino设计原则:

  • 清晰度:文字易读,图标精确
  • 遵从性:内容优先,UI辅助
  • 深度:层级分明,动效过渡自然

3.2 Cupertino组件实战

3.2.1 Cupertino页面架构
import 'package:flutter/cupertino.dart';

class CupertinoStylePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text('iOS风格页面'),
        trailing: CupertinoButton(
          child: Icon(CupertinoIcons.add),
          onPressed: () {},
        ),
      ),
      child: SafeArea(
        child: ListView(
          children: [
            CupertinoListSection(
              children: [
                CupertinoListTile(
                  title: Text('设置'),
                  leading: Icon(CupertinoIcons.settings),
                  trailing: CupertinoListTileChevron(),
                  onTap: () {},
                ),
                CupertinoListTile(
                  title: Text('通知'),
                  leading: Icon(CupertinoIcons.bell),
                  trailing: CupertinoSwitch(
                    value: true,
                    onChanged: (value) {},
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Cupertino页面结构图:

CupertinoPageScaffold
├── CupertinoNavigationBar
│   ├── leading (左侧按钮)
│   ├── middle (标题)
│   └── trailing (右侧按钮)
└── child (主要内容)
    └── SafeArea
        └── ListView
            └── CupertinoListSection
                ├── CupertinoListTile
                └── CupertinoListTile
3.2.2 自适应开发模式

在跨平台开发中,提供平台原生的用户体验非常重要。

class AdaptiveComponent {
  static Widget buildButton({
    required BuildContext context,
    required String text,
    required VoidCallback onPressed,
  }) {
    final isIOS = Theme.of(context).platform == TargetPlatform.iOS;
    
    if (isIOS) {
      return CupertinoButton(
        onPressed: onPressed,
        child: Text(text),
      );
    } else {
      return ElevatedButton(
        onPressed: onPressed,
        child: Text(text),
      );
    }
  }

  static void showAlert({
    required BuildContext context,
    required String title,
    required String content,
  }) {
    final isIOS = Theme.of(context).platform == TargetPlatform.iOS;
    
    if (isIOS) {
      showCupertinoDialog(
        context: context,
        builder: (context) => CupertinoAlertDialog(
          title: Text(title),
          content: Text(content),
          actions: [
            CupertinoDialogAction(
              child: Text('确定'),
              onPressed: () => Navigator.pop(context),
            ),
          ],
        ),
      );
    } else {
      showDialog(
        context: context,
        builder: (context) => AlertDialog(
          title: Text(title),
          content: Text(content),
          actions: [
            TextButton(
              child: Text('确定'),
              onPressed: () => Navigator.pop(context),
            ),
          ],
        ),
      );
    }
  }
}

平台适配流程图:

graph LR
    A[组件初始化] --> B{检测运行平台}
    B -->|iOS| C[使用Cupertino组件]
    B -->|Android| D[使用Material组件]
    C --> E[渲染iOS风格UI]
    D --> F[渲染Material风格UI]

4. 第三方UI组件库

4.1 第三方库选择标准与架构

在选择第三方UI库时,需要有一定系统的评估标准。当然这些评估标准也没有定式,适合自己的才是最重要的~~~

第三方库评估矩阵:

评估维度 权重 评估标准
维护活跃度 30% 最近更新、Issue响应
文档完整性 25% API文档、示例代码
测试覆盖率 20% 单元测试、集成测试
社区生态 15% Star数、贡献者
性能表现 10% 内存占用、渲染性能

4.2 状态管理库集成

状态管理是复杂应用的核心,Provider是目前最流行的解决方案之一。

import 'package:provider/provider.dart';

// 用户数据模型
class UserModel with ChangeNotifier {
  String _name = '默认用户';
  int _age = 0;

  String get name => _name;
  int get age => _age;

  void updateUser(String newName, int newAge) {
    _name = newName;
    _age = newAge;
    notifyListeners(); // 通知监听者更新
  }
}

// 主题数据模型
class ThemeModel with ChangeNotifier {
  bool _isDarkMode = false;

  bool get isDarkMode => _isDarkMode;
  
  void toggleTheme() {
    _isDarkMode = !_isDarkMode;
    notifyListeners();
  }
}

// 应用入口配置
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => UserModel()),
        ChangeNotifierProvider(create: (_) => ThemeModel()),
      ],
      child: MyApp(),
    ),
  );
}

// 使用Provider的页面
class ProfilePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('用户资料'),
      ),
      body: Consumer2<UserModel, ThemeModel>(
        builder: (context, user, theme, child) {
          return Column(
            children: [
              ListTile(
                title: Text('用户名: ${user.name}'),
                subtitle: Text('年龄: ${user.age}'),
              ),
              SwitchListTile(
                title: Text('深色模式'),
                value: theme.isDarkMode,
                onChanged: (value) => theme.toggleTheme(),
              ),
            ],
          );
        },
      ),
    );
  }
}

Provider状态管理架构图:

graph TB
    A[数据变更] --> B[notifyListeners]
    B --> C[Provider监听到变化]
    C --> D[重建依赖的Widget]
    D --> E[UI更新]
    
    F[用户交互] --> G[调用Model方法]
    G --> A
    
    subgraph "Provider架构"
        H[ChangeNotifierProvider] --> I[数据提供]
        I --> J[Consumer消费]
        J --> K[UI构建]
    end

5. 自定义组件开发:构建专属设计系统

5.1 自定义组件设计方法论

开发自定义组件需要遵循系统化的设计流程。

组件开发生命周期:

需求分析 → API设计 → 组件实现 → 测试验证 → 文档编写 → 发布维护

5.2 实战案例:可交互评分组件开发

下面开发一个支持点击、滑动交互的动画评分组件。

// 动画评分组件
class InteractiveRatingBar extends StatefulWidget {
  final double initialRating;
  final int itemCount;
  final double itemSize;
  final Color filledColor;
  final Color unratedColor;
  final ValueChanged<double> onRatingChanged;

  const InteractiveRatingBar({
    Key? key,
    this.initialRating = 0.0,
    this.itemCount = 5,
    this.itemSize = 40.0,
    this.filledColor = Colors.amber,
    this.unratedColor = Colors.grey,
    required this.onRatingChanged,
  }) : super(key: key);

  @override
  _InteractiveRatingBarState createState() => _InteractiveRatingBarState();
}

class _InteractiveRatingBarState extends State<InteractiveRatingBar>
    with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation<double> _animation;
  double _currentRating = 0.0;
  bool _isInteracting = false;

  @override
  void initState() {
    super.initState();
    _currentRating = widget.initialRating;
    _animationController = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );
    _animation = Tween<double>(
      begin: widget.initialRating,
      end: widget.initialRating,
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeOut,
    ));
  }

  void _updateRating(double newRating) {
    setState(() {
      _currentRating = newRating;
    });
    _animateTo(newRating);
    widget.onRatingChanged(newRating);
  }

  void _animateTo(double targetRating) {
    _animation = Tween<double>(
      begin: _currentRating,
      end: targetRating,
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeOut,
    ));
    _animationController.forward(from: 0.0);
  }

  double _calculateRatingFromOffset(double dx) {
    final itemWidth = widget.itemSize;
    final totalWidth = widget.itemCount * itemWidth;
    final rating = (dx / totalWidth) * widget.itemCount;
    return rating.clamp(0.0, widget.itemCount.toDouble());
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return GestureDetector(
          onPanDown: (details) {
            _isInteracting = true;
            final rating = _calculateRatingFromOffset(details.localPosition.dx);
            _updateRating(rating);
          },
          onPanUpdate: (details) {
            final rating = _calculateRatingFromOffset(details.localPosition.dx);
            _updateRating(rating);
          },
          onPanEnd: (details) {
            _isInteracting = false;
          },
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: List.generate(widget.itemCount, (index) {
              return _buildRatingItem(index);
            }),
          ),
        );
      },
    );
  }

  Widget _buildRatingItem(int index) {
    final ratingValue = _animation.value;
    final isFilled = index < ratingValue;
    final fillAmount = (ratingValue - index).clamp(0.0, 1.0);

    return CustomPaint(
      size: Size(widget.itemSize, widget.itemSize),
      painter: _StarPainter(
        fill: fillAmount,
        filledColor: widget.filledColor,
        unratedColor: widget.unratedColor,
      ),
    );
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }
}

// 自定义星星绘制器
class _StarPainter extends CustomPainter {
  final double fill;
  final Color filledColor;
  final Color unratedColor;

  _StarPainter({
    required this.fill,
    required this.filledColor,
    required this.unratedColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = unratedColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2.0;

    final fillPaint = Paint()
      ..color = filledColor
      ..style = PaintingStyle.fill;

    // 绘制星星路径
    final path = _createStarPath(size);
    
    // 绘制未填充的轮廓
    canvas.drawPath(path, paint);
    
    // 绘制填充部分
    if (fill > 0) {
      canvas.save();
      final clipRect = Rect.fromLTWH(0, 0, size.width * fill, size.height);
      canvas.clipRect(clipRect);
      canvas.drawPath(path, fillPaint);
      canvas.restore();
    }
  }

  Path _createStarPath(Size size) {
    final path = Path();
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2;
    
    // 五角星绘制算法
    for (int i = 0; i < 5; i++) {
      final angle = i * 4 * pi / 5 - pi / 2;
      final point = center + Offset(cos(angle) * radius, sin(angle) * radius);
      if (i == 0) {
        path.moveTo(point.dx, point.dy);
      } else {
        path.lineTo(point.dx, point.dy);
      }
    }
    path.close();
    return path;
  }

  @override
  bool shouldRepaint(covariant _StarPainter oldDelegate) {
    return fill != oldDelegate.fill ||
        filledColor != oldDelegate.filledColor ||
        unratedColor != oldDelegate.unratedColor;
  }
}

自定义组件交互流程图:

sequenceDiagram
    participant U as 用户
    participant G as GestureDetector
    participant A as AnimationController
    participant C as CustomPainter
    participant CB as 回调函数

    U->>G: 手指按下/移动
    G->>G: 计算对应评分
    G->>A: 启动动画
    A->>C: 触发重绘
    C->>C: 根据fill值绘制
    G->>CB: 调用onRatingChanged
    CB->>U: 更新外部状态

5.3 组件性能优化策略

性能优化是自定义组件开发的非常重要的一环。

组件优化:

优化方法 适用场景 实现方式
const构造函数 静态组件 使用const创建widget
RepaintBoundary 复杂绘制 隔离重绘区域
ValueKey 列表优化 提供唯一标识
缓存策略 重复计算 缓存计算结果
// 优化后的组件示例
class OptimizedComponent extends StatelessWidget {
  const OptimizedComponent({
    Key? key,
    required this.data,
  }) : super(key: key);

  final ExpensiveData data;

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      child: Container(
        child: _buildExpensiveContent(),
      ),
    );
  }

  Widget _buildExpensiveContent() {
    // 复杂绘制逻辑
    return CustomPaint(
      painter: _ExpensivePainter(data),
    );
  }
}

class _ExpensivePainter extends CustomPainter {
  final ExpensiveData data;
  
  _ExpensivePainter(this.data);

  @override
  void paint(Canvas canvas, Size size) {
    // 复杂绘制操作
  }

  @override
  bool shouldRepaint(covariant _ExpensivePainter oldDelegate) {
    return data != oldDelegate.data;
  }
}

6. 综合实战:电商应用商品列表页面

下面构建一个完整的电商商品列表页面,综合运用各种UI组件。

// 商品数据模型
class Product {
  final String id;
  final String name;
  final String description;
  final double price;
  final double originalPrice;
  final String imageUrl;
  final double rating;
  final int reviewCount;
  final bool isFavorite;

  Product({
    required this.id,
    required this.name,
    required this.description,
    required this.price,
    required this.originalPrice,
    required this.imageUrl,
    required this.rating,
    required this.reviewCount,
    this.isFavorite = false,
  });

  Product copyWith({
    bool? isFavorite,
  }) {
    return Product(
      id: id,
      name: name,
      description: description,
      price: price,
      originalPrice: originalPrice,
      imageUrl: imageUrl,
      rating: rating,
      reviewCount: reviewCount,
      isFavorite: isFavorite ?? this.isFavorite,
    );
  }
}

// 商品列表页面
class ProductListPage extends StatefulWidget {
  @override
  _ProductListPageState createState() => _ProductListPageState();
}

class _ProductListPageState extends State<ProductListPage> {
  final List<Product> _products = [];
  final ScrollController _scrollController = ScrollController();
  bool _isLoading = false;
  int _currentPage = 1;
  final int _pageSize = 10;

  @override
  void initState() {
    super.initState();
    _loadProducts();
    _scrollController.addListener(_scrollListener);
  }

  void _scrollListener() {
    if (_scrollController.position.pixels ==
        _scrollController.position.maxScrollExtent) {
      _loadMoreProducts();
    }
  }

  Future<void> _loadProducts() async {
    setState(() {
      _isLoading = true;
    });
    
    // 网络请求
    await Future.delayed(Duration(seconds: 1));
    
    final newProducts = List.generate(_pageSize, (index) => Product(
      id: '${_currentPage}_$index',
      name: '商品 ${_currentPage * _pageSize + index + 1}',
      description: '商品的详细描述',
      price: 99.99 + index * 10,
      originalPrice: 199.99 + index * 10,
      imageUrl: 'https://picsum.photos/200/200?random=${_currentPage * _pageSize + index}',
      rating: 3.5 + (index % 5) * 0.5,
      reviewCount: 100 + index * 10,
    ));
    
    setState(() {
      _products.addAll(newProducts);
      _isLoading = false;
      _currentPage++;
    });
  }

  Future<void> _loadMoreProducts() async {
    if (_isLoading) return;
    await _loadProducts();
  }

  void _toggleFavorite(int index) {
    setState(() {
      _products[index] = _products[index].copyWith(
        isFavorite: !_products[index].isFavorite,
      );
    });
  }

  void _onProductTap(int index) {
    final product = _products[index];
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => ProductDetailPage(product: product),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('商品列表'),
        actions: [
          IconButton(
            icon: Icon(Icons.search),
            onPressed: () {},
          ),
          IconButton(
            icon: Icon(Icons.filter_list),
            onPressed: () {},
          ),
        ],
      ),
      body: Column(
        children: [
          // 筛选栏
          _buildFilterBar(),
          // 商品列表
          Expanded(
            child: RefreshIndicator(
              onRefresh: _refreshProducts,
              child: ListView.builder(
                controller: _scrollController,
                itemCount: _products.length + 1,
                itemBuilder: (context, index) {
                  if (index == _products.length) {
                    return _buildLoadingIndicator();
                  }
                  return _buildProductItem(index);
                },
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildFilterBar() {
    return Container(
      padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      decoration: BoxDecoration(
        border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
      ),
      child: Row(
        children: [
          _buildFilterChip('综合'),
          SizedBox(width: 8),
          _buildFilterChip('销量'),
          SizedBox(width: 8),
          _buildFilterChip('价格'),
          Spacer(),
          Text('${_products.length}件商品'),
        ],
      ),
    );
  }

  Widget _buildFilterChip(String label) {
    return FilterChip(
      label: Text(label),
      onSelected: (selected) {},
    );
  }

  Widget _buildProductItem(int index) {
    final product = _products[index];
    final discount = ((product.originalPrice - product.price) / 
                     product.originalPrice * 100).round();

    return Card(
      margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: InkWell(
        onTap: () => _onProductTap(index),
        child: Padding(
          padding: EdgeInsets.all(12),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // 商品图片
              Stack(
                children: [
                  ClipRRect(
                    borderRadius: BorderRadius.circular(8),
                    child: Image.network(
                      product.imageUrl,
                      width: 100,
                      height: 100,
                      fit: BoxFit.cover,
                      errorBuilder: (context, error, stackTrace) {
                        return Container(
                          width: 100,
                          height: 100,
                          color: Colors.grey[200],
                          child: Icon(Icons.error),
                        );
                      },
                    ),
                  ),
                  if (discount > 0)
                    Positioned(
                      top: 0,
                      left: 0,
                      child: Container(
                        padding: EdgeInsets.symmetric(horizontal: 4, vertical: 2),
                        decoration: BoxDecoration(
                          color: Colors.red,
                          borderRadius: BorderRadius.only(
                            topLeft: Radius.circular(8),
                            bottomRight: Radius.circular(8),
                          ),
                        ),
                        child: Text(
                          '$discount%',
                          style: TextStyle(
                            color: Colors.white,
                            fontSize: 12,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                    ),
                ],
              ),
              SizedBox(width: 12),
              // 商品信息
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      product.name,
                      style: TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                    ),
                    SizedBox(height: 4),
                    Text(
                      product.description,
                      style: TextStyle(
                        fontSize: 14,
                        color: Colors.grey[600],
                      ),
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                    ),
                    SizedBox(height: 8),
                    // 评分和评论
                    Row(
                      children: [
                        _buildRatingStars(product.rating),
                        SizedBox(width: 4),
                        Text(
                          product.rating.toStringAsFixed(1),
                          style: TextStyle(fontSize: 12),
                        ),
                        SizedBox(width: 4),
                        Text(
                          '(${product.reviewCount})',
                          style: TextStyle(
                            fontSize: 12,
                            color: Colors.grey[600],
                          ),
                        ),
                      ],
                    ),
                    SizedBox(height: 8),
                    // 价格信息
                    Row(
                      children: [
                        Text(
                          ${product.price.toStringAsFixed(2)}',
                          style: TextStyle(
                            fontSize: 18,
                            fontWeight: FontWeight.bold,
                            color: Colors.red,
                          ),
                        ),
                        SizedBox(width: 8),
                        if (product.originalPrice > product.price)
                          Text(
                            ${product.originalPrice.toStringAsFixed(2)}',
                            style: TextStyle(
                              fontSize: 14,
                              color: Colors.grey,
                              decoration: TextDecoration.lineThrough,
                            ),
                          ),
                      ],
                    ),
                  ],
                ),
              ),
              // 收藏按钮
              IconButton(
                icon: Icon(
                  product.isFavorite ? Icons.favorite : Icons.favorite_border,
                  color: product.isFavorite ? Colors.red : Colors.grey,
                ),
                onPressed: () => _toggleFavorite(index),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildRatingStars(double rating) {
    return Row(
      children: List.generate(5, (index) {
        final starRating = index + 1.0;
        return Icon(
          starRating <= rating
              ? Icons.star
              : starRating - 0.5 <= rating
                  ? Icons.star_half
                  : Icons.star_border,
          color: Colors.amber,
          size: 16,
        );
      }),
    );
  }

  Widget _buildLoadingIndicator() {
    return _isLoading
        ? Padding(
            padding: EdgeInsets.all(16),
            child: Center(
              child: CircularProgressIndicator(),
            ),
          )
        : SizedBox();
  }

  Future<void> _refreshProducts() async {
    _currentPage = 1;
    _products.clear();
    await _loadProducts();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }
}

电商列表页面架构图:

graph TB
    A[ProductListPage] --> B[AppBar]
    A --> C[Column]
    C --> D[FilterBar]
    C --> E[Expanded]
    E --> F[RefreshIndicator]
    F --> G[ListView.builder]
    
    G --> H[商品卡片]
    H --> I[商品图片]
    H --> J[商品信息]
    H --> K[收藏按钮]
    
    J --> L[商品标题]
    J --> M[商品描述]
    J --> N[评分组件]
    J --> O[价格显示]
    
    subgraph "状态管理"
        P[产品列表]
        Q[加载状态]
        R[分页控制]
    end

7. 组件性能监控与优化

7.1 性能分析工具使用

Flutter提供了丰富的性能分析工具来监控组件性能。

性能分析:

工具名称 主要功能 使用场景
Flutter DevTools 综合性能分析 开发阶段性能调试
Performance Overlay 实时性能覆盖层 UI性能监控
Timeline 帧时间线分析 渲染性能优化
Memory Profiler 内存使用分析 内存泄漏检测

7.2 性能优化技巧

// 示例
class OptimizedProductList extends StatelessWidget {
  final List<Product> products;
  final ValueChanged<int> onProductTap;
  final ValueChanged<int> onFavoriteToggle;

  const OptimizedProductList({
    Key? key,
    required this.products,
    required this.onProductTap,
    required this.onFavoriteToggle,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: products.length,
      // 为每个列表项提供唯一key
      itemBuilder: (context, index) {
        return ProductItem(
          key: ValueKey(products[index].id), // 优化列表diff
          product: products[index],
          onTap: () => onProductTap(index),
          onFavoriteToggle: () => onFavoriteToggle(index),
        );
      },
    );
  }
}

// 使用const优化的商品项组件
class ProductItem extends StatelessWidget {
  final Product product;
  final VoidCallback onTap;
  final VoidCallback onFavoriteToggle;

  const ProductItem({
    Key? key,
    required this.product,
    required this.onTap,
    required this.onFavoriteToggle,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const RepaintBoundary( // 隔离重绘区域
      child: ProductItemContent(
        product: product,
        onTap: onTap,
        onFavoriteToggle: onFavoriteToggle,
      ),
    );
  }
}

// 使用const构造函数的内容组件
class ProductItemContent extends StatelessWidget {
  const ProductItemContent({
    Key? key,
    required this.product,
    required this.onTap,
    required this.onFavoriteToggle,
  }) : super(key: key);

  final Product product;
  final VoidCallback onTap;
  final VoidCallback onFavoriteToggle;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: onTap,
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Row(
          children: [
            const CachedProductImage(imageUrl: product.imageUrl),
            const SizedBox(width: 12),
            const Expanded(
              child: ProductInfo(product: product),
            ),
            const FavoriteButton(
              isFavorite: product.isFavorite,
              onToggle: onFavoriteToggle,
            ),
          ],
        ),
      ),
    );
  }
}

8. 总结

8.1 核心知识点回顾

通过本篇文章,我们系统学习了Flutter UI组件库的各个方面:

Material Design组件体系:

  • 理解了Material Design的实现原理
  • 掌握了Scaffold、Container等核心布局组件
  • 学会了表单验证和复杂列表的实现

Cupertino风格组件:

  • 了解了iOS设计规范与实现
  • 掌握了平台自适应开发模式

第三方组件库:

  • 第三方库评估标准
  • 掌握了状态管理库的集成使用
  • 了解了流行UI扩展库的应用场景

自定义组件开发:

  • 学会了组件设计的方法论
  • 掌握了自定义绘制和动画实现
  • 理解了组件性能优化的手段

8.2 实际开发建议

组件选择策略:

  1. 优先使用官方组件,保证稳定性和性能
  2. 谨慎选择第三方库,选择前先评估
  3. 适时开发自定义组件

性能优化原则:

  1. 合理使用const构造函数减少重建
  2. 为列表项提供唯一Key优化diff算法
  3. 使用RepaintBoundary隔离重绘区域
  4. 避免在build方法中执行耗时操作

如果觉得这篇文章对你有帮助,请点赞、关注、收藏支持一下!!! 你的支持是我持续创作优质内容的最大动力! 有任何问题欢迎在评论区留言讨论,我会及时回复解答。

What Auto Layout Doesn’t Allow

作者 songgeb
2025年11月3日 15:38

日常使用Snapkit做约束时经常会遇到约束更新错误或者不允许的操作,本文记录一下Autolayout中哪些操作是不被允许的

前置知识

Size Attributes and Location Attributes

Autolayout中Attributes分为两类:Size Attributes 和 Location Attributes

  • Size Attributes:表示的是尺寸信息,有Width、Height
  • Location Attributes:表示位置信息,有Leading、Left、Trailing、Right、Top、Bottom、Cente(x、y)、Baseline等等

Autolayout的本质

Autolayout的本质是如下形式的等式或不等式:

Item1.Attribute1 [Relationship] Multiplier * Item2.Attribute2 + Constant

Relationship可以是等式,比如:

  • View1.leading = 1.0 * View2.leading + 0
  • View1.width = 1.0 * View2.width + 20

也可以是不等式,比如:

View1.leading >= 1.0 * View2.trailing + 0

需要注意的是式子两边不总是有Attribute,如下所示:

  • View.height = 0.0 * NotAnAttribute + 40.0(✅)
  • View1.height = 0.5 * View2.height(✅)

上述两个约束都是正确的

规则

1. Size Attribute与Location Attribute之间不能做约束

原文:You cannot constrain a size attribute to a location attribute.

错误举例:View1.width = 1.0 * View2.trailing + 0(❌)

2. 不能对Location Attribute设置常量

原文:You cannot assign constant values to location attributes.

错误举例:View1.leading = 0.0 * NotAnAttribute + 20(❌)

3. Location Attribute中不能使用非1的mutiplier

You cannot use a nonidentity multiplier (a value other than 1.0) with location attributes.

错误举例:View1.leading = 0.5 * View2.leading + 20(❌)

4. Location Attribute中不同方向之间不能做约束

For location attributes, you cannot constrain vertical attributes to horizontal attributes.

错误举例:View1.centerX = 1 * View2.centerY + 20(❌)

5. Leading(Trailing)不能与Left(Trailing)做约束

For location attributes, you cannot constrain Leading or Trailing attributes to Left or Right attributes.

错误举例:View1.centerX = 1 * View2.centerY + 20(❌)

修改Constraints需要注意什么

除了上述规则外,在运行时也可能会更新约束,有如下这些支持的修改约束操作:

  • 激活/停用约束(Activating or deactivating a constraint)
  • 修改约束的常量部分(Changing the constraint’s constant value)
  • 修改约束的优先级(Changing the constraint’s priority)
  • 将视图从页面层级中移除(Removing a view from the view hierarchy)

SwiftUI动画之使用 navigationTransition(.zoom) 实现 Hero 动画

作者 汉秋
2025年11月3日 14:20

使用navigationTransition(.zoom(sourceID:in:))在 SwiftUI 中实现 Hero 式放大过渡

SwiftUI iOS 17 带来了新的导航过渡系统。本文将带你学习如何使用 navigationTransition(.zoom(sourceID:in:)) 实现类似 Hero 动画的平滑放大效果。


✨ 简介

在 UIKit 时代,想要实现一个“列表 → 详情页”的放大过渡动画,往往需要复杂的自定义转场或者第三方库 Hero。而从 iOS 17 开始,SwiftUI 提供了新的导航过渡 API,使得这一切都能以极少的代码实现。

navigationTransition(.zoom(sourceID:in:)) 是 Apple 新增的 API,它允许我们在两个导航页面间创建共享元素(Shared Element)动画。源视图与目标视图使用相同的 sourceID,系统就能自动识别并生成缩放过渡。


🧩 实现思路

  1. 定义一个 @Namespace 来管理共享动画空间。

  2. 在源视图(比如卡片)与目标视图(详情页)上使用相同的 id。

  3. 通过 .navigationTransition(.zoom(sourceID:in:)) 声明使用 zoom 过渡。

系统会自动在两个页面间平滑地缩放、移动这两个匹配的视图,形成 Hero 式的过渡体验。


💻 完整示例代码

import SwiftUI

struct ZoomHeroExample: View {
    @Namespace private var ns
    @State private var selected: Item? = nil
    
    let items: [Item] = [
        Item(id: "1", color: .pink, title: "粉红"),
        Item(id: "2", color: .blue, title: "湛蓝"),
        Item(id: "3", color: .orange, title: "橙色")
    ]
    
    var body: some View {
        
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [.init(.adaptive(minimum: 120))], spacing: 12) {
                    ForEach(items) { item in
                        RoundedRectangle(cornerRadius: 12)
                            .fill(item.color)
                            .frame(height: 140)
                            .overlay(Text(item.title).foregroundColor(.white).bold())
                            .matchedTransitionSource(id: "card-" + item.id, in: ns)
                            .onTapGesture { selected = item }
                    }
                }
                .padding()
            }
            .navigationDestination(item: $selected) { item in
                // 添加 zoom 过渡
                DetailView(item: item, namespace: ns)
                    .navigationTransition(.zoom(sourceID: "card-" + item.id, in: ns))
            }
            .navigationTitle("颜色卡片")
        }
    }
}

struct DetailView: View {
    let item: Item
    let namespace: Namespace.ID
    
    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 24)
                .fill(item.color)
                .frame(height: 420)
                .padding()
            
            Text(item.title)
                .font(.largeTitle)
                .bold()
            
            Spacer()
        }
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct Item: Identifiable, Hashable {
    let id: String
    let color: Color
    let title: String
}


#Preview {
    ZoomHeroExample()
}

🎬 动画效果说明

当用户点击某个卡片:

  1. 源视图与目标视图通过相同的 sourceID 匹配。

  2. 系统自动计算两者在坐标空间中的差异。

  3. SwiftUI 执行一个 .zoom 类型的放大过渡动画,使卡片平滑地扩展到详情页。

这与 Hero 库的核心概念完全一致,但现在由 SwiftUI 原生支持。


⚙️ 常见问题

❓ 为什么动画没生效?

  • 确认源视图和目标视图的 id 一致。

  • 确保使用的是 NavigationStack 而不是旧的 NavigationView。

  • 确认系统版本 ≥ iOS 17。

⚠️ 视图闪烁或跳动?

  • 避免动态尺寸变化过大的布局。
  • 给匹配元素一个固定的 .frame() 可增强过渡的稳定性。

🔍 与matchedGeometryEffect的对比

特性 matchedGeometryEffect navigationTransition(.zoom)
匹配方式 Namespace + ID sourceID + Namespace
场景 任意动画、自定义匹配 导航过渡专用
代码复杂度 较高 极简
稳定性 手动控制 系统托管

简言之:在导航场景下优先使用 navigationTransition,其它复杂动画仍可用 matchedGeometryEffect。


💡 延伸思考

如果你想自定义更多转场样式,比如滑动、淡入淡出,可以尝试:

.navigationTransition(.slide(sourceID: "card-" + $0.id, in: ns))

或:

.navigationTransition(.zoom(sourceID: "card-" + $0.id, in: ns).combined(with: .opacity))

SwiftUI 让多个过渡组合成为可能,且依旧保持声明式风格。


🧭 总结

通过 navigationTransition(.zoom(sourceID:in:)),我们可以在 SwiftUI 中轻松实现 Hero 式放大动画。它不仅简化了过渡代码,还 seamlessly 与 NavigationStack 集成。

一句话总结:

从此以后,Hero 动画在 SwiftUI 中,不再需要 Hero。


参考链接:

昨天以前iOS

惊险但幸运,两次! - 肘子的 Swift 周报 #109

作者 Fatbobman
2025年11月3日 22:00

我的岳父岳母的身体一向很好,尤其是岳父,八十多岁了从未住过院。但在短短的时间里,他们先后经历了两场惊魂时刻——好在结果都十分幸运。

HarfBuzz 实战:五大核心API 实例详解【附iOS/Swift实战示例】

2025年11月1日 21:15

本文概述

本文是 HarfBuzz 系列的完结篇。

本文主要结合示例来讲解HarfBuzz中的核心API,不会面面俱到,只会介绍常用和重要的。

本文是HarfBuzz系列的第三篇,在阅读本文前,推荐先阅读以下两篇文章:

1)第一篇:HarfBuzz概览

2)第二篇:HarfBuzz核心概念

更多内容在公众号「非专业程序员Ping」,此外你可能还感兴趣:

一、hb-blob

1)定义

blob 是一个抽象概念,是对一段二进制数据的封装,一般用来承载字体数据,在HarfBuzz中用 hb_blob_t 结构体表示。

2)hb_blob_create

hb_blob_t 的构造方法,签名如下:表示从一段二进制数据(u8序列)中创建

hb_blob_t *
hb_blob_create (const char *data,
                unsigned int length,
                hb_memory_mode_t mode,
                void *user_data,
                hb_destroy_func_t destroy);
  • data:原始二进制数据,比如字体文件内容
  • length:二进制长度
  • mode:内存管理策略,即如何管理二进制数据,一般使用 HB_MEMORY_MODE_DUPLICATE 最安全,类型如下
模式 含义 优缺点
HB_MEMORY_MODE_DUPLICATE 复制模式,HarfBuzz会将传入的数据完整复制一份到私有内存 优点是不受传入的 data 生命周期影响缺点是多一次内存分配
HB_MEMORY_MODE_READONLY 只读模式,HarfBuzz会直接使用传入的数据,数据不会被修改 优点是无额外性能开销缺点是外部需要保证在 hb_blob_t 及其衍生的所有对象(如 hb_face_t)被销毁之前,始终保持有效且内容不变
HB_MEMORY_MODE_WRITABLE 可写模式,HarfBuzz会直接使用传入的指针,同时修改这块内存数据, 优点同READONLY缺点同READONLY,同时还可能修改数据
HB_MEMORY_MODE_READONLY_MAY_MAKE_WRITABLE 写时复制,HarfBuzz会直接使用传入的指针,在需要修改这块内存时才复制一份到私有内存 优点同READONLY缺点同READONLY,同时还可能修改数据
  • user_data:可以通过 user_data 携带一些上下文
  • destroy:blob释放时的回调

使用示例:

// 准备字体文件
let ctFont = UIFont.systemFont(ofSize: 18) as CTFont
let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as! URL
guard let fontData = try? Data(contentsOf: url) else {
    return
}
// 创建 HarfBuzz Blob 和 Face
// 'withUnsafeBytes' 确保指针在 'hb_blob_create' 调用期间是有效的。
// 'HB_MEMORY_MODE_DUPLICATE' 告诉 HarfBuzz 复制数据,这是在 Swift 中管理内存最安全的方式。
let blob = fontData.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> OpaquePointer? in
    let charPtr = ptr.baseAddress?.assumingMemoryBound(to: CChar.self)
    return hb_blob_create(charPtr, UInt32(fontData.count), HB_MEMORY_MODE_DUPLICATE, nil, nil)
}

3)hb_blob_create_from_file

hb_blob_t 的构造方法,签名如下:表示从文件路径创建

hb_blob_t *
hb_blob_create_from_file (const char *file_name);
  • file_name:文件绝对路径,注意非文件名

使用示例:

let ctFont = UIFont.systemFont(ofSize: 18) as CTFont
let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as! URL
let blob = url.path.withCString { ptr in
    hb_blob_create_from_file(ptr)
}

查看 hb_blob_create_from_file 函数实现,会通过 mmap 的方式来映射字体文件,可以共享系统的字体内存缓存,相比自己读取二进制数据来创建blob来说,这种方式会少一次IO,且内存占用也可能更小(复用系统内存缓存)。

二、hb-face

1)定义

face 表示一个单独的字体,它会解析blob中的二进制字体数据,通过face可以访问字体中的各种table,如GSUB、GPOS、cmap表等,在HarfBuzz中用 hb_face_t 结构体表示。

2)hb_face_create

hb_face_t的构造方法,签名如下:表示从一段字体二进制数据中构造face

hb_face_t *
hb_face_create (hb_blob_t *blob,
                unsigned int index);
  • blob:字体数据
  • index:有的字体文件是一个字体集合(ttc),index表示使用第几个字体数据来创建face;对于单字体文件(ttf)来说,index传0即可

关于字体更多知识可以参考:一文读懂字体文件

3)hb_face_reference

hb_face_t的引用计数 +1

hb_face_t *
hb_face_reference (hb_face_t *face);

3)hb_face_destroy

hb_face_t的引用计数 -1,注意不是直接销毁对象,在HarfBuzz中,所有对象类型都提供了特定的生命周期管理API(create、reference、destroy),对象采用引用计数方式管理生命周期,当引用计数为0时才会释放内存

void
hb_face_destroy (hb_face_t *face);

在实际使用时,需要注意调用顺序,需要保证所有从face创建出的对象销毁之后,再调用hb_face_destroy。

4)hb_face_get_upem

获取字体的upem。

unsigned int
hb_face_get_upem (const hb_face_t *face);

upem 即 unitsPerEm,在字体文件中一般存储在 head 表中,字体的upem通常很大(一般是1000或2048),其单位并不是像素值,而是 em unit,<unitsPerEm value="2048"/> 表示 2048 units = 1 em = 设计的字高,比如当字体在屏幕上以 16px 渲染时,1 em = 16px,其他数值可按比例换算。

5)hb_face_reference_table

从字体中获取原始的table数据,这个函数返回的是table数据的引用,而不是拷贝,所以这个函数几乎没有性能开销;如果对应 tag 的table不存在,会返回一个空的blob,可以通过 hb_blob_get_length 来检查获取是否成功。

hb_blob_t *
hb_face_reference_table (const hb_face_t *face,
                         hb_tag_t tag);

使用示例:

// 构造tag,这里是获取head表
let headTag = "head".withCString { ptr in
    hb_tag_from_string(ptr, -1)
}
let headBlob = hb_face_reference_table(face, headTag);
// 检查是否成功
if (hb_blob_get_length(headBlob) > 0) {
    // 获取原始数据指针并解析
    var length: UInt32 = 0
    let ptr = hb_blob_get_data(headBlob, &length);
    // ... 在这里执行自定义解析 ...
}
// 必须销毁返回的 blob!
hb_blob_destroy(headBlob);

6)hb_face_collect_unicodes

获取字体文件支持的所有Unicode,这个函数会遍历cmap表,收集cmap中定义的所有code point。

void
hb_face_collect_unicodes (hb_face_t *face,
                          hb_set_t *out);

可以用收集好的结果来判断字体文件是否支持某个字符,这在做字体回退时非常有用。

使用示例:

let set = hb_set_create()
hb_face_collect_unicodes(face, set)
var cp: UInt32 = 0
while hb_set_next(set, &cp) == 1 {
    print("code point: ", cp)
}
hb_set_destroy(set)

三、hb-font

1)定义

font 表示字体实例,可以在face的基础上,设置字号、缩放等feature来创建一个font,在HarfBuzz中用 hb_font_t 结构体表示。

2)hb_font_create & hb_font_reference & hb_font_destroy

hb_font_t 的创建、引用、销毁函数,整体同face对象一样,采用引用计数的方式管理生命周期。

3)hb_font_get_glyph_advance_for_direction

获取一个字形在指定方向上的默认前进量(advance)

void
hb_font_get_glyph_advance_for_direction
                               (hb_font_t *font,
                                hb_codepoint_t glyph,
                                hb_direction_t direction,
                                hb_position_t *x,
                                hb_position_t *y);
  • font:指定字体
  • glyph:目标字形
  • direction:指定方向,HB_DIRECTION_LTR/HB_DIRECTION_LTR/HB_DIRECTION_TTB/HB_DIRECTION_BTT
  • x:返回值,advance.x
  • y:返回值,advance.y

这个函数会从 hmtx(横向)或vmtx(纵向)表中读取advance。

一般情况下,我们不需要直接使用这个函数,这个函数是直接查表返回静态的默认前进量,但实际塑形时,一般还涉及kerning等调整,所以一般常用hb_shape()的返回值,hb_shape()返回的是包含字形上下文调整(如kerning)等的结果。

使用示例:

let glyph_A: hb_codepoint_t = 65
var x_adv: hb_position_t = 0
var y_adv: hb_position_t = 0
// 1. 获取 'A' 在水平方向上的前进位移
hb_font_get_glyph_advance_for_direction(font,
                                        glyph_A,
                                        HB_DIRECTION_LTR, // 水平方向
                                        &x_adv,
                                        &y_adv)

4)hb_font_set_ptem & hb_font_get_ptem

设置和获取字体大小(point size),ptem 即 points per Em,也就是 iOS 中的 point size

void
hb_font_set_ptem (hb_font_t *font,
                  float ptem);

这个函数是 hb_font_set_scale() 简易封装,在HarfBuzz内部,字体大小不是用 points 来存储的,而是用一个称为 scale 的 26.6 的整数格式来存储的。

使用示例:

// 设置字体大小为 18 pt
hb_font_set_ptem(myFont, 18.0f);

// 等价于
// 手动计算 scale
int32_t scale = (int32_t)(18.0f * 64); // scale = 1152
// 手动设置 scale
hb_font_set_scale(myFont, scale, scale);

Q:什么是 26.6 整数格式?

"26.6" 格式是一种定点数(Fixed-Point Number)表示法,用于将浮点数转换成整数存储和运算;在 HarfBuzz 中,这个格式用于 hb_position_t 类型(int32_t),用来表示所有的坐标和度量值(如字形位置、前进量等)。

26.6 表示将一个 32 位整数划分为:高26位用于存储整数部分(一个有符号的 25 位整数 + 1 个符号位)+ 低6位用于存储小数部分。

换算规则:2^6 = 64

  • 从「浮点数」转为「26.6 格式」:hb_position_t = (float_value * 64)
  • 从「26.6 格式」转回「浮点数」:float_value = hb_position_t / 64.0

那为什么不直接用整数呢,因为文本布局需要极高的精度,如果只用整数,那任何小于1的误差都会被忽略,在一行文本中累计下来,误差就很大了。

那为什么不直接用浮点数呢,因为整数比浮点数的运算快,且浮点数在不同平台上存储和计算产生的误差还确定。

因此为了兼顾性能和精确,将浮点数「放大」成整数参与计算。

5)hb_font_get_glyph

用于查询指定 unicode 在字体中的有效字形(glyph),这在做字体回退时非常有用。

hb_bool_t
hb_font_get_glyph (hb_font_t *font,
                   hb_codepoint_t unicode,
                   hb_codepoint_t variation_selector,
                   hb_codepoint_t *glyph);
  • 返回值 hb_bool_t:true 表示成功,glyph 被设置有效字形,false 表示失败,即字体不支持该 unicode
  • font:字体
  • unicode:待查询 unicode
  • variation_selector:变体选择符的code point,比如在 CJK 中日韩表意文字中,一个汉字可能有不同的字形(如下图),一个字体可能包含这些所有的变体,那我们可以通过 variation_selector 指定要查询哪个变体;如果只想获取默认字形,那该参数可传 0

在这里插入图片描述

  • glyph:返回值,用于存储 unicode 对应字形

当然,还有与之对应的批量查询的函数:hb_font_get_nominal_glyphs

四、hb-buffer

1)定义

buffer 在HarfBuzz中表示输入输出的缓冲区,用 hb_buffer_t 结构体表示,一般用于存储塑形函数的输入和塑形结束的输出。

2)hb_buffer_create & hb_buffer_reference & hb_buffer_destroy

hb_buffer_t 的创建、引用、销毁函数,整体同face对象一样,采用引用计数的方式管理生命周期。

3)hb_buffer_add_utf8 & hb_buffer_add_utf16 & hb_buffer_add_utf32

将字符串添加到buffer,使用哪个函数取决于字符串编码方式。

void
hb_buffer_add_utf8 (hb_buffer_t *buffer,
                    const char *text,
                    int text_length,
                    unsigned int item_offset,
                    int item_length);
  • buffer:目标buffer
  • text:文本
  • text_length:文本长度,传 -1 会自动查找到字符串末尾的 \0
  • item_offset:偏移量,0 表示从头开始
  • item_length:添加长度,-1 表示全部长度

使用示例:

let buffer = hb_buffer_create()
let text = "Hello World!"
let cText = text.cString(using: .utf8)!
hb_buffer_add_utf8(buffer, cText, -1, 0, -1)

4)hb_buffer_guess_segment_properties

猜测并设置buffer的塑形属性(script、language、direction等)。

void
hb_buffer_guess_segment_properties (hb_buffer_t *buffer);

这个函数一般取第一个字符的属性作为整体buffer的属性,所以如果要使用这个函数来猜测属性的话,需要保证字符串已经被提前分段。

当然也可以手动调用hb_buffer_set_script、hb_buffer_set_language 等来手动设置。

五、hb-shape

hb_shape是HarfBuzz的核心塑形函数,签名如下:

void
hb_shape (hb_font_t *font,
          hb_buffer_t *buffer,
          const hb_feature_t *features,
          unsigned int num_features);
  • font:用于塑形的字体实例,需要提前设置好字体大小等属性
  • buffer:既是输入,待塑形的字符串会通过buffer传入;也是输出,塑形完成后,塑形结果会通过buffer返回
  • features:feature数组,用于启用或禁用字体中的某些特性,不需要的话可以传nil
  • num_features:上一参数features数组的数量

hb_shape 会执行一系列复杂操作,比如:

  • 字符到字形映射:查询cmap表,将字符转换为字形
  • 字形替换:查询 GSUB 表,进行连字替换、上下文替换等
  • 字形定位:查询 GPOS 表,微调每个字形的位置,比如kerning,标记定位,草书连接等

详细的塑形操作可以参考HarfBuzz核心概念

下面重点介绍塑形结果,可以通过 hb_buffer_get_glyph_infos 和 hb_buffer_get_glyph_positions 从buffer中获取塑形结果。

hb_buffer_get_glyph_infos 签名如下:

// hb_buffer_get_glyph_infos
hb_glyph_info_t *
hb_buffer_get_glyph_infos (hb_buffer_t *buffer,
                           unsigned int *length);

typedef struct {
  hb_codepoint_t codepoint;
  uint32_t       cluster;
} hb_glyph_info_t;

hb_buffer_get_glyph_infos 返回一个 hb_glyph_info_t 数组,用于获取字形信息,hb_glyph_info_t 中有两个重要参数:

  • codepoint:glyphID,注意这里不是 unicode 码点
  • cluster:映射回原始字符串的字节索引

这里需要展开介绍下cluster:

  • 在连字 (多对一)情况下:比如 "f" 和 "i" (假设在索引 0 和 1) 被塑形为一个 "fi" 字形。这个 "fi" 字形的 cluster 值会是 0(即它所代表的第一个字符的索引)
  • 拆分 (一对多)情况下:在某些语言中,一个字符可能被拆分为两个字形,这两个字形都会有相同的 cluster 值,都指向那个原始字符
  • 高亮与光标:当我们需要高亮显示原始文本的第 3 到第 5 个字符时,就是通过 cluster 值来查找所有 cluster 在 3 和 5 之间的字形,然后绘制它们的选区

hb_buffer_get_glyph_positions 的签名如下:

hb_glyph_position_t *
hb_buffer_get_glyph_positions (hb_buffer_t *buffer,
                               unsigned int *length);
                               
typedef struct {
  hb_position_t  x_advance;
  hb_position_t  y_advance;
  hb_position_t  x_offset;
  hb_position_t  y_offset;
} hb_glyph_position_t;

hb_buffer_get_glyph_positions 返回一个 hb_glyph_position_t 的数组,用于获取字形的位置信息,hb_glyph_position_t 参数有:

  • x_advance / y_advance:x / y 方向的前进量;前进量指的是绘制完一个字形后,光标应该移动多远继续绘制下一个字形;对于横向排版而言,y_advance 一般是0;需要注意的是 advance 值中已经包含了 kernig 的计算结果
  • x_offset / y_offset:x / y 方向的绘制偏移,对于带重音符的字符如 é 来说,塑形时可能拆分成 e + ´,重音符 ´ 塑形结果往往会带 offset,以保证绘制在 e 的上方

position主要在排版/绘制时使用,以绘制为例,通常用法如下:

// (x, y) 是“笔尖”或“光标”位置
var current_x: Double = 0.0 
var current_y: Double = 0.0

// 获取塑形结果
var glyphCount: UInt32 = 0
let infos = hb_buffer_get_glyph_infos(buffer, &glyphCount)
let positions = hb_buffer_get_glyph_positions(buffer, &glyphCount)

// 遍历所有输出的字形
for i in 0..<Int(glyphCount) {
    let info = infos[i]
    let pos = positions[i]

    // 1. 计算这个字形的绘制位置 (Draw Position)
    //    = 当前光标位置 + 本字形的偏移
    let draw_x = current_x + (Double(pos.x_offset) / 64.0)
    let draw_y = current_y + (Double(pos.y_offset) / 64.0)

    // 2. 在该位置绘制字形
    //    (info.codepoint 就是字形 ID)
    drawGlyph(glyphID: info.codepoint, x: draw_x, y: draw_y)

    // 3. 将光标移动到下一个字形的起点
    //    = 当前光标位置 + 本字形的前进位移
    current_x += (Double(pos.x_advance) / 64.0)
    current_y += (Double(pos.y_advance) / 64.0)
}

六、完整示例

下面我们以 Swift 中调用 HarfBuzz 塑形一段文本为例:

func shapeTextExample() {
    // 1. 准备字体
    let ctFont = UIFont.systemFont(ofSize: 18) as CTFont
    let url = CTFontCopyAttribute(ctFont, kCTFontURLAttribute) as! URL

    // 2. 从字体文件路径创建blob
    let blob = url.path.withCString { ptr in
        hb_blob_create_from_file(ptr)
    }

    guard let face = hb_face_create(blob, 0) else { // 0 是字体索引 (TTC/OTF collections)
        print("无法创建 HarfBuzz face。")
        hb_blob_destroy(blob) // 即使失败也要清理
        return
    }

    // Blob 已经被 face 引用,现在可以安全销毁
    hb_blob_destroy(blob)

    // --- 3. 创建 HarfBuzz 字体对象 ---
    guard let font = hb_font_create(face) else {
        print("无法创建 HarfBuzz font。")
        hb_face_destroy(face)
        return
    }

    // 告诉 HarfBuzz 使用其内置的 OpenType 函数来获取字形等信息
    // 这对于 OpenType 字体(.otf, .ttf)是必需的
    hb_ot_font_set_funcs(font)

    hb_font_set_synthetic_slant(font, 1.0)

    // 设置字体大小 (例如 100pt)。
    // HarfBuzz 内部使用 26.6 整数坐标系,即 1 单位 = 1/64 像素。
    let points: Int32 = 100
    let scale = points * 64
    hb_font_set_scale(font, scale, scale)

    // --- 4. 创建 HarfBuzz 缓冲区 ---
    guard let buffer = hb_buffer_create() else {
        print("无法创建 HarfBuzz buffer。")
        hb_font_destroy(font)
        hb_face_destroy(face)
        return
    }

    // --- 5. 添加文本到缓冲区 ---
    let text = "Hello World!"
    let cText = text.cString(using: .utf8)!

    // hb_buffer_add_utf8:
    // - buffer: 缓冲区
    // - cText: UTF-8 字符串指针
    // - -1: 字符串长度 (传 -1 表示自动计算直到 null 终止符)
    // - 0: item_offset (从字符串开头)
    // - -1: item_length (处理整个字符串)
    hb_buffer_add_utf8(buffer, cText, -1, 0, -1)

    // 猜测文本属性 (语言、文字方向、脚本)
    // 这对于阿拉伯语 (RTL - 从右到左) 至关重要!
    hb_buffer_guess_segment_properties(buffer)

    // --- 6. 执行塑形 (Shape!) ---
    // 使用 nil 特征 (features),表示使用字体的默认 OpenType 特征
    hb_shape(font, buffer, nil, 0)

    // --- 7. 获取塑形结果 ---
    var glyphCount: UInt32 = 0
    // 获取字形信息 (glyph_info)
    let glyphInfoPtr = hb_buffer_get_glyph_infos(buffer, &glyphCount)
    // 获取字形位置 (glyph_position)
    let glyphPosPtr = hb_buffer_get_glyph_positions(buffer, &glyphCount)

    guard glyphCount > 0, let glyphInfo = glyphInfoPtr, let glyphPos = glyphPosPtr else {
        print("塑形失败或没有返回字形。")
        // 清理
        hb_buffer_destroy(buffer)
        hb_font_destroy(font)
        hb_face_destroy(face)
        return
    }

    print("\n--- 塑形结果 for '\(text)' (\(glyphCount) glyphs) ---")

    // --- 8. 遍历并打印结果 ---
    // 'cluster' 字段将字形映射回原始 UTF-8 字符串中的字节索引。
    // 这对于高亮显示、光标定位等非常重要。
    var currentX: Int32 = 0
    var currentY: Int32 = 0

    // 注意:阿拉伯语是从右到左 (RTL) 的。
    // hb_buffer_get_direction(buffer) 会返回 HB_DIRECTION_RTL。
    // HarfBuzz 会自动处理布局,所以我们只需按顺序迭代字形。

    for i in 0..<Int(glyphCount) {
        let info = glyphInfo[i]
        let pos = glyphPos[i]

        let glyphID = info.codepoint // 这是字形 ID (不是 Unicode 码点!)
        let cluster = info.cluster  // 映射回原始字符串的字节索引

        let x_adv = pos.x_advance   // X 轴前进
        let y_adv = pos.y_advance   // Y 轴前进
        let x_off = pos.x_offset    // X 轴偏移 (绘制位置)
        let y_off = pos.y_offset    // Y 轴偏移 (绘制位置)

        print("Glyph[\(i)]: ID=\(glyphID)")
        print("  Cluster (string index): \(cluster)")
        print("  Advance: (x=\(Double(x_adv) / 64.0), y=\(Double(y_adv) / 64.0)) pt") // 除以 64 转回 pt
        print("  Offset:  (x=\(Double(x_off) / 64.0), y=\(Double(y_off) / 64.0)) pt")
        print("  Cursor pos before draw: (x=\(Double((currentX + x_off)) / 64.0), y=\(Double((currentY + y_off)) / 64.0)) pt")

        // 累加光标位置
        currentX += x_adv
        currentY += y_adv
    }

    print("------------------------------------------")
    print("Total Advance: (x=\(currentX / 64), y=\(currentY / 64)) pt")

    // --- 9. 清理所有 HarfBuzz 对象 ---
    // 按照创建的相反顺序销毁
    hb_buffer_destroy(buffer)
    hb_font_destroy(font)
    hb_face_destroy(face)

    print("✅ 塑形和清理完成。")
}

输出结果如下: 在这里插入图片描述

​​探索 Xcode String Catalog:现代化 iOS 应用国际化指南​​

作者 VirusTarget
2025年11月1日 16:32

概述

随着应用程序面向全球用户,本地化支持已成为必不可少的基础能力。一款好的全球化应用应当能够在不同的语言和地区无缝运行,这不仅能让您的产品覆盖更广泛的受众,还能为用户提供更原生、更友好的体验,从而全面提升客户满意度。

包含多种语言的“你好”一词的横幅。

本地化(Localization)过程简单来说就是

  1. 提取应用中对应的面向用户的字符串文本或图片资源等内容
  2. 交给翻译人员,进行不同语言和地区的适配
  3. 最后将翻译好的内容重新导入应用

但如果你曾经在项目中维护过十几种语言的 .strings 文件,你一定知道那种传统模式下的「痛苦」:文件分散、条目重复、翻译遗漏、协作混乱等等等等……

有幸,苹果官方在 Xcode 15 推出的 String Catalog ,其目的就是为了解决传统模式下的这些痛点。

本文介绍的重点就是 Apple 推出的全新国际化管理机制——String Catalog

了解 String Catalog

String Catalog(字符串目录)是 Apple 在 Xcode 15 中引入的一种全新的、集中化的本地化资源管理方式,用于简化项目汇总翻译管理的过程实现轻松本地化。它旨在取代传统的 .strings和 .stringsdict文件,并且最大的好处在于 Xcode 中提供一个统一的可视化界面来管理所有本地化字符串(包括复数形式和设备特定变体)

核心概念

String Catalog 对应的文件后缀是 .xcstrings。其本质是一个 JSON 文件,使其在版本控制系统(如 Git)中进行差异比较时,比旧的 .strings 文件更友好
.xcstrings 文件中包含的内容有:

  • 所有语言的翻译:无需再为每种语言维护多个独立的 .strings或 .stringsdict文件
  • 多种字符串类型:支持常规字符串、复数变体 (如英语中 "1 apple" 和 "2 apples" 的区别)、以及设备特定变体 (如为 iPhone、iPad、Mac 提供不同的翻译)
  • 上下文注释:支持为每个字符串键(Key)添加注释,帮助翻译者理解上下文,从而提供更准确的翻译。

优势与特性

  • Xcode 为xcstrings文件提供的可视化界面
    • 翻译进度显示:以百分比的形式展示每种语言的翻译完成度,用于快速识别未完成或需要更新的语种
    • 智能筛选与排序:支持根据状态、key、翻译、注释等条件进行快速或组合筛选与排序,用于快速定位所需条目
    • 精细化内容管理:可以直接修改各语言对应的翻译内容,并支持为每个条目补充注释(Commnet),为翻译人员提供关键上下文,翻译人员能理解具体含义并确保翻译准确性
    • 状态控制:提供各语言各条目当前状态(STALE、NEW、NEEDS REVIEW、Translated)的查看,并且支持手动设置每个变量的状态(当前仅支持 Don’t Translate、NEEDS REVIEW、REVIEWED)
    • 工程双向联动:支持从可视化编辑界面跳转到变量对应代码位置,也支持从代码快捷跳转到可视化界面中,方便代码的查阅与修改
  • 智能自动管理
    • 编译时自动提取:在编译过程中( syncing localizations ) Xcode 会扫描代关键字,将对应类型的字符串自动提取到 String Catalog 中,无需手动维护条目(设置为 Manually 的条目除外)
    • 无缝语言扩展:为项目新增一种语言时,Xcode 会自动在 Catalog 中为该语言创建列(也支持在编辑界面中新增),并将其所有条目初始标记为 “需要翻译”
    • 变量的自动转换:在代码中使用 String(localized: "Welcome, \(userName)")等包含变量的字符串时,变量(如 (userName))对应的 C 类型的占位符(%@、%lld 等)会自动提取到 String Catalog 中并正确显示,翻译人员只需按目标语言的语序组织字符串即可。
  • 高效协作与集成
    • 集中式管理:将所有需要国际化的字符串都集中在一个可视化的文件中进行管理,告别了过往分散在多个 .strings.stringsdict 文件中的繁琐
    • 设备异化支持:支持按照设备(iPhoen\iPad\iPod\Mac\Apple Watch\Apple TV\Apple Vision)定义不同的翻译版本,保证在各种设备中均能提供合适的显示文本
    • 复数规则支持:内置对复数形式的处理支持,能够根据不同语言的复数规则(各国家规则不同,需要提前确认规则后再做处理,如英语的 "1 apple" 和 "2 apples")自动选择正确的字符串变体,无需开发者手动实现复杂逻辑
    • 标准化支持:无缝支持 XLIFF 标准格式,方便与专业的本地化服务或翻译工具进行导入导出
    • 快捷迁移:支持从旧的 .strings和 .stringsdict文件一键迁移至新的 String Catalog 格式

工作原理与编译机制

flowchart TD
    A[开发者编辑.xcstrings文件] --> B[Xcode编译项目]
    B --> C{编译时处理}
    
    subgraph C [编译时处理]
        C1[String Catalog处理器]
        C2[提取本地化字符串]
        C3[转换为传统格式]
    end
    
    C --> D[生成对应语言的<br>.strings和.stringsdict文件]
    D --> E[打包到App Bundle中]
    E --> F[用户运行应用]
    
    F --> G{运行时处理}
    
    subgraph G [运行时处理]
        G1[系统根据设备语言设置]
        G2[加载对应的.strings文件]
        G3[提供本地化文本]
    end
    
    G --> H[界面显示本地化内容]

具体来说:

  1. 字符串提取机制:在项目的编译过程中存在 syncing localizations 步骤,这个步骤的作用是扫描代码,自动提取以下内容

    • OC 中:使用指定的宏定义 NSLocalizedSrting,(自定义实际内容为 xxxBundle localizedStringForKey: value: table:
    • Interface Builder或storyboard中,文本默认进行提取,Document 部分有一个localizer Hint,通过这个进行注释,会存在在一个Main(Strings) 的 catalog 中
    • swiftUI,文本相关的默认都会进行提取,例如Text("Hello")
    • swiftLocalizedStringKey或 String.LocalizationValue类型的字符串,例如String(localized: "Welcome")
  2. 编译时处理: 编译时,会扫描工程中所有的 .xstrings 文件,并根据文件里面的内容生成对应语言的 .stringdict.strings 文件并引入到工程中(所以本质工程还是使用 .strings 实现的多语言)

image.png


创建与使用指南

由于本质是.strings 实现的多语言,所以实际使用的无最低部署目标要求,仅对 Xcode 存在要求
在新项目中创建 String Catalog

  1. 在 Xcode 项目中,通过 File > New > File...(或快捷键 Cmd+N)打开新建文件对话框
  2. 选择 Strings Catalog 模板
  3. 输入文件名(默认使用 Localizable),然后点击创建

迁移现有项目

如果已有的项目已经在使用传统的 .strings和 .stringsdict文件,那也可以机器轻松迁移到 Strings Catalog (毕竟还是那句话,String Catalog 的本质还是 .strings和 .stringsdict)

  1. 在 Xcode 项目导航器中, 右键单击 现有的 .strings或 .stringsdict文件
  2. 从上下文菜单中选择 Migrate to String Catalog
  3. Xcode 会自动开始迁移过程。为了完成此过程,您可能需要 构建项目 (Cmd+B),Xcode 会在构建过程中收集项目中的字符串资源到 .xcstrings文件中。

添加新语言支持

在 String Catalog 编辑器中:

  1. 点击编辑器窗口底部的 + 按钮
  2. 从下拉列表中选择您要添加的语言
  3. 添加后,Xcode 会自动为所有字符串创建条目,并标记为"需要翻译"(NEW)。

关于文件命名

  • 如果是应用内使用的文本需要国际化:则默认的命名为 Localizable.xcstrings,如需其他自定义名称,则需要在调用时候传递对应的table

  • 如果是需要实现 info.plist 文件的多语言,则固定命名为 InfoPlist.xcstrings

  • 如果是 .storyboard ,则可以直接右击显示 Migrate to String Catalog 即可自动生成

参考文献

探索字符串目录

苹果官方文档-localizing-and-varying-text-with-a-string-catalog

我的第一款 iOS App:PingMind

2025年11月1日 08:00

一直觉得有一套合适的反思系统很重要,相关的思考可以参见上一篇文章。于是开始寻找合适的解决方案,但都没有太满意的。既然这套系统具有重要性,而市面上又没有合适的解决方案,那就很有必要自己实现一个,于是就有了 PingMind 这个 app。

Gallery image 1
Gallery image 2
Gallery image 3
Gallery image 4
Gallery image 5

具体的 features 可以参见官网,简单来说就是可以创建每日/周/月的自省问题,并进行记录,设计上尽量保障输入和回顾的便捷性。创建的内容保存在本地,使用 iCloud 同步,支持导出,不需要创建账号,开箱即用。价格上采用 IAP(In App Purchase),可以免费使用大部分功能,购买后解锁全部功能,不需要订阅。

如果你对自省感兴趣,或者也在寻找合适的自省 App,或许可以试试 PingMind。使用过程中有任何的建议和反馈,欢迎留言,或邮件与我联系。

Combine 基本使用指南

作者 littleplayer
2025年10月31日 22:52

Combine 基本使用指南

Combine 是 Apple 在 2019 年推出的响应式编程框架,用于处理随时间变化的值流。下面是 Combine 的基本概念和使用方法。

核心概念

1. Publisher(发布者)

  • 产生值的源头
  • 可以发送 0 个或多个值
  • 可能以完成或错误结束

2. Subscriber(订阅者)

  • 接收来自 Publisher 的值
  • 可以控制数据流的需求

3. Operator(操作符)

  • 转换、过滤、组合来自 Publisher 的值

基本使用示例

创建简单的 Publisher

import Combine

// 1. Just - 发送单个值然后完成
let justPublisher = Just("Hello, World!")

// 2. Sequence - 发送序列中的值
let sequencePublisher = [1, 2, 3, 4, 5].publisher

// 3. Future - 异步操作的结果
func fetchData() -> Future<String, Error> {
    return Future { promise in
        // 模拟异步操作
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
            promise(.success("Data fetched"))
        }
    }
}

// 4. @Published 属性包装器
class DataModel {
    @Published var name: String = "Initial"
}

订阅 Publisher

// 使用 sink 订阅
var cancellables = Set<AnyCancellable>()

// 订阅 Just
justPublisher
    .sink { value in
        print("Received value: \(value)")
    }
    .store(in: &cancellables)

// 订阅 Sequence
sequencePublisher
    .sink(
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("Finished successfully")
            case .failure(let error):
                print("Failed with error: \(error)")
            }
        },
        receiveValue: { value in
            print("Received: \(value)")
        }
    )
    .store(in: &cancellables)

常用操作符

// 转换操作符
sequencePublisher
    .map { $0 * 2 }                    // 转换每个值
    .filter { $0 > 5 }                 // 过滤值
    .reduce(0, +)                      // 聚合值
    .sink { print("Result: \($0)") }
    .store(in: &cancellables)

// 组合操作符
let publisher1 = [1, 2, 3].publisher
let publisher2 = ["A", "B", "C"].publisher

Publishers.Zip(publisher1, publisher2)
    .sink { print("Zipped: \($0), \($1)") }
    .store(in: &cancellables)

// 错误处理
enum MyError: Error {
    case testError
}

Fail(outputType: String.self, failure: MyError.testError)
    .catch { error in
        Just("Recovered from error")
    }
    .sink { print($0) }
    .store(in: &cancellables)

处理 UI 更新

import UIKit
import Combine

class ViewController: UIViewController {
    @IBOutlet weak var label: UILabel!
    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var button: UIButton!
    
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupBindings()
    }
    
    private func setupBindings() {
        // 监听文本框变化
        NotificationCenter.default
            .publisher(for: UITextField.textDidChangeNotification, object: textField)
            .compactMap { ($0.object as? UITextField)?.text }
            .sink { [weak self] text in
                self?.label.text = "You typed: \(text)"
            }
            .store(in: &cancellables)
        
        // 按钮点击事件
        button.publisher(for: .touchUpInside)
            .sink { [weak self] _ in
                self?.handleButtonTap()
            }
            .store(in: &cancellables)
    }
    
    private func handleButtonTap() {
        print("Button tapped!")
    }
}

网络请求示例

struct User: Codable {
    let id: Int
    let name: String
    let email: String
}

class UserService {
    private var cancellables = Set<AnyCancellable>()
    
    func fetchUsers() -> AnyPublisher<[User], Error> {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/users") else {
            return Fail(error: URLError(.badURL))
                .eraseToAnyPublisher()
        }
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: [User].self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
    
    func loadUsers() {
        fetchUsers()
            .receive(on: DispatchQueue.main) // 切换到主线程
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    case .finished:
                        print("Request completed")
                    case .failure(let error):
                        print("Error: \(error)")
                    }
                },
                receiveValue: { users in
                    print("Received users: \(users)")
                }
            )
            .store(in: &cancellables)
    }
}

定时器示例

class TimerExample {
    private var cancellables = Set<AnyCancellable>()
    
    func startTimer() {
        Timer.publish(every: 1.0, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] date in
                print("Timer fired at: \(date)")
                self?.handleTimerTick()
            }
            .store(in: &cancellables)
    }
    
    private func handleTimerTick() {
        // 处理定时器触发
    }
}

内存管理

class MyViewController: UIViewController {
    private var cancellables = Set<AnyCancellable>()
    
    deinit {
        // 自动取消所有订阅
        cancellables.forEach { $0.cancel() }
    }
}

最佳实践

  1. 及时取消订阅:使用 store(in:) 管理订阅生命周期
  2. 线程切换:使用 receive(on:) 在合适的线程处理数据
  3. 错误处理:合理使用 catchreplaceError 等操作符
  4. 避免强引用循环:在闭包中使用 [weak self]

这些是 Combine 的基本使用方法。Combine 提供了强大的响应式编程能力,特别适合处理异步事件流和数据绑定。

❌
❌