普通视图

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

IOS SwiftUI 全组件详解

作者 如此风景
2025年12月15日 11:43

SwiftUI 全组件详解(iOS 端)

SwiftUI 是苹果推出的声明式 UI 框架,覆盖 iOS、macOS、watchOS 等多平台,下面是 iOS 端核心组件的分类详解,包含用法、核心属性和常见场景,基于最新 SwiftUI 5(iOS 18)适配。

一、基础视图组件

基础视图是构建 UI 的最小单元,用于展示文本、图片、图标等核心内容。

1. Text(文本)

作用:展示静态/动态文本,支持富文本、样式定制。 核心属性

  • font(_:):设置字体(Font.title/Font.system(size:16, weight:.bold));
  • foregroundStyle(_:):文本颜色(支持渐变);
  • multilineTextAlignment(_:):对齐方式(.leading/.center/.trailing);
  • lineLimit(_:):行数限制(nil 表示无限制);
  • truncationMode(_:):截断方式(.tail/.head/.middle)。

示例

Text("Hello SwiftUI")
    .font(.title)
    .foregroundStyle(LinearGradient(colors: [.red, .blue], startPoint: .leading, endPoint: .trailing))
    .padding()

2. Image(图片)

作用:展示本地/网络图片,支持缩放、裁剪、渲染模式。 核心属性

  • resizable(capInsets:resizingMode:):自适应尺寸(必加,否则图片按原始尺寸显示);
  • scaledToFit()/scaledToFill():适配方式(保持比例);
  • clipShape(_:):裁剪形状(Circle()/RoundedRectangle(cornerRadius:10));
  • renderingMode(_:):渲染模式(.original 保留原图,.template 作为模板色);
  • asyncImage(url:):加载网络图片(iOS 15+)。

示例

// 本地图片
Image("avatar")
    .resizable()
    .scaledToFit()
    .frame(width: 100, height: 100)
    .clipShape(Circle())

// 网络图片
AsyncImage(url: URL(string: "https://example.com/photo.jpg")) { phase in
    switch phase {
    case .empty: ProgressView() // 加载中
    case .success(let image): image.resizable().scaledToFit()
    case .failure: Image(systemName: "photo.badge.exclamationmark") // 加载失败
    @unknown default: EmptyView()
    }
}
.frame(width: 200, height: 200)

3. Image(systemName:)(SF Symbols 图标)

作用:使用苹果内置的 SF Symbols 图标库,支持自定义颜色/大小。 核心属性

  • font(_:):设置图标大小(Font.system(size:24));
  • foregroundStyle(_:):图标颜色;
  • symbolVariant(_:):图标变体(.fill/.circle/.square);
  • symbolRenderingMode(_:):渲染模式(.hierarchical/.palette)。

示例

Image(systemName: "heart.fill")
    .font(.system(size: 32))
    .foregroundStyle(.red)
    .symbolVariant(.circle)

4. Label(标签)

作用:组合图标+文本的复合视图(系统默认排版)。 核心属性

  • init(_ title: String, systemImage: String):快捷创建(文本+SF 图标);
  • labelStyle(_:):样式(.iconOnly/.titleOnly/.horizontal/.vertical);
  • titleSpacing(_:):图标与文本间距。

示例

Label("设置", systemImage: "gear")
    .labelStyle(.horizontal)
    .titleSpacing(8)
    .foregroundStyle(.gray)

二、容器视图组件

容器视图用于布局和管理多个子视图,是构建复杂 UI 的核心。

1. VStack(垂直栈)

作用:垂直排列子视图,默认居中对齐。 核心属性

  • init(alignment: HorizontalAlignment, spacing: CGFloat?, content:):对齐方式、子视图间距;
  • spacing:子视图间距(默认系统自适应);
  • alignment:水平对齐(.leading/.center/.trailing/.top 等)。

示例

VStack(alignment: .leading, spacing: 12) {
    Text("标题")
        .font(.headline)
    Text("副标题")
        .font(.subheadline)
        .foregroundStyle(.gray)
}
.padding()

2. HStack(水平栈)

作用:水平排列子视图,默认居中对齐。 核心属性

  • init(alignment: VerticalAlignment, spacing: CGFloat?, content:):垂直对齐、子视图间距;
  • alignment:垂直对齐(.top/.center/.bottom/.firstTextBaseline 等)。

示例

HStack(alignment: .center, spacing: 8) {
    Image(systemName: "bell")
    Text("消息通知")
    Spacer() // 推挤右侧视图到最右
    Image(systemName: "chevron.right")
        .foregroundStyle(.gray)
}
.padding(.horizontal)

3. ZStack(层级栈)

作用:重叠排列子视图,默认居中对齐。 核心属性

  • init(alignment: Alignment, content:):对齐方式(.topLeading/.center/.bottomTrailing 等);
  • zIndex(_:):子视图层级(数值越大越上层)。

示例

ZStack(alignment: .bottomTrailing) {
    Image("background")
        .resizable()
        .scaledToFill()
        .frame(width: 200, height: 200)
    Text("标签")
        .padding(4)
        .background(Color.red)
        .foregroundStyle(.white)
        .cornerRadius(4)
        .padding(8)
}
.clipShape(RoundedRectangle(cornerRadius: 10))

4. List(列表)

作用:滚动列表,支持单行/分组/可编辑,替代 UIKit 的 UITableView。 核心属性

  • listStyle(_:):列表样式(.plain/.grouped/.insetGrouped/.sidebar);
  • onDelete(perform:):删除操作(需配合 ForEach);
  • onMove(perform:):移动操作(需配合 EditButton);
  • selection(_:):选中项绑定(iOS 14+)。

示例

struct ListDemo: View {
    @State private var items = ["苹果", "香蕉", "橙子"]
    @State private var editing = false
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item)
                }
                .onDelete { indexSet in
                    items.remove(atOffsets: indexSet)
                }
                .onMove { source, destination in
                    items.move(fromOffsets: source, toOffset: destination)
                }
            }
            .listStyle(.insetGrouped)
            .navigationTitle("水果列表")
            .toolbar {
                EditButton() // 编辑按钮
            }
        }
    }
}

5. ScrollView(滚动视图)

作用:可滚动的容器,支持垂直/水平滚动,无默认单元格复用(区别于 List)。 核心属性

  • init(.vertical/.horizontal, showsIndicators: Bool, content:):滚动方向、是否显示滚动条;
  • scrollIndicators(_:):滚动条样式(.hidden/.automatic/.visible);
  • scrollDismissesKeyboard(_:):滚动时关闭键盘(.immediately/.interactively)。

示例

ScrollView(.vertical, showsIndicators: false) {
    VStack(spacing: 20) {
        ForEach(1...20, id: \.self) { i in
            Text("滚动项 \(i)")
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(8)
        }
    }
    .padding()
}

6. LazyVStack/LazyHStack(懒加载栈)

作用:类似 VStack/HStack,但仅渲染可视区域内的子视图,适合长列表(性能优化)。 核心属性

  • pinnedViews(_:):固定视图(.sectionHeaders/.sectionFooters);
  • 其他属性与普通栈一致。

示例

ScrollView {
    LazyVStack(spacing: 10, pinnedViews: .sectionHeaders) {
        Section(header: Text("头部固定").font(.title).background(Color.white)) {
            ForEach(1...100, id: \.self) { i in
                Text("懒加载项 \(i)")
                    .frame(height: 50)
            }
        }
    }
}

7. Grid(网格)

作用:二维网格布局(iOS 16+),替代手动嵌套 HStack/VStack。 核心组件

  • GridRow:网格行;
  • GridItem:网格列配置(.flexible()/.adaptive(minimum:)/.fixed());
  • alignment:单元格对齐方式。

示例

Grid(alignment: .center, horizontalSpacing: 8, verticalSpacing: 8) {
    GridRow {
        Text("1")
            .frame(maxWidth: .infinity)
            .background(Color.gray.opacity(0.1))
        Text("2")
            .frame(maxWidth: .infinity)
            .background(Color.gray.opacity(0.1))
    }
    GridRow {
        Text("3")
            .frame(maxWidth: .infinity)
            .background(Color.gray.opacity(0.1))
        Text("4")
            .frame(maxWidth: .infinity)
            .background(Color.gray.opacity(0.1))
    }
}
.padding()

8. Group(分组)

作用:分组子视图,无视觉效果,仅用于拆分代码或突破 10 个子视图限制。 示例

VStack {
    Group {
        Text("1")
        Text("2")
        Text("3")
    }
    Group {
        Text("4")
        Text("5")
    }
}

三、交互控件组件

用于响应用户操作(点击、输入、选择等)的交互型组件。

1. Button(按钮)

作用:响应点击操作,支持自定义样式。 核心属性

  • init(action: @escaping () -> Void, label: () -> Label):点击事件、按钮内容;
  • buttonStyle(_:):样式(.plain/.bordered/.borderedProminent/.borderless);
  • tint(_:):按钮主色调;
  • disabled(_:):是否禁用。

示例

Button(action: {
    print("按钮点击")
}) {
    HStack {
        Image(systemName: "paperplane")
        Text("提交")
    }
}
.buttonStyle(.borderedProminent)
.tint(.blue)
.disabled(false)
.padding()

2. Toggle(开关)

作用:布尔值切换控件(类似 UISwitch)。 核心属性

  • init(isOn: Binding<Bool>, label:):绑定布尔值、标签;
  • toggleStyle(_:):样式(.switch/.button/.checkbox);
  • tint(_:):开启状态颜色。

示例

struct ToggleDemo: View {
    @State private var isOn = false
    
    var body: some View {
        Toggle(isOn: $isOn) {
            Text("开启通知")
        }
        .toggleStyle(.switch)
        .tint(.green)
        .padding()
    }
}

3. TextField(单行输入框)

作用:单行文本输入,支持键盘类型、占位符等。 核心属性

  • init(_ prompt: String, text: Binding<String>):占位符、绑定文本;
  • keyboardType(_:):键盘类型(.numberPad/.emailAddress/.default);
  • autocapitalization(_:):自动大写(.none/.words);
  • disableAutocorrection(_:):禁用自动纠错;
  • onCommit(perform:):回车触发事件。

示例

struct TextFieldDemo: View {
    @State private var text = ""
    
    var body: some View {
        TextField("请输入用户名", text: $text)
            .keyboardType(.default)
            .autocapitalization(.none)
            .disableAutocorrection(true)
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(8)
            .padding(.horizontal)
    }
}

4. TextEditor(多行文本编辑器)

作用:多行文本输入(类似 UITextView)。 核心属性

  • init(text: Binding<String>):绑定文本;
  • foregroundStyle(_:):文本颜色;
  • font(_:):字体;
  • scrollContentBackground(_:):是否显示滚动背景(.hidden 可去除默认白色背景)。

示例

struct TextEditorDemo: View {
    @State private var text = ""
    
    var body: some View {
        TextEditor(text: $text)
            .font(.body)
            .foregroundStyle(.black)
            .scrollContentBackground(.hidden)
            .background(Color.gray.opacity(0.1))
            .frame(height: 150)
            .padding()
    }
}

5. Slider(滑块)

作用:数值选择滑块(范围值)。 核心属性

  • init(value: Binding<Double>, in range: ClosedRange<Double>, step: Double):绑定值、范围、步长;
  • tint(_:):滑块进度颜色;
  • onChange(of: value) { newValue in }:值变化监听。

示例

struct SliderDemo: View {
    @State private var progress = 0.0
    
    var body: some View {
        VStack {
            Slider(value: $progress, in: 0...100, step: 1)
                .tint(.orange)
                .padding()
            Text("进度:\(Int(progress))%")
        }
    }
}

6. Picker(选择器)

作用:下拉/滚轮选择器,支持多类型数据源。 核心属性

  • init(selection: Binding<Value>, label: Label, content:):选中值绑定、标签;
  • pickerStyle(_:):样式(.menu/.wheel/.segmented/.inline);
  • ForEach:数据源遍历。

示例

struct PickerDemo: View {
    @State private var selectedFruit = "苹果"
    let fruits = ["苹果", "香蕉", "橙子", "葡萄"]
    
    var body: some View {
        Picker(selection: $selectedFruit, label: Text("选择水果")) {
            ForEach(fruits, id: \.self) {
                Text($0)
            }
        }
        .pickerStyle(.menu) // 下拉菜单样式
        .padding()
    }
}

7. DatePicker(日期选择器)

作用:日期/时间选择器(类似 UIDatePicker)。 核心属性

  • init(_ label: String, selection: Binding<Date>, displayedComponents:):标签、绑定日期、显示组件(.date/.time/.dateAndTime);
  • datePickerStyle(_:):样式(.compact/.wheel/.graphical);
  • range(_:):可选日期范围。

示例

struct DatePickerDemo: View {
    @State private var selectedDate = Date()
    
    var body: some View {
        DatePicker("选择日期", selection: $selectedDate, displayedComponents: .date)
            .datePickerStyle(.compact)
            .padding()
    }
}

8. Stepper(步进器)

作用:增减数值的控件(+/- 按钮)。 核心属性

  • init(_ label: String, value: Binding<Value>, in range: ClosedRange<Value>, step: Value):标签、绑定值、范围、步长;
  • onIncrement(perform:):增加触发事件;
  • onDecrement(perform:):减少触发事件。

示例

struct StepperDemo: View {
    @State private var count = 0
    
    var body: some View {
        Stepper("数量:\(count)", value: $count, in: 0...10, step: 1)
            .padding()
    }
}

四、导航与页面组件

用于页面跳转、导航栏管理、模态弹窗等场景。

1. NavigationStack(导航栈,iOS 16+)

作用:替代旧版 NavigationView,实现页面层级导航。 核心属性

  • init(path: Binding<NavigationPath>, root:):导航路径绑定(支持任意可哈希类型);
  • navigationTitle(_:):导航栏标题;
  • navigationBarTitleDisplayMode(_:):标题显示模式(.large/.inline/.automatic);
  • toolbar(content:):导航栏工具栏(按钮、菜单);
  • navigationDestination(for:destination:):目标页面映射。

示例

struct NavigationDemo: View {
    @State private var path = NavigationPath()
    let fruits = ["苹果", "香蕉", "橙子"]
    
    var body: some View {
        NavigationStack(path: $path) {
            List(fruits, id: \.self) { fruit in
                NavigationLink(value: fruit) {
                    Text(fruit)
                }
            }
            .navigationTitle("水果列表")
            .navigationDestination(for: String.self) { fruit in
                Text("你选择了:\(fruit)")
                    .navigationTitle(fruit)
            }
            .toolbar {
                Button("返回首页") {
                    path.removeLast(path.count)
                }
            }
        }
    }
}

2. Sheet(模态弹窗)

作用:弹出模态视图(从底部滑入)。 核心属性

  • sheet(isPresented: Binding<Bool>, onDismiss: (() -> Void)?, content:):显示状态绑定、关闭回调、弹窗内容;
  • presentationDetents(_:):弹窗高度(.height(200)/.medium/.large,iOS 16+);
  • presentationDragIndicator(_:):拖拽指示器(.visible/.hidden,iOS 16+)。

示例

struct SheetDemo: View {
    @State private var showSheet = false
    
    var body: some View {
        Button("打开弹窗") {
            showSheet = true
        }
        .sheet(isPresented: $showSheet) {
            Text("模态弹窗内容")
                .presentationDetents([.medium, .large])
                .presentationDragIndicator(.visible)
        }
    }
}

3. Alert(警告弹窗)

作用:系统样式的警告弹窗(含标题、按钮)。 核心属性

  • alert(_: isPresented: actions: message:):标题、显示状态、按钮组、消息内容;
  • ButtonRole:按钮角色(.cancel/.destructive/.none)。

示例

struct AlertDemo: View {
    @State private var showAlert = false
    
    var body: some View {
        Button("显示警告") {
            showAlert = true
        }
        .alert("提示", isPresented: $showAlert) {
            Button("取消", role: .cancel) {}
            Button("确认", role: .none) {}
        } message: {
            Text("确定要执行此操作吗?")
        }
    }
}

4. Popover(弹出框,iOS 14+)

作用:从指定位置弹出的气泡视图。 核心属性

  • popover(isPresented: attachmentAnchor: arrowEdge: content:):显示状态、锚点、箭头方向、内容。

示例

struct PopoverDemo: View {
    @State private var showPopover = false
    
    var body: some View {
        Button("弹出菜单") {
            showPopover = true
        }
        .popover(isPresented: $showPopover, attachmentAnchor: .point(.topTrailing), arrowEdge: .top) {
            VStack {
                Text("选项1")
                Text("选项2")
                Text("选项3")
            }
            .padding()
            .frame(width: 150)
        }
    }
}

五、视觉装饰组件

用于增强 UI 视觉效果的装饰性组件。

1. Divider(分割线)

作用:水平分割线,用于分隔视图。 核心属性

  • foregroundStyle(_:):分割线颜色;
  • frame(height:):分割线高度。

示例

VStack {
    Text("上部分")
    Divider()
        .foregroundStyle(.gray.opacity(0.3))
        .frame(height: 1)
    Text("下部分")
}
.padding()

2. Spacer(空白填充)

作用:占据剩余空间,用于推挤子视图到指定位置。 核心属性

  • minLength(_:):最小长度(默认 0)。

示例

HStack {
    Text("左侧")
    Spacer(minLength: 20)
    Text("右侧")
}
.padding()

3. Color(颜色视图)

作用:纯色背景/装饰,可作为视图使用。 核心属性

  • opacity(_:):透明度;
  • gradient(_:):渐变(LinearGradient/RadialGradient/AngularGradient);
  • frame():尺寸(默认填充父视图)。

示例

Color.blue
    .opacity(0.5)
    .frame(height: 100)
    .cornerRadius(10)
    .padding()

4. RoundedRectangle/Circle/Capsule(形状视图)

作用:基础形状,用于背景、裁剪、装饰。 核心属性

  • fill(_:):填充颜色/渐变;
  • stroke(_:lineWidth:):描边;
  • cornerRadius(_:)(仅 RoundedRectangle):圆角。

示例

RoundedRectangle(cornerRadius: 12)
    .fill(LinearGradient(colors: [.purple, .pink], startPoint: .leading, endPoint: .trailing))
    .stroke(Color.white, lineWidth: 2)
    .frame(width: 200, height: 100)

六、高级组件(iOS 14+)

1. ProgressView(进度条)

作用:展示加载进度(确定/不确定)。 核心属性

  • init(value: Binding<Double>?, total: Double = 1.0):进度值、总进度;
  • progressViewStyle(_:):样式(.linear/.circular/.automatic);
  • tint(_:):进度颜色。

示例

struct ProgressViewDemo: View {
    @State private var progress = 0.5
    
    var body: some View {
        VStack {
            // 确定进度
            ProgressView(value: progress, total: 1.0)
                .progressViewStyle(.linear)
                .tint(.blue)
                .padding()
            // 不确定进度(加载中)
            ProgressView()
                .progressViewStyle(.circular)
                .tint(.orange)
        }
    }
}

2. Menu(菜单)

作用:下拉菜单(点击展开选项)。 核心属性

  • init(content: label:):菜单内容、触发标签;
  • menuStyle(_:):样式(.borderlessButton/.contextMenu);
  • Button(role:):菜单选项(支持取消/销毁角色)。

示例

Menu {
    Button("编辑", action: {})
    Button("删除", role: .destructive, action: {})
    Button("分享", action: {})
    Divider()
    Button("取消", role: .cancel, action: {})
} label: {
    Label("更多操作", systemImage: "ellipsis.circle")
}

3. TabView(标签页)

作用:底部/顶部标签页切换(类似 UITabBarController)。 核心属性

  • tabViewStyle(_:):样式(.page/.tabBar);
  • tabItem { Label(...) }:标签项内容;
  • tag(_:):标签标识(配合选中绑定);
  • selection(_:):选中标签绑定。

示例

struct TabViewDemo: View {
    @State private var selectedTab = 0
    
    var body: some View {
        TabView(selection: $selectedTab) {
            Text("首页")
                .tabItem {
                    Label("首页", systemImage: "house")
                }
                .tag(0)
            Text("消息")
                .tabItem {
                    Label("消息", systemImage: "bell")
                }
                .tag(1)
            Text("我的")
                .tabItem {
                    Label("我的", systemImage: "person")
                }
                .tag(2)
        }
        .tint(.blue)
    }
}

七、关键注意事项

  1. 版本适配:部分组件(如 NavigationStackGrid)仅支持 iOS 16+,低版本需用 NavigationView 等兼容方案;
  2. 性能优化:长列表优先用 List/LazyVStack,避免普通 VStack 导致的卡顿;
  3. 状态管理:所有交互组件需配合 @State/@Binding/@ObservedObject 等状态属性;
  4. 样式定制:可通过 ViewModifier 封装自定义样式,减少重复代码;
  5. 多平台兼容:部分组件(如 SidebarListStyle)仅在 iPad/macOS 生效,iOS 需适配。

以上覆盖了 SwiftUI 核心组件,实际开发中可结合苹果官方文档(SwiftUI 官方文档)和 SF Symbols 库扩展使用。

iOS开发之MetricKit监控App性能

作者 YungFan
2025年12月15日 10:05

介绍

iOS 13 之后,Apple 推出了 MetricKit — 一个由系统统一收集性能指标并按日自动送达给应用的强大框架。不需要侵入式埋点,不需要长期后台运行,也不需要手动分析复杂的系统行为,MetricKit 能够帮助开发者在真实用户设备上捕获 CPU、内存、启动耗时、异常诊断等关键性能指标。

特点

  • 自动收集:基于设备上的真实行为,系统会在后台定期收集性能数据。
  • 每天上报:每次应用启动,系统会把前一天的性能指标通过回调送达。
  • 极低侵入:性能统计由系统统一完成,不增加 CPU/内存负担,不影响用户体验。
  • 结构标准:系统提供结构化的 MXMetricPayload,便于解析与分析。
  • 隐私保护:Apple 会对数据进行匿名化和聚合处理,保护用户隐私。

步骤

  1. 导入 MetricKit。
  2. 注册 MetricKit 的订阅者。
  3. 实现回调协议,接受 Metric 数据。
  4. 逐项解析 Metric 数据。
  5. 上传 Metric 数据到服务器(可选)。

案例

以下是一份应用 MetricKit 的模版代码。

import MetricKit
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        MXMetricManager.shared.add(self)
        return true
    }

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    func applicationWillTerminate(_ application: UIApplication) {
        MXMetricManager.shared.remove(self)
    }
}

// MARK: - MXMetricManagerSubscriber,回调协议
extension AppDelegate: MXMetricManagerSubscriber {
    // MARK: 接收每日性能指标
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            handleMetrics(payload)
        }
    }

    // MARK: 接收诊断报告(崩溃、挂起等)
    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            handleDiagnostic(payload)
        }
    }

    // MARK: 处理每日性能指标
    func handleMetrics(_ payload: MXMetricPayload) {
        print("===== 开始处理性能指标 =====")
        // 时间范围
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.timeStyle = .short
        let timeRange = "\(formatter.string(from: payload.timeStampBegin)) - \(formatter.string(from: payload.timeStampEnd))"
        print("指标时间范围: \(timeRange)")

        // CPU指标
        if let cpu = payload.cpuMetrics {
            let cpuTime = cpu.cumulativeCPUTime.value
            print("CPU总使用时间: \(String(format: "%.2f", cpuTime)) 秒")
        }

        // GPU指标
        if let gpu = payload.gpuMetrics {
            let gpuTime = gpu.cumulativeGPUTime.value
            print("GPU总使用时间: \(String(format: "%.2f", gpuTime)) 秒")
        }

        // 内存指标
        if let memory = payload.memoryMetrics {
            let avgMemory = memory.averageSuspendedMemory.averageMeasurement.value
            let peakMemory = memory.peakMemoryUsage.value
            print("平均挂起内存: \(String(format: "%.2f", avgMemory / 1024 / 1024)) MB")
            print("峰值内存使用: \(String(format: "%.2f", peakMemory / 1024 / 1024)) MB")
        }

        // 启动时间指标
        if let launch = payload.applicationLaunchMetrics {
            let histogram = launch.histogrammedTimeToFirstDraw
            print("启动时间分布: ")
            for bucket in histogram.bucketEnumerator {
                if let bucket = bucket as? MXHistogramBucket<UnitDuration> {
                    let start = String(format: "%.2f", bucket.bucketStart.value)
                    let end = String(format: "%.2f", bucket.bucketEnd.value)
                    print("范围: \(start)-\(end)秒, 次数: \(bucket.bucketCount)")
                }
            }
        }

        // 上传数据
        let jsonData = payload.jsonRepresentation()
        uploadToServer(jsonData)
    }

    // MARK: 处理诊断报告
    func handleDiagnostic(_ payload: MXDiagnosticPayload) {
        print("===== 开始处理诊断报告 =====")

        // 崩溃诊断
        if let crashes = payload.crashDiagnostics {
            print("崩溃次数: \(crashes.count)")
            for (index, crash) in crashes.enumerated() {
                print("崩溃 \(index + 1): ")
                print("应用版本: \(crash.metaData.applicationBuildVersion)")
                print("设备类型: \(crash.metaData.deviceType)")
                print("系统版本: \(crash.metaData.osVersion)")
                print("平台架构: \(crash.metaData.platformArchitecture)")
                print("调用栈: \(crash.callStackTree)")
            }
        }

        // CPU异常
        if let cpuExceptions = payload.cpuExceptionDiagnostics {
            print("CPU异常次数: \(cpuExceptions.count)")
            for (index, exception) in cpuExceptions.enumerated() {
                print("CPU异常 \(index + 1): ")
                print("总CPU时间: \(exception.totalCPUTime.value) 秒")
                print("调用栈: \(exception.callStackTree)")
            }
        }

        // 上传数据
        let jsonData = payload.jsonRepresentation()
        uploadToServer(jsonData)
    }

    // MARK: 上传数据到服务器
    func uploadToServer(_ json: Data) {
        guard !json.isEmpty else { return }
        // 上传数据,如URLSession上传
    }
}

《Flutter全栈开发实战指南:从零到高级》- 24 -集成推送通知

2025年12月15日 10:04

引言

推送通知在移动开发中随处可见,比方说你关注的商品降价了,你的微信收到了新消息,你的外卖提示骑手已取餐,等等这些都离不开推送通知。推送通知不仅仅是“弹个窗”,它是移动应用与用户保持连接的生命线。

在Flutter生态中,推送通知的实现方案多种多样,但最主流、最成熟的方案当属Firebase Cloud Messaging。今天,我们就来深入探讨如何在Flutter应用中集成FCM,并实现本地通知、自定义通知栏以及消息路由。

一、推送通知的底层机制

1.1 FCM工作原理

核心原理:FCM不是简单的HTTP请求,本质是一个消息路由,设备与FCM服务器保持长连接,而不是每次推送都新建连接。

graph TD
    A[你的服务器] -->|HTTPS| B[FCM服务器]
    B -->|长连接| C[设备1]
    B -->|长连接| D[设备2]
    B -->|长连接| E[设备N...]
    
    C -->|心跳包| B
    D -->|心跳包| B
    E -->|心跳包| B
    
    F[APNs/GCM] -->|平台通道| B
    B -->|二进制协议| F

过程

  • 设备通过Google Play服务(Android)或APNs(iOS)与FCM建立连接
  • 连接建立后,设备定期发送心跳包维持连接
  • 你的服务器只需要把消息发给FCM,FCM负责路由到具体设备

核心代码实现

// 设备注册流程
class FCMRegistration {
  Future<String?> getToken() async {
    // Android:检查Google服务是否可用
    if (Platform.isAndroid) {
      await _checkGoogleServices();
    }
    
    // 请求权限
    final settings = await FirebaseMessaging.instance.requestPermission();
    if (settings.authorizationStatus != AuthorizationStatus.authorized) {
      return null;
    }
    
    // 获取Token
    return await FirebaseMessaging.instance.getToken();
  }
  
  // Token刷新监听
  void setupTokenRefresh() {
    FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
      // Token变化时更新到你的服务器
      _updateTokenOnServer(newToken);
    });
  }
}

注意

  1. Token在以下情况会变化:重装应用、清除应用数据等情况;
  2. 必须监听Token刷新,否则用户会收不到推送;
  3. iOS需要在真机上测试,模拟器不支持推送;

1.2 消息类型的本质区别

很多人分不清通知消息和数据消息,其实它们的区别在于处理者不同

维度 通知消息 (Notification) 数据消息 (Data) 混合消息 (Hybrid)
处理者 操作系统自动处理 应用程序自己处理 系统显示通知 + 应用处理数据
消息格式 包含 notification 字段 包含 data 字段 同时包含 notificationdata 字段
应用状态 任何状态都能收到
(前台/后台/终止)
必须在前台或后台处理时才能收到 系统部分任何状态都能收到
数据部分需要应用处理
推送示例 json<br>{<br> "notification": {<br> "title": "新消息",<br> "body": "您收到一条消息"<br> },<br> "to": "token"<br>} json<br>{<br> "data": {<br> "type": "chat",<br> "from": "user123"<br> },<br> "to": "token"<br>} json<br>{<br> "notification": {<br> "title": "新消息"<br> },<br> "data": {<br> "type": "chat"<br> },<br> "to": "token"<br>}
iOS处理 通过APNs直接显示 应用必须在前台或配置后台模式 系统显示通知,
点击后应用处理数据
Android处理 系统通知栏直接显示 应用需要在前台或
创建前台服务处理
系统显示通知,
点击后应用处理数据
payload大小 较小,只包含显示内容 最大4KB 通知部分+数据部分≤4KB
推荐场景 简单通知提醒
营销推送
系统公告
需要应用处理的业务逻辑
实时数据同步
静默更新
既需要显示通知
又需要处理业务逻辑

核心代码

// 处理不同类型的消息
void setupMessageHandlers() {
  // 1. 处理前台消息
  FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    if (message.notification != null) {
      // 通知消息
      _showLocalNotification(message);
    }
    
    if (message.data.isNotEmpty) {
      // 数据消息
      _processDataMessage(message.data);
    }
  });
  
  // 2. 处理后台消息
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
}

// 后台处理函数
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 注意:这里不能直接更新UI,只能处理数据或显示本地通知
  if (message.data.isNotEmpty) {
    await _processInBackground(message.data);
  }
}

二、本地通知

2.1 Android

为什么需要通知渠道?

  • 用户可以对不同类型的通知进行更精细地控制
  • 应用需要为通知分类,否则无法在Android 8.0+上显示

核心实现

// 创建通知渠道
Future<void> createNotificationChannels() async {
  // 唯一的渠道ID
  const AndroidNotificationChannel channel = AndroidNotificationChannel(
    'important_channel',  
    '重要通知',           
    description: '账户安全、订单状态等关键通知',
    importance: Importance.max,  
    
    // 配置
    playSound: true,
    sound: RawResourceAndroidNotificationSound('notification'),
    enableVibration: true,
    vibrationPattern: Int64List.fromList([0, 500, 200, 500]),
    showBadge: true,  // 角标
  );
  
  await FlutterLocalNotificationsPlugin()
      .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>()
      ?.createNotificationChannel(channel);
}

级别说明

  • Importance.max:发出声音并作为抬头通知显示
  • Importance.high:发出声音
  • Importance.default:没有声音
  • Importance.low:没有声音,且不会在状态栏显示

2.2 通知样式

大图通知

Future<void> showBigPictureNotification() async {
  // 先将图片下载到本地
  final String imagePath = await _downloadImageToCache(url);
  
  final bigPictureStyle = BigPictureStyleInformation(
    FilePathAndroidBitmap(imagePath),  
    
    // 延迟加载
    hideExpandedLargeIcon: false,
    contentTitle: '<b>标题</b>',
    htmlFormatContentTitle: true,
  );
  
  // 显示通知
  await notificationsPlugin.show(
    id,
    title,
    body,
    NotificationDetails(android: AndroidNotificationDetails(
      'channel_id',
      'channel_name',
      styleInformation: bigPictureStyle,  
    )),
  );
}

优化

  1. 图片缓存:下载的图片应该缓存,避免重复下载
  2. 图片压缩:大图需要压缩,建议不超过1MB
  3. 懒加载:大图在通知展开时才加载

2.3 实时更新

原理:通过不断更新同一个通知ID来实现进度显示。

class ProgressNotification {
  static const int notificationId = 1000;  // 固定ID
  
  Future<void> updateProgress(int progress, int total) async {
    final percent = (progress / total * 100).round();
    
    await notificationsPlugin.show(
      notificationId, 
      '下载中',
      '$percent%',
      NotificationDetails(
        android: AndroidNotificationDetails(
          'progress_channel',
          '进度通知',
          showProgress: true,
          maxProgress: total,
          progress: progress,
          onlyAlertOnce: true,  // 只提醒一次
        ),
      ),
    );
  }
}

注意

  1. 使用onlyAlertOnce: true避免每次更新都弹出通知
  2. 进度完成后应该取消或更新为完成状态的通知
  3. 考虑网络中断的恢复机制

三、消息路由

3.1 深度链接

路由设计

graph LR
    A[点击通知] --> B{判断链接类型}
    B -->|应用内链接| C[路由解析器]
    B -->|HTTP/HTTPS链接| D[WebView打开]
    B -->|其他应用链接| E[系统处理]
    
    C --> F{匹配路径}
    F -->|匹配成功| G[跳转对应页面]
    F -->|匹配失败| H[跳转首页]
    
    G --> I[传递参数]
    H --> J[显示错误]

核心代码

class DeepLinkRouter {
  // 配置路由表
  static final Map<RegExp, String Function(Match)> routes = {
    RegExp(r'^/product/(\d+)$'): (match) => '/product?id=${match[1]}',
    RegExp(r'^/order/(\d+)$'): (match) => '/order?id=${match[1]}',
    RegExp(r'^/chat/(\w+)$'): (match) => '/chat?userId=${match[1]}',
  };
  
  // 路由解析
  static RouteInfo? parse(String url) {
    final uri = Uri.parse(url);
    
    // 提取路径
    final path = uri.path;
    
    // 匹配路由
    for (final entry in routes.entries) {
      final match = entry.key.firstMatch(path);
      if (match != null) {
        final route = entry.value(match);
        final queryParams = Map<String, String>.from(uri.queryParameters);
        
        return RouteInfo(
          route: route,
          params: queryParams,
        );
      }
    }
    
    return null;
  }
}

注意

  1. URL Scheme需要在Info.plist(iOS)和AndroidManifest.xml(Android)中声明
  2. 冷启动处理:应用被终止时,需要保存启动参数
  3. 参数验证:需要对传入参数进行安全性检查

3.2 状态恢复的两种策略

策略1:URL参数传递

// 实现相对简单,但参数长度有限,不适合复杂状态
void navigateWithParams(String route, Map<String, dynamic> params) {
  final encodedParams = Uri.encodeComponent(json.encode(params));
  Navigator.pushNamed(context, '$route?data=$encodedParams');
}

策略2:状态管理+ID传递

// 适合复杂状态,但需要状态管理框架
class StateRecoveryManager {
  // 保存状态
  Future<String> saveState(Map<String, dynamic> state) async {
    final id = Uuid().v4();
    await _storage.write(key: 'state_$id', value: json.encode(state));
    return id;  
  }
  
  // 恢复状态
  Future<Map<String, dynamic>?> restoreState(String id) async {
    final data = await _storage.read(key: 'state_$id');
    if (data != null) {
      return json.decode(data) as Map<String, dynamic>;
    }
    return null;
  }
}

注意

  1. 简单参数用URL传递
  2. 复杂状态用ID传递,配合状态管理
  3. 状态应该有有效期,定期清理过期状态

四、性能优化

4.1 网络请求

批量发送Token更新:减少频繁的HTTP请求,特别是应用启动时可能多个组件都要同步Token。

class TokenSyncManager {
  final List<String> _pendingTokens = [];
  Timer? _syncTimer;
  
  // 延迟批量同步
  void scheduleTokenSync(String token) {
    _pendingTokens.add(token);
    
    // 延迟500ms,批量发送
    _syncTimer?.cancel();
    _syncTimer = Timer(const Duration(milliseconds: 500), () {
      _syncTokensToServer();
    });
  }
  
  Future<void> _syncTokensToServer() async {
    if (_pendingTokens.isEmpty) return;
    
    final uniqueTokens = _pendingTokens.toSet().toList();
    _pendingTokens.clear();
    
    try {
      await _api.batchUpdateTokens(uniqueTokens);
    } catch (e) {
      // 失败重试
      _pendingTokens.addAll(uniqueTokens);
    }
  }
}

4.2 内存优化

class LightweightNotification {
  final String id;
  final String title;
  final String? body;
  final DateTime timestamp;
  final bool read;
  final Map<String, dynamic>? data;  // 延迟加载
  
  // 工厂方法
  factory LightweightNotification.fromJson(Map<String, dynamic> json) {
    return LightweightNotification(
      id: json['id'],
      title: json['title'],
      body: json['body'],
      timestamp: DateTime.parse(json['timestamp']),
      read: json['read'] ?? false,
      data: json['data'],  
    );
  }
}

优化

  1. 列表显示时只加载必要字段
  2. 大字段(如图片、详细数据)延迟加载
  3. 定期清理内存中的通知缓存

4.3 电池优化

减少不必要的通知

class SmartNotificationScheduler {
  // 根据用户活跃时间调整通知频率
  Future<bool> shouldSendNotification(NotificationType type) async {
    final now = DateTime.now();
    
    // 1. 检查免打扰时间
    if (await _isQuietTime(now)) {
      return false;
    }
    
    // 2. 检查上一次活跃时间
    final lastActive = await _getLastActiveTime();
    if (now.difference(lastActive) > Duration(hours: 24)) {
      // 用户24小时未活跃,避免过多推送
      return type == NotificationType.important;
    }
    
    // 3. 检查同类型通知频率
    final recentCount = await _getRecentNotificationCount(type);
    if (recentCount > _getRateLimit(type)) {
      return false;
    }
    
    return true;
  }
}

五、调试

开发环境

class NotificationDebugger {
  static bool _isDebugMode = false;
  
  static void log(String message, {dynamic data}) {
    if (_isDebugMode) {
      print('[通知调试] $message');
      if (data != null) {
        print('数据: $data');
      }
    }
  }
  
  static Future<void> testAllScenarios() async {
    log('开始推送测试...');
    
    // 测试1: 前台通知
    await _testForeground();
    
    // 测试2: 后台通知
    await _testBackground();
    
    // 测试3: 数据消息
    await _testDataMessage();
    
    // 测试4: 点击处理
    await _testClickHandling();
    
    log('测试完成');
  }
  
  // 服务器推送
  static Future<void> simulatePush({
    required String type,
    required Map<String, dynamic> data,
  }) async {
    final message = RemoteMessage(
      data: data,
      notification: RemoteNotification(
        title: '测试通知',
        body: '这是一个测试通知',
      ),
    );
    
    // 直接触发消息处理器
    FirebaseMessaging.onMessage.add(message);
  }
}

六、平台特定优化

6.1 Android端

后台限制的应对方法

class AndroidOptimizer {
  // Android 10+的后台限制
  static Future<void> optimizeForBackgroundRestrictions() async {
    if (Platform.isAndroid) {
      // 1. 使用前台服务显示重要通知
      if (await _isAppInBackground()) {
        await _startForegroundServiceForImportantNotification();
      }
      
      // 2. 适配电源优化
      final status = await _checkBatteryOptimizationStatus();
      if (status == BatteryOptimizationStatus.optimized) {
        await _requestIgnoreBatteryOptimizations();
      }
    }
  }
  
  // 适配不同的Android版本
  static Future<void> adaptToAndroidVersion() async {
    final version = await DeviceInfoPlugin().androidInfo;
    final sdkVersion = version.version.sdkInt;
    
    if (sdkVersion >= 31) {  // Android 12+
      // 需要精确的闹钟权限
      await _requestExactAlarmPermission();
    }
    
    if (sdkVersion >= 33) {  // Android 13+
      // 需要新的通知权限
      await _requestPostNotificationsPermission();
    }
  }
}

6.2 iOS端

iOS推送的特殊处理

class IOSOptimizer {
  // APNs环境设置
  static Future<void> configureAPNsEnvironment() async {
    if (Platform.isIOS) {
      // 设置推送环境
      await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
        alert: true,   // 显示弹窗
        badge: true,   // 更新角标
        sound: true,   // 播放声音
      );
      
      // 获取APNs Token
      final apnsToken = await FirebaseMessaging.instance.getAPNSToken();
      if (apnsToken != null) {
        print('APNs Token: $apnsToken');
      }
    }
  }
  
  // 处理静默推送
  static Future<void> handleSilentPush(RemoteMessage message) async {
    if (Platform.isIOS && message.data['content-available'] == '1') {
      // 静默推送,需要后台处理
      await _processSilentNotification(message.data);
      final deadline = DateTime.now().add(Duration(seconds: 25));
      // ... 
    }
  }
}

七、总结

至此,Flutter消息推送知识就讲完了,记住以下核心原则:

  • 用户体验永远是第一位,让用户控制通知,提供清晰的设置
  • 消息必须可靠到达,状态必须正确恢复
  • 注意优化电池、流量、内存使用
  • 尊重Android和iOS两端的平台特性

不要简单地把推送通知当成一个功能,要保证每一次推送都应该有价值。


如果觉得文章对你有帮助,别忘了三连支持一下,欢迎评论区留言,我会详细解答! 保持技术热情,持续深度思考!

Swift 6.2 列传(第十二篇):杨不悔的“临终”不悔与 Isolated Deinit

2025年12月15日 09:43

在这里插入图片描述

摘要:当对象的生命走到尽头,是曝尸荒野还是落叶归根?Swift 6.2 引入的 isolated deinit 就像是一道“归元令”,让 Actor 隔离的类在销毁时也能体面地访问隔离状态。本文通过大熊猫侯佩与杨不悔的奇遇,为您揭秘 SE-0371 的奥义。

0️⃣ 🐼 序章:光明顶的内存泄漏

光明顶,Server 机房。

这里是中土明教的代码总坛,无数条线程如蜿蜒的巨龙般穿梭在服务器之间。

大熊猫侯佩正对着一块发烫的屏幕发呆。他摸了摸自己圆滚滚的肚皮,又习惯性地用爪子去探了探头顶——那里的黑毛依然茂密,绝对没有秃。他松了一口气,但这口气还没叹完,屏幕上那红色的 Compiler Error 就像明教的圣火令一样刺眼。

“奇怪,明明是在销毁对象,为什么就像是还没断气就诈尸了一样?”侯佩嘟囔着,嘴里还叼着半截没吃完的量子竹笋。

“因为你不仅是个路痴,还是个法盲。”

一个清脆的声音从机架后方传来。一位绿衣少女缓步走出,她眉目如画,眼神中却透着一股倔强与决绝。她是杨逍与纪晓芙之女,杨不悔

在这里插入图片描述

“不悔妹子!”侯佩眼睛一亮(主要是看到了不悔手里提着的食盒),“你怎么在这?听说你在维护‘倚天屠龙’分布式系统?”

在本次大冒险中,您将学到如下内容:

  • 0️⃣ 🐼 序章:光明顶的内存泄漏
  • 1️⃣ 💔 销魂时刻的尴尬:为何 Deinit 总是“身不由己”?
  • 2️⃣ 🛡️ 绝处逢生:Isolated Deinit 的“归元令”
  • 3️⃣ 🔌 实战演练:脆弱的 Session 与非 Sendable 的状态
  • 4️⃣ 🐼 熊猫的哲学思考与黑色幽默
  • 5️⃣ 🛑 尾声:突如其来的警报

杨不悔冷哼一声,将食盒放在服务器机柜上:“我娘给我取名‘不悔’,便是要我行事无愧于心。可现在的 Swift 代码,对象死的时候(deinit)乱七八糟,连最后一点体面都没有,还怎么谈‘不悔’?我正为此事烦恼。”

侯佩凑过去一看,原来是一个 Actor 隔离的类在 deinit 里试图访问属性时崩溃了。


1️⃣ 💔 销魂时刻的尴尬:为何 Deinit 总是“身不由己”?

在 Swift 的江湖里,对象的诞生(init)通常都有明确的归属,但对象的死亡(deinit)却往往充满了不确定性。

杨不悔指着屏幕上的代码说道:“你看,这是一个被 @MainActor 保护的类。按理说,它的属性都应该在主线程安全访问。但是,当它的引用计数归零时,也就是它该死的时候,系统并不保证 deinit 会在主线程执行。”

侯佩恍然大悟:“你是说,这就像一个人明明是中原人士,死的时候却可能莫名其妙被扔到了西域荒漠,连句遗言都传不回来?”

在这里插入图片描述

“话糙理不糙。”杨不悔叹了口气,“如果没有 Swift 6.2 的新特性,我们在 deinit 里根本无法安全地访问那些被 Actor 隔离的数据。这叫‘死不瞑目’。”

这就引出了 SE-0371:Isolated synchronous deinit

2️⃣ 🛡️ 绝处逢生:Isolated Deinit 的“归元令”

在这里插入图片描述

Swift 6.2 引入了一个关键能力:允许我们将 Actor 隔离类的析构函数(deinitializer)标记为 isolated

这意味着什么?这意味着当对象销毁时,系统会像拥有“乾坤大挪移”一般,确保代码跳转到该 Actor 的执行器(Executor)上运行。

杨不悔敲击键盘,写下了一段范例:

@MainActor
class DataController {
    func cleanUp() {
        // 这里的逻辑需要在大威天龙...哦不,是 MainActor 上执行
        print("正在清理门户...")
    }

    // 注意这个 isolated 关键字,这就是杨不悔的“不悔”令牌
    isolated deinit {
        cleanUp()
    }
}

侯佩瞪大了眼睛:“你是说,加上 isolated,这遗言就能准时传达了?”

在这里插入图片描述

“没错。”杨不悔解释道,“如果没有 isolated 关键字,析构器就不会隔离到 @MainActor。全局 Actor 的工作机制决定了这一点。但一旦加上它,你的代码在运行前就会自动切换到 Actor 的执行器。这才是真正的落叶归根,安全无痛。

3️⃣ 🔌 实战演练:脆弱的 Session 与非 Sendable 的状态

在这里插入图片描述

“光说不练假把式。”侯佩虽然爱吃,但对技术还是很较真的(尤其是涉及到能不能早点下班吃竹笋的问题),“有没有更具体的场景?比如...我也能听懂的?”

杨不悔微微一笑,想起了当年母亲纪晓芙的教诲,那种对誓言的执着。

“假设我们有一个 User 类,它就像武当派的张翠山,虽然正直,但并不是线程安全的(非 Sendable)。我们还有一个管理会话的 Session 类。”

// 一个普通的、非 Sendable 的用户类
// 就像是一个不懂武功的凡人,经不起多线程的撕扯
class User {
    var isLoggedIn = false
}

@MainActor
class Session {
    let user: User

    init(user: User) {
        self.user = user
        // 登录时,我们在 MainActor 上把状态改为 true
        user.isLoggedIn = true
    }

    // 重点来了:必须加上 isolated
    isolated deinit {
        // 销毁会话时,我们要把用户登出。
        // 如果没有 isolated,编译器会认为你在非隔离环境下访问了
        // 属于 MainActor 的 user 属性,直接给你报个错!
        user.isLoggedIn = false
    }
}

侯佩若有所思地点点头:“这下我明白了。Session 是在 @MainActor 上的,它持有的 user 状态也归它管。如果 deinit 随便在哪个后台线程跑,去修改 user.isLoggedIn 就会导致数据竞争,也就是走火入魔!”

在这里插入图片描述

“正是。”杨不悔目光坚定,“加上 isolated,就是告诉编译器:‘即使我要死了,我也要在我该在的地方,干干净净地把事情做完。’ 这便是我杨不悔的道。”

4️⃣ 🐼 熊猫的哲学思考与黑色幽默

侯佩看着屏幕上编译通过的绿色对勾,心中不禁生出一丝感慨。

“其实,写代码和做熊一样。”侯佩抓起一根竹笋,咔嚓咬了一口,“生(Init)的时候要风风光光,死(Deinit)的时候也要体体面面。以前我们为了在 deinit 里做点清理工作,还得用 Task 搞异步,结果对象都销毁了,任务还在那飘着,像孤魂野鬼。”

杨不悔白了他一眼:“你那是代码写得烂。现在的 isolated deinit同步的,它保证了逻辑的原子性和顺序性。”

“同步好啊!”侯佩拍着胸脯,“我就喜欢同步,像我吃饭,必须嘴巴动肚子就饱,要是嘴巴动了三天肚子才饱(异步),那我不饿瘦了?虽然我看起来很壮,但我这是虚胖,而且头绝对不秃,经不起折腾。”

在这里插入图片描述

杨不悔看着这只明明胖得像球、却还在担心秃顶的大熊猫,忍不住噗嗤一笑。这笑声如冰雪消融,让机房里冰冷的服务器都似乎有了温度。

5️⃣ 🛑 尾声:突如其来的警报

就在两人以为 Bug 已除,准备去吃一顿正宗的“明教火锅”时,机房深处突然传来了一阵急促的蜂鸣声!

在这里插入图片描述

🚨 WEE-WOO-WEE-WOO 🚨

“不好!”杨不悔脸色一变,“是‘乾坤大挪移’心法模块的后台任务卡住了!”

侯佩吓得竹笋都掉了:“怎么回事?不是已经 Isolated 了吗?”

杨不悔飞快地敲击着键盘,屏幕上的日志疯狂滚动:“不是线程安全问题!是有个非常重要的清理任务,被系统判定为‘低优先级’,一直被其他杂鱼任务插队,导致卡死在队列里出不来!”

在这里插入图片描述

“那怎么办?”侯佩急得团团转,“有没有办法给它打一针兴奋剂?或者给它一块免死金牌?让它插队先走?”

杨不悔眼神凝重,手指悬在回车键上:“这就需要用到一种禁术了……能够手动提升任务优先级的禁术。”

侯佩咽了口唾沫:“你是说……”

在这里插入图片描述

杨不悔转过头,看着侯佩,嘴角露出一丝神秘的微笑:“准备好了吗?下一章,我们要逆天改命。”

(欲知后事如何,且看下回分解:Task Priority Escalation APIs —— 当熊猫学会了插队)

在这里插入图片描述

深夜的代码惊魂:一个你绝对不能再犯的 Swift 错误

2025年12月15日 09:37

在这里插入图片描述

🕵️‍♂️ 引子

夜已深,写字楼的空调轰鸣声掩盖不住键盘的敲击声。阿强,一名拥有十年 Apple 开发经验的资深工程师,正端着一杯早已凉透的咖啡,站在实习生小美的工位后面。

小美对着屏幕上一堆乱七八糟的 Emoji 表情符号,眉头紧锁,仿佛在参悟什么上古天书。就在刚才,公司那个名为“老古董”的技术总监(因为酷爱 Objective-C 且痛恨 Swift 的安全检查而得名)提交了一段祖传代码的重构需求,而小美似乎正一步步掉进“老古董”留下的逻辑陷阱里。

在这里插入图片描述

“又在跟字符串较劲?”阿强推了推眼镜,镜片上反射出一行行诡异的代码。

“强哥,这不仅是见鬼,简直是玄学!”小美指着屏幕,“我明明只是想替换个国旗,结果这 App 像是搞起了地缘政治重组!”

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

  • 🕵️‍♂️ 引子
  • 🚫 那个让无数英雄折腰的“顺手”错误
  • 🇨🇦🇺🇸 看似无害的国旗游戏
  • 🤯 见证奇迹(灾难)的时刻
  • 🔍 揭秘:被“老古董”肢解的 Unicode
  • ✅ 救赎:Swift 的原生正义
  • 🎉 尾声:别让你的代码变成地缘政治灾难

阿强嘴角微微上扬,露出一种看透世态炎凉的冷笑。他知道,是时候给年轻人上一堂关于UnicodeAPI 选择的生动课程了。这场战役的对手,正是那个每个人都可能犯、却又极其致命的 Swift 错误。

在这里插入图片描述


🚫 那个让无数英雄折腰的“顺手”错误

TL;DR: 听哥一句劝,赶紧把 replacingOccurrences(of:with:) 扔进垃圾桶,拥抱 replacing(_:with:) 吧!

阿强清了清嗓子,开始了他的表演:“小美啊,这种错误我见得多了。它就像是恐怖片里的那个地下室,看起来平平无奇,只要你敢进去,就能引发一系列令人发指匪夷所思的 Bug。我在之前的技术分享会说过,其他大牛也说过,甚至有人为此专门录了视频,但大家就是不信邪。”

在这里插入图片描述

“哪怕你只是不想加班,也请记住这个简单的结论:如果你正在使用 StringreplacingOccurrences(of:with) 方法,请立刻、马上、毫不犹豫地把它换成 replacing(_:with:)。否则,你的代码可能会产生某种‘灵异现象’。”

如果你不想在演示 Demo 时当众出丑,那就耐着性子听阿强把这个恐怖故事讲完。

在这里插入图片描述


🇨🇦🇺🇸 看似无害的国旗游戏

“来,看这段代码。”阿强接管了键盘,敲下了几行看似人畜无害的代码:

// 这里有两个国旗:加拿大 🇨🇦 和 美国 🇺🇸
let vacation = "🇨🇦🇺🇸"

“这是一个包含两个国旗的简单字符串。作为一个正常的字符串,我们理所当然地可以检查它里面有什么。”

在这里插入图片描述

// 检查是否包含加拿大国旗
print(vacation.contains("🇨🇦"))
// 输出: true,没毛病

// 检查是否包含美国国旗
print(vacation.contains("🇺🇸"))
// 输出: true,也没毛病

“甚至,”阿强顿了顿,眼神变得锐利起来,“我们可以检查它是否包含澳大利亚国旗 🇦🇺。”

// 显然,加拿大和美国中间没有澳大利亚
print(vacation.contains("🇦🇺"))
// 输出: false,逻辑完美闭环

小美点了点头,“这不是很正常吗?"🇨🇦🇺🇸" 里当然没有 🇦🇺。”

在这里插入图片描述


🤯 见证奇迹(灾难)的时刻

“天真。”阿强冷笑一声,那是对“老古董”那套旧时代逻辑的嘲讽,“现在,让我们请出那个万恶之源——来自 Objective-C 时代的遗产 replacingOccurrences(of:with:)。”

“假设我们要把刚才那个不存在的澳大利亚国旗 🇦🇺,替换成尼加拉瓜国旗 🇳🇮。按理说,既然原字符串里没有澳大利亚,那应该什么都不会发生,对吧?”

在这里插入图片描述

代码运行了:

// 试图把不存在的 "🇦🇺" 替换成 "🇳🇮"
print(vacation.replacingOccurrences(of: "🇦🇺", with: "🇳🇮"))

小美瞪大了眼睛,屏幕上赫然打印出了:

“🇨🇳🇮🇸”

“这...这是中国国旗和冰岛国旗?”小美惊呼,“加拿大和美国去哪了?澳大利亚明明不在里面啊!为什么替换一个不存在的东西,会把原本好好的两个国家变成了中国和冰岛?这是什么国际玩笑?”

在这里插入图片描述

“这就是‘老古董’最喜欢的黑色幽默。”阿强叹了口气。

在这里插入图片描述


🔍 揭秘:被“老古董”肢解的 Unicode

“要理解这个 Bug,你得先理解 Swift 和 Objective-C 对待字符截然不同的态度。”阿强开始在白板上画图。

“问题的根源在于,replacingOccurrences(of:with:) 本质上是一个 Objective-C 方法(基于 NSString)。它并没有 Swift 这种自娘胎里带出来的、对 Unicode 安全性的极致追求。”

在这里插入图片描述

“当我们写下 🇨🇦🇺🇸 时,在底层,它并不是两个简单的图片,而是由四个 区域指示符号(Regional Indicator Symbols) 组成的序列:”

  1. C (Regional Indicator Symbol Letter C)
  2. A (Regional Indicator Symbol Letter A)
  3. U (Regional Indicator Symbol Letter U)
  4. S (Regional Indicator Symbol Letter S)

“看明白了吗?”阿强圈出了重点,“前两个 C + A 组成了加拿大的 ISO 代码,所以系统渲染成 🇨🇦。后两个 U + S 组成了美国的 ISO 代码,渲染成 🇺🇸。”

在这里插入图片描述

“但是!”阿强加重了语气,仿佛在揭露大反派的阴谋,“在这个字符串 CAUS 的中间,竟然藏着一个 AU!”

  • C
  • [ A
  • U ]
  • S

“虽然从 Swift 的高层语义(Grapheme Clusters,字素簇)来看,这两个旗帜是独立的。但在 Objective-C 那种不管三七二十一的字节处理逻辑眼里,它只看到了中间连在一起的 AU。”

在这里插入图片描述

“于是,当你调用 replacingOccurrences 时,它粗暴地把中间的 AU 挖走,换成了尼加拉瓜的代码 NI。现在的字符序列变成了这样:”

  1. C (原本的头)
  2. N (新来的)
  3. I (新来的)
  4. S (原本的尾)

“于是,C + N 变成了中国(CN,🇨🇳),I + S 变成了冰岛(IS,🇮🇸)。”

小美听得目瞪口呆:“这也太变态了吧?这简直就是代码界的‘人体蜈蚣’啊!”

在这里插入图片描述

“没错,”阿强点头,“这就是技术债。虽然从纯技术的角度看,Objective-C 并没有‘做错’(它确实找到了 A 和 U),但在业务逻辑和人类直觉上,这种行为就是令人发指的。这种因为未能正确处理 Unicode 字素簇而导致的 Bug,往往极其隐蔽,一旦上线,你的 App 可能会展示出一些甚至会引起外交纠纷的内容。”


✅ 救赎:Swift 的原生正义

“那怎么办?我们难道要自己写算法解析 Unicode 吗?”小美有些绝望。

在这里插入图片描述

“大可不必。”阿强删掉了那行罪恶的代码,换上了一行清爽的 Swift 原生调用,“只要你使用 Swift 3 之后引入的原生方法 replacing(_:with:),一切邪祟都会退散。”

// 使用 Swift 原生的 replacing 方法
print(vacation.replacing("🇦🇺", with: "🇳🇮"))

“运行它。”

在这里插入图片描述

小美按下回车。屏幕上稳稳地输出了:

“🇨🇦🇺🇸”

“看,”阿强露出了满意的微笑,“字符串毫发无损。因为 Swift 的 replacing 方法会尊重 Unicode 的字素簇边界。它知道 🇨🇦 是一个整体,🇺🇸 是另一个整体,中间并没有独立的 AU 供你替换。”

“而且,”阿强补充道,合上了电脑,“这代码写起来更短,运行起来通常也更快。这是一场全方位的胜利。”

在这里插入图片描述


🎉 尾声:别让你的代码变成地缘政治灾难

窗外的天已经蒙蒙亮了。小美看着屏幕上正确运行的代码,长舒了一口气。

“强哥,要是没有你,我刚才可能就引发第三次世界大战了。”

在这里插入图片描述

“没那么夸张,”阿强拍了拍小美的肩膀,转身向门口走去,“也就是把用户原本想去的‘美加七日游’变成‘中冰探险’而已。”

走到门口,阿强停下脚步,回头留下了最后一句至理名言:

“记住,在这个 Emoji 横行的时代,坚持使用 Swift 原生的 replacing,不仅是为了代码的优雅,更是为了维护世界的和平。”

在这里插入图片描述

说完,他消失在清晨的微光中,深藏功与名,只留下小美对着屏幕,默默地把项目中所有的 replacingOccurrences 全都搜索了出来……

在这里插入图片描述

Flutter 官方正式解决 WebView 在 iOS 26 上有点击问题

2025年12月15日 07:10

上个月和大家聊到了 《为什么你的 Flutter WebView 在 iOS 26 上有点击问题?》 ,源头是因为 WKWebView(WebKit)内部的手势识别器与 Flutter 在 Engine 里用于“阻止/延迟”手势的 recognizer 之间的冲突,因为 Flutter 和 UIKit 都各自有手势识别系统(GestureRecognizer),为了防止互相抢事件,Flutter engine 在 iOS 上加入了一个“delaying gesture recognizer”(延迟识别器),这也最终导致了 iOS 26 上的 bug :

在 Flutter 弹窗和 WKWebView 一起出来的时候,要么点不动,要么触摸会穿透到下面的 WebView 。

而在提供了之前部分场景有效的临时解决方案之后,Flutter 官方也提出了几个对应的可行性重构方案,具体可见 docs.google.com/document/d/… ,而现在方案三最终确定并 LGTM :

回顾整个问题里程,主要有两点:

  • 现有的 “gesture recognizer approach” (依赖自定义 UIGestureRecognizer + shouldRequireFailureOfGestureRecognizer)存在局限:无法阻止 UIView / JS 的底层 touch 回调(例如 WebView 的 touchstart),并且会和 WebView 的内部识别器冲突(这个导致了 iOS 的平台 bug),从而让一直以来的 hack 实现(如 remove/re-add recognizer)不生效:

因为 JS的 touchstarttouchesBegan 当帧同步触发,Flutter 没法在 touchesBegan 前屏蔽掉事件。

  • 以前为了解决 Google Maps 的 “dangling touchesBegan” 问题,引入了 WaitUntilTouchesEnded 策略,这是个权宜之计但并不理想,本质也是一种延迟机制:

因为当时 Flutter 没有能力在 touchesBegan 之前阻止触摸的到达,只能用 gesture recognizer 阻断,而这就导致了 Google Maps 在 touchesBegan 之后,后续 touchesEnded 会变成 recognizer fails 从而不会收到 touchesEnded,而这就是 WaitUntilTouchesEnded 诞生的背景,WaitUntilTouchesEnded 的目的就是避免 Google Maps 在中途被强制 fail,导致内部手势状态机 fails。

其实这一切的原因都是已经“异步协同”,所以现在修复开始改为“同步”,也就是 Flutter Engine + iOS embedder 新增了 “同步 hitTest 回调” 能力 :

  • iOS embedder 增加了可拦截 hitTest 的 UIView
  • Engine 与 Dart Framework 通过 FFI 实现“同步回调”

具体来说就是,首先是 Dart Framework 层,这里新增手势拦截策略 API (UiKitView),在 UiKitView 组件中新增了 gestureRecognizersBlockingPolicy 参数,让开发者可以为每个 PlatformView 单独配置手势拦截行为:

策略名称 拦截时机 使用技术 解决核心问题
touchBlockingOnly 最快 (HitTest 阶段) iOS hitTest 重写 修复 iOS 18+/26 WebView/AdMob 无法点击
eager 快 (手势竞争胜出时) 阻塞手势识别器 (旧默认值,现已不推荐)
waitUntilTouchesEnded 慢 (手指抬起后) 阻塞手势识别器 过去修复 Google Maps 状态卡死问题
fallbackToPluginDefault (取决于插件设定) (取决于插件设定) 保持旧插件兼容性

新机制让开发者可以在 Dart 代码中直接指定 PlatformView 的手势拦截策略,而不是依赖全局配置或原生代码,根据不同场景配置不同的拦截处理机制,而 touchBlockingOnly 就是全新的支持。

例如针对 AdMob 或 WebView 在 iOS 18+/26 上的点击穿透问题,现在开发者可以强制使用 touchBlockingOnly 策略,从而绕过有问题的 gesture recognizer 机制。

另外也提供了 fallbackToPluginDefault,确保不修改代码的情况下维持原有插件的行为。

接着就是在 Dart Framework 层实现了 Hit Test 逻辑,用于响应 Engine 发起的命中测试请求,判断点击位置是否落在 PlatformView 上:

当用户点击屏幕时,Flutter 通过 Render Tree 判断该位置最上层是否是 PlatformView,如果是被 Flutter 组件(如下拉菜单)遮挡,firstHit 就不会是 NativeHitTestTarget,从而拦截触摸。

然后就是 Engine / Bridge 层的通信,这部分主要负责将 iOS 原生的同步调用桥接到 Dart 环境,这里提供了一个同步的 Dart 入口点 _platformViewShouldAcceptTouch,从而让 iOS 原生的 hitTest 方法可以阻塞等待 Dart 的判断结果:

之后就是 iOS Engine 层重写了 hitTest 方法,这也是本次修复的核心,通过重写 PlatformView 的 hitTest 方法,在通过响应链传递触摸事件之前,先询问 Flutter Framework 是否应该拦截该事件:

- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event {
  if (_blockingPolicy == FlutterPlatformViewGestureRecognizersBlockingPolicyTouchBlockingOnly) { //
    // ... (获取 flutterViewController)
    
    CGPoint pointInFlutterView = [self convertPoint:point toView:self.flutterViewController.view];
    
    // 询问 Framework 是否应该接收此触摸
    if (![self.flutterViewController
            platformViewShouldAcceptTouchAtTouchBeganLocation:pointInFlutterView]) { //
      // 如果 Framework 说 "不" (例如点击了 Flutter 遮罩),返回 self (拦截触摸)
      return self;
    }
  }

  // 如果 Framework 说 "是",调用 super,让事件传递给 WKWebView
  return [super hitTest:point withEvent:event];
}

而对应的就是,如果策略是 TouchBlockingOnly,则不再添加容易导致冲突的 delayingRecognizer :

也就是,现在会先通过 hitTest 预先判断,避免了使用 UIGestureRecognizerDelegate 带来的复杂性和 iOS 18+ 上的 Bug,而当 platformViewShouldAcceptTouch 返回 NO 时,FlutterTouchInterceptingView 自身会吞掉事件,底层的 WebView 就不会收到错误的点击,从而修复了点击穿透或链接无法点击的问题。

最后

可以看到,本次调整数据较大的底层变动,所以牵动的模块也比较多,这也是为什么这个 PR 一直拖到现在才合并的原因,因为需要考虑和测试的因素很多:

而对于开发者来说,如果要引用修复,最好是通过增加对应的 gestureBlockingPolicy 参数来支持配置,只针对有问题的场景使用 touchBlockingOnly ,因为这怎么说也是一个底层大变更,会不会有新的问题还不好说。

参考链接

❌
❌