阅读视图

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

iOS 基于Vision.framework从图片中提取文字

基于Vision.framework从图片中提取文字 苹果在iOS 11中引入的Vision框架为OCR提供了基础能力,其核心组件VNRecognizeTextRequest可实现高效文字检测与识别。结合VisionKit中的DocumentCameraViewController,可快速构建扫描界面,支持自动裁剪、透视校正等预处理功能。

技术优势

  • 硬件加速:利用神经网络引擎(Neural Engine)实现低功耗、高帧率识别
  • 隐私保护:所有计算在设备端完成,无需上传至云端
  • 系统级优化:与iOS相机、相册系统深度集成
#import <Foundation/Foundation.h>
#import <Vision/Vision.h>

NS_ASSUME_NONNULL_BEGIN

API_AVAILABLE(ios(11.0))
typedef void(^SBVisionTextCallBack)(NSError *error, NSArray<__kindof VNObservation*>* results);


API_AVAILABLE(ios(11.0))

@interface SBVisionText : NSObject

@property (nonatomic,copy)SBVisionTextCallBack resultBlock;

+ (void)sb_vision_text_image:(UIImage *)img result:(SBVisionTextCallBack) resultBlock;

@end


#import "SBVisionText.h"

@implementation SBVisionText

+ (void)sb_vision_text_image:(UIImage *)img result:(SBVisionTextCallBack) resultBlock{

    if (@available(iOS 13.0, *)) {

        VNRecognizeTextRequest *textRequest = [[VNRecognizeTextRequest alloc] initWithCompletionHandler:^(VNRequest * _Nonnull request, NSError * _Nullable error){

            NSArray *observations = request.results;

            //        [self textRectangles:observations image:image complete:complete];

            NSLog(@"sb_vision_text_image:%@",observations);

            if (resultBlock) {
                resultBlock(error,request.results);
            }
        }];
        
        textRequest.recognitionLevel = VNRequestTextRecognitionLevelAccurate;
        textRequest.usesLanguageCorrection = NO;
        textRequest.recognitionLanguages = @[@"zh-Hans", @"en-US"];

        // 转换CIImage
        CIImage *convertImage = [[CIImage alloc]initWithImage:img];

        // 创建处理requestHandler

        VNImageRequestHandler *detectRequestHandler = [[VNImageRequestHandler alloc]initWithCIImage:convertImage options:@{}];

        // 发送识别请求
        [detectRequestHandler performRequests:@[textRequest] error:nil];

    } else {
        // Fallback on earlier versions
        NSLog(@"Fallback on earlier versions");
    }
}

@end

方法调用

#import "SBVisionTextViewController.h"
#import "SBVisionText.h"


@implementation SBVisionTextViewController
- (void)viewDidLoad {
    [super viewDidLoad];
}

- (IBAction)getText:(UIButton *)sender {
    [self getTextFormImage:[UIImage imageNamed:@"1681888102373.jpg"]];
}

-(void)getTextFormImage:(UIImage *)img{
    if (@available(iOS 11.0, *)) {
        [SBVisionText sb_vision_text_image:img result:^(NSError * _Nonnull error, NSArray<__kindof VNObservation *> * _Nonnull results) {

            if (@available(iOS 13.0, *)) {
                for (VNRecognizedTextObservation *observation in results) {
                    NSLog(@"%@", [observation topCandidates:1].firstObject.string);
                }
            } else {
                NSLog(@"Fallback on earlier versions");
            }
        }];

    } else {
        NSLog(@"Fallback on earlier versions");
    }
    return;
}

@end

SwiftUI 键盘快捷键作用域深度解析

原文:xuanhu.info/projects/it…

SwiftUI 键盘快捷键作用域深度解析

SwiftUI 的 keyboardShortcut 修饰符让为应用添加快捷键变得简单直观。然而,这些快捷键的作用域(Scope)生命周期可能会带来一些意想不到的行为,例如即使关联的视图不在屏幕可见区域,其快捷键仍可能被激活。本文将深入探讨 SwiftUI 键盘快捷键的作用域机制,并提供一系列解决方案和最佳实践。

1. SwiftUI 键盘快捷键基础

在 SwiftUI 中,你可以使用 .keyboardShortcut 修饰符为任何可交互的视图(如 Button)附加键盘快捷键。

1.1 基本用法

以下代码为一个按钮添加了快捷键 Command + Shift + P

import SwiftUI

struct ContentView: View {
    var body: some View {
        Button("打印信息") {
            print("Hello World!")
        }
        .keyboardShortcut("p", modifiers: [.command, .shift]) // 
    }
}

1.2 关键概念解析

  • KeyEquivalent:表示快捷键的主键,可以是单个字符(如 "p")或特殊键(如 .return, .escape, .downArrow)。它遵循 ExpressibleByExtendedGraphemeClusterLiteral 协议,允许我们用字符串字面量创建实例。
  • EventModifiers:表示修饰键(如 .command, .shift, .control, .option),它是一个遵循 OptionSet 协议的结构体,允许组合多个修饰键。
  • 默认修饰符:如果省略 modifiers 参数,SwiftUI 默认使用 .command 修饰符。
  • 关联操作:触发快捷键等效于直接与视图交互(例如,点击按钮)。

1.3 应用于不同视图

keyboardShortcut 修饰符可以应用于任何视图,不仅是 Button。例如,可以将其应用于 Toggle

struct ContentView: View {
    @State private var isEnabled = false
    
    var body: some View {
        Toggle(isOn: $isEnabled) {
            Text(String(isEnabled))
        }
        .keyboardShortcut("t") // 按下快捷键将切换 Toggle 的状态 
    }
}

它也可以应用于容器视图(如 VStack, HStack)。在这种情况下,快捷键会作用于该容器层次结构中第一个可交互的子视图

struct ContentView: View {
    var body: some View {
        VStack {
            Button("打印信息") {
                print("Hello World!")
            }
            Button("删除信息") {
                print("信息已删除。")
            }
        }
        .keyboardShortcut("p") // 此快捷键将激活第一个按钮(打印信息) 
    }
}

2. 快捷键的作用域与生命周期

理解快捷键的作用域(Scope)生命周期(Lifetime) 是有效管理它们的关键。

2.1 作用域机制

SwiftUI 的键盘快捷键在视图层次结构中进行管理。其解析过程遵循深度优先、从前向后的遍历规则。当多个控件关联到同一快捷键时,系统会使用最先找到的那个

2.2 生命周期与“离屏”激活

一个非常重要的特性是:只要附加了快捷键的视图仍然存在于视图层次结构中(即使该视图当前不在屏幕可见范围内,例如在 TabView 的非活动标签页、NavigationStack 的深层页面,或者简单的 if 条件渲染但视图未销毁),其快捷键就保持有效并可激活

这种行为可能导致非预期的操作:

  • 用户意图在当前活跃的上下文中使用一个快捷键,却意外触发了另一个在背景中不可见视图的操作。
  • 在标签页 A 中定义的快捷键,在标签页 B 中仍然可以触发。

2.3 示例:标签页中的潜在问题

struct ContentView: View {
    @State private var selection = 1
    
    var body: some View {
        TabView(selection: $selection) {
            Tab("标签 1", systemImage: "1.circle") {
                Button("标签1的按钮") {
                    print("标签1动作")
                }
                .keyboardShortcut("a") // ⌘A 在标签1
            }
            .tag(1)
            
            Tab("标签 2", systemImage: "2.circle") {
                Button("标签2的按钮") {
                    print("标签2动作")
                }
                .keyboardShortcut("b") // ⌘B 在标签2
            }
            .tag(2)
        }
    }
}

在此例中,即使你在标签页 2(⌘B 活跃),按下 ⌘A 仍然会触发标签页 1 中的按钮动作,因为标签页 1 的视图仍然在视图层次结构中(只是未被显示)。

3. 管理快捷键作用域的解决方案

为了解决快捷键意外激活的问题,我们需要有意识地控制其作用域。以下是几种有效的方法。

3.1 条件修饰符(动态禁用视图)

最直接的方法是通过条件语句(如 if.disabled)控制视图的存在与否或可交互性,从而间接控制快捷键。

使用 if 条件语句

通过 @State 驱动视图的条件渲染,当视图被移除时,其快捷键自然失效。

struct ContentView: View {
    @State private var isFeatureEnabled = false
    
    var body: some View {
        VStack {
            Toggle("启用功能", isOn: $isFeatureEnabled)
            
            if isFeatureEnabled {
                Button("执行功能") {
                    // 执行操作
                }
                .keyboardShortcut("e") // 仅在 isFeatureEnabled 为 true 时存在且有效
            }
        }
    }
}
使用 .disabled 修饰符

.disabled 修饰符会禁用视图的交互能力,同时也会使其关联的快捷键失效

struct ContentView: View {
    @State private var isButtonDisabled = true
    
    var body: some View {
        Button("点击我") {
            // 执行操作
        }
        .keyboardShortcut("k")
        .disabled(isButtonDisabled) // 为 true 时,按钮无法点击且快捷键无效
    }
}

3.2 基于 isPresented 的状态控制

对于通过 sheetalertpopover 等呈现的视图,其快捷键的生命周期通常与模态视图的呈现状态绑定。

struct ContentView: View {
    @State private var isSheetPresented = false
    
    var body: some View {
        Button("显示表单") {
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            SheetView()
        }
    }
}

struct SheetView: View {
    var body: some View {
        Button("提交表单") {
            // 提交操作
        }
        .keyboardShortcut(.return, modifiers: [.command]) // ⌘Return
        // 此快捷键仅在 Sheet 呈现时有效
    }
}

在这个例子中,⌘Return 快捷键只在 SheetView 显示时有效。当 sheet 被关闭后,该快捷键也随之失效,完美避免了与主界面快捷键的冲突。

3.3 使用 AppDelegateUIKeyCommand 进行全局管理

对于更复杂的应用,尤其是在 macOS 或需要非常精确控制快捷键的 iPad 应用上,你可以选择绕过 SwiftUI 的修饰符,直接在 AppDelegate 中使用 UIKit 的 UIKeyCommand

这种方法让你可以完全自主地决定在不同场景下哪些快捷键应该被激活

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var window: UIWindow?
    // 跟踪当前视图状态
    var currentView: CurrentViewType = .main
    
    override var keyCommands: [UIKeyCommand]? {
        switch currentView {
        case .main:
            return [
                UIKeyCommand(title: "搜索", action: #selector(handleKeyCommand(_:)), input: "f", modifierFlags: .command, propertyList: "search"),
                UIKeyCommand(title: "新建", action: #selector(handleKeyCommand(_:)), input: "n", modifierFlags: .command, propertyList: "new")
            ]
        case .sheet:
            return [
                UIKeyCommand(title: "保存", action: #selector(handleKeyCommand(_:)), input: "s", modifierFlags: .command, propertyList: "saveSheet")
            ]
        case .settings:
            return [] // 在设置页面禁用所有自定义快捷键
        }
    }
    
    @objc func handleKeyCommand(_ sender: UIKeyCommand) {
        guard let action = sender.propertyList as? String else { return }
        
        switch action {
        case "search": // 处理搜索逻辑
        case "new":   // 处理新建逻辑
        case "saveSheet": // 处理Sheet保存逻辑
        default: break
        }
    }
    
    // ... 其他 AppDelegate 方法
}

enum CurrentViewType {
    case main, sheet, settings
}

通过在 AppDelegate 中维护一个状态机(如 currentView),你可以根据应用当前所处的不同界面或模式,动态返回不同的快捷键数组,实现精准的全局快捷键管理。

4. 高级技巧与最佳实践

4.1 优先级与冲突解决

如前所述,SwiftUI 会选择在深度优先遍历中最先找到的快捷键。 因此,在设计快捷键时,需要注意其唯一性,避免无意中的覆盖。如果确实需要覆盖,可以利用视图的层次结构,将高优先级的快捷键定义放在更靠近视图树根部的位置或确保其被先定义。

4.2 隐藏快捷键与用户体验

你可以创建“隐藏”的快捷键(不显示在菜单中),用于一些通用操作,如关闭模态框。

// 在 AppDelegate 的 keyCommands 中
UIKeyCommand(title: "", action: #selector(handleKeyCommand(_:)), input: UIKeyCommand.inputEscape, propertyList: "closeModal"),
UIKeyCommand(title: "", action: #selector(handleKeyCommand(_:)), input: "w", modifierFlags: .command, propertyList: "closeModal")

这些没有标题的 UIKeyCommand 不会出现在菜单中,但用户按下 Esc⌘W 时仍然会触发关闭操作,这符合许多桌面应用的用户习惯。

4.3 调试快捷键

在模拟器中测试快捷键时,记得点击模拟器窗口底部的 “Capture Keyboard” 按钮(看起来像一个小键盘图标),以确保模拟器捕获你的键盘输入。

4.4 与 FocusState 结合管理文本输入焦点

在处理文本输入时,快捷键常与焦点管理配合使用。SwiftUI 的 @FocusState 可以用来程序控制第一个响应者(焦点)。

struct ContentView: View {
    @State private var username = ""
    @State private var password = ""
    @FocusState private var focusedField: Field? // 焦点状态
    
    enum Field: Hashable {
        case username, password
    }
    
    var body: some View {
        Form {
            TextField("用户名", text: $username)
                .focused($focusedField, equals: .username)
                .keyboardShortcut("1", modifiers: [.control, .command]) // 切换焦点快捷键
            
            SecureField("密码", text: $password)
                .focused($focusedField, equals: .password)
                .keyboardShortcut("2", modifiers: [.control, .command]) // 切换焦点快捷键
        }
        .onSubmit { // 处理回车键提交
            if focusedField == .username {
                focusedField = .password
            } else {
                login()
            }
        }
    }
    
    private func login() { ... }
}

4.5 在 macOS 中与菜单栏集成

在 macOS 应用中,SwiftUI 的 .commands 修饰符允许你向菜单栏添加项目,并为其指定快捷键。这些快捷键通常具有全局性,但系统会自动处理其与当前焦点视图的优先级关系。

import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandMenu("编辑") {
                Button("复制") {
                    // 执行复制操作
                }
                .keyboardShortcut("c") // 定义在菜单栏中
                
                Button("粘贴") {
                    // 执行粘贴操作
                }
                .keyboardShortcut("v")
            }
        }
    }
}

5. 实战案例:一个多视图的应用

假设我们有一个文档编辑器,它包含:

  1. 一个主编辑界面(MainEditorView)。
  2. 一个设置页面(SettingsView),通过导航链接推送。
  3. 一个导出模态框(ExportView),通过 sheet 呈现。
struct MainEditorView: View {
    @State private var documentText: String = ""
    @State private var showSettings = false
    @State private var showExportSheet = false
    @State private var isExportDisabled = true
    
    var body: some View {
        NavigationStack {
            TextEditor(text: $documentText)
                .toolbar {
                    ToolbarItemGroup {
                        Button("设置") { showSettings.toggle() }
                        Button("导出") { showExportSheet.toggle() }
                            .disabled(isExportDisabled) // 初始状态下导出禁用
                    }
                }
                .navigationDestination(isPresented: $showSettings) {
                    SettingsView()
                }
                .sheet(isPresented: $showExportSheet) {
                    ExportView()
                }
                // 主编辑器的快捷键
                .keyboardShortcut("s", modifiers: [.command]) // 保存,始终有效
        }
        .onChange(of: documentText) { 
            isExportDisabled = documentText.isEmpty // 有内容时才允许导出
        }
    }
}

struct SettingsView: View {
    var body: some View {
        Form {
            // 各种设置选项...
        }
        // 设置页面可能有自己的快捷键,但只在当前视图活跃
        .keyboardShortcut("r", modifiers: [.command]) // 重置设置
    }
}

struct ExportView: View {
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        VStack {
            // 导出选项...
            Button("确认导出") {
                // 导出逻辑
                dismiss()
            }
            .keyboardShortcut(.return, modifiers: [.command]) // ⌘Return 在Sheet中有效
        }
        .frame(minWidth: 300, minHeight: 200)
        .padding()
    }
}

在这个案例中:

  • ⌘S (保存):定义在 MainEditorView 上,只要该视图在层次结构中就有效(即使在设置页面或导出Sheet背后)。
  • ⌘R (重置):定义在 SettingsView 上,仅在设置页面可见时有效。
  • ⌘Return (确认导出):定义在 ExportView 上,仅在导出 Sheet 显示时有效。
  • 导出按钮的禁用状态:通过 isExportDisabled 状态控制,同时也禁用了其快捷键,避免了无效操作。

总结

SwiftUI 的键盘快捷键功能强大且易于使用,但其“离屏”激活的特性要求开发者仔细考虑其作用域管理。

  • 核心机制:快捷键的生命周期与其附加的视图绑定,只要视图在层次结构中,快捷键就有效。
  • 主要解决方案
    • 条件渲染与禁用:使用 if.disabled(_:) 动态控制视图及其快捷键的可用性。
    • 状态绑定:利用 isPresented 等状态,将模态视图的快捷键生命周期限制在模态显示期间。
    • 全局管理:对于复杂场景,可退回到 AppDelegate 中使用 UIKeyCommand 实现精细的、基于状态的全局快捷键控制。
  • 建议:始终考虑用户体验,确保快捷键在正确的上下文中生效,避免冲突和意外操作。善用 .commands 为 macOS 应用添加快捷键,并结合 @FocusState 管理文本输入焦点。

原文:xuanhu.info/projects/it…

Xcode 26 的10个新特性解析

原文:xuanhu.info/projects/it…

Xcode 26 的10个新特性解析

Xcode 26,这次更新不仅仅是版本号的迭代,而是对iOS开发生态的一次彻底重构。从设备端AI模型集成到革命性的界面设计语言,从编码智能辅助到跨平台开发优化,Xcode 26为开发者提供了前所未有的工具集,标志着Apple开发工具进入智能化、一体化的新纪元。本文将深入解析十大核心特性,揭示它们如何重塑开发工作流。

1. 生成式AI编程助手:ChatGPT与本地模型的深度集成

Xcode 26最引人注目的特性是深度集成了大语言模型(LLM),使开发者能够使用自然语言与代码交互。这一功能不仅支持云端模型如ChatGPT,还允许在配备Apple芯片的Mac上运行本地模型,为代码编写、测试生成、文档创作和错误修复提供智能辅助。

理论基石:上下文感知的代码生成

传统的代码自动完成基于静态语法分析,而Xcode 26的AI助手采用动态上下文收集技术。系统会自动分析整个项目结构、代码风格和开发者习惯,使模型生成的代码不仅语法正确,更符合项目特定需求。例如,当开发者输入“为地标集合添加评分功能”时,模型会参考项目中现有的数据模型和UI模式,生成类型安全且风格一致的Swift代码。

实践案例:智能错误修复与重构

考虑一个常见场景:开发者在ForEach视图中遇到类型不符合Identifiable协议的错误。在Xcode 26中,只需选择错误代码并调用Coding Tools,AI助手会分析相关类型声明和错误位置,自动建议添加协议一致性代码。更令人印象深刻的是,系统能理解代码语义——如果修复涉及多个文件,它会跨文件协调修改,保持代码库的一致性。

// 修复前:Landmark结构体缺少Identifiable一致性
struct Landmark {
    var name: String
    var coordinate: Coordinate
}

// 使用AI助手后,自动添加Identifiable一致性
extension Landmark: Identifiable {
    var id: String { name }
}

代码注释:以上代码演示了AI助手如何自动添加协议一致性。id属性使用name作为标识符,这是基于上下文分析得出的合理实现。

多模型支持与隐私保护

开发者可以灵活选择AI模型提供商:默认集成ChatGPT(无需账户即可有限使用),也支持通过API密钥连接Claude等第三方模型。对于注重隐私的团队,可以在本地部署模型,确保代码完全不离开开发环境。这种灵活性使Xcode 26能适应不同组织的安全和合规需求。

2. Liquid Glass设计语言:光学质感与流畅交互的融合

Apple在Xcode 26中引入了全新的软件设计语言Liquid Glass,这是一种基于软件的材料设计系统,将玻璃的光学特性与流体力学的动态感相结合,为应用程序带来前所未有的视觉深度和触觉响应。

设计哲学:内容优先与一致性

Liquid Glass的核心设计原则是“内容优先”。通过智能调节UI元素的光学属性(如透明度、折射率和表面反射),系统确保用户注意力始终集中在核心内容上,而非界面控件本身。这种设计在iOS 26、iPadOS 26、macOS Tahoe 26等平台上保持一致,使开发者能够轻松创建跨设备的统一体验。

技术实现:动态材质系统

Liquid Glass不是简单的视觉样式,而是一套完整的材质系统。在代码层面,它通过SwiftUI的修饰符系统暴露给开发者:

// 应用Liquid Glass效果到按钮
Button("探索") {
    // 操作逻辑
}
.buttonStyle(.liquidGlass) // 应用Liquid Glass样式
.material(.adaptive)       // 自适应材质
.depthEffect(.medium)      // 深度效果调节

代码注释:上述代码展示了如何应用Liquid Glass样式。.adaptive材质会根据环境光自动调整外观,而.depthEffect控制视觉深度级别。

实际应用:图标与控件的革新

Icon Composer应用充分利用Liquid Glass特性,允许开发者创建具有多层动态效果的图标。图标现在可以根据渲染模式(默认、深色、单色)自动调整外观,并支持模糊、半透明调整、镜面高光等高级效果。例如,天气应用图标可以在雨天显示湿润的表面反射,在晴天呈现清晰的透光效果。

3. Foundation Models框架:设备端AI与隐私保护

Foundation Models框架是Xcode 26中最重要的架构创新,它使开发者能够直接利用设备上的Apple Intelligence模型,实现智能功能同时确保用户数据永不离开设备。

架构设计:本地推理与成本免费

与依赖云端的AI服务不同,Foundation Models框架专为设备端推理优化。模型直接在iPhone、iPad或Mac上运行,带来三个关键优势:离线可用性、零成本推理和强隐私保护。开发者只需几行Swift代码即可集成强大AI能力:

import FoundationModels

// 初始化Apple Intelligence模型
let model = try AppleIntelligenceModel()

// 使用引导式生成创建内容
let response = try await model.generate(
    prompt: "总结今天的主要活动",
    guidance: .summarization // 引导生成类型
)

代码注释:此代码演示了如何初始化Apple Intelligence模型并进行引导式生成。guidance参数控制生成内容的方向,如摘要、创作或翻译。

案例研究:Day One日记应用的隐私保护AI

Automattic在Day One日记应用中集成Foundation Models框架,实现了智能日记分析功能。应用可以自动识别日记中的情绪模式、重要事件和建议提醒,所有处理均在设备上完成。用户获得个性化AI体验的同时,确保敏感日记内容不会上传到云端。

工具调用与多模态支持

框架内置工具调用能力,使AI模型能够与设备功能交互。例如,模型可以调用日历API检查时间冲突,或访问健康数据提供个性化建议。未来更新将支持多模态输入,结合文本、图像和传感器数据实现更丰富的交互。

4. 智能编码工具:上下文感知的开发辅助

Xcode 26的Coding Tools不是简单的代码补全工具,而是深度集成到开发环境中的智能辅助系统,能够在任何代码位置提供上下文相关的操作建议。

工作流集成:无缝的开发者体验

Coding Tools的核心优势在于其无缝的工作流集成。当开发者在编写代码时,工具会自动分析当前上下文(如光标位置、选中代码、错误信息)并提供最相关的操作。这些操作包括生成预览、创建Playground、修复问题或进行内联更改,所有操作都无需离开编码环境。

高级功能:多词搜索与代码探索

传统的代码搜索基于精确匹配,而Xcode 26引入了“多词搜索”技术,采用搜索引擎算法在项目中查找词语组合。例如,搜索“clipped resizable image”会找到这些词在相近位置出现的所有文档,即使它们跨越多行或以不同顺序出现。这种搜索方式特别适合探索不熟悉的代码库。

// 多词搜索示例:查找创建可调整大小图像的位置
// 搜索"clipped resizable image"可能匹配以下代码
Image("landscape")
    .resizable()
    .clipShape(RoundedRectangle(cornerRadius: 10))

代码注释:多词搜索能够识别代码语义关联,即使关键词分散在不同行也能准确匹配。

语音控制编码:无障碍开发突破

Xcode 26为Voice Control添加了Swift模式支持,开发者可以通过语音听写Swift代码。系统不仅识别单词,更理解Swift语法规则——自动处理空格位置、运算符对应和驼峰命名法。这一功能为行动不便的开发者打开了编程的大门,也提供了全新的交互方式。

5. Icon Composer:动态多层图标设计系统

图标是应用品牌识别的核心,Xcode 26中的Icon Composer应用彻底重构了图标创建工作流,支持创建基于Liquid Glass的多层动态图标。

分层架构与动态效果

Icon Composer引入全新的多层图标格式,每个图层可以独立应用动态效果。开发者可以调整深度属性、动态光照效果,并在默认、深色和单色渲染模式间自定义外观。例如,导航应用图标可以包含道路层、交通层和背景层,各层以不同速度响应设备运动,创造伪3D效果。

工作流优化:从设计到导出

工具与Xcode无缝集成,支持从单一设计创建所有所需尺寸和风格的图标。高级功能包括为不同渲染模式注释图层、模糊处理、调整半透明度和测试镜面高光。完成后,可以导出扁平化版本用于营销材料,确保品牌一致性。

实践示例:创建自适应天气图标

考虑天气应用图标设计:晴天版本显示明亮的光照效果和清晰的阴影;雨天版本则应用湿润表面效果和柔和的光线散射。通过Icon Composer,可以定义条件规则,使图标根据实时天气数据自动调整外观:

// 图标条件规则示例
IconCondition.when(.weatherIsSunny) {
    $0.applyEffect(.brightSunlight)
    $0.adjustLayerOpacity(1.0)
}

IconCondition.when(.weatherIsRainy) {
    $0.applyEffect(.wetSurface)
    $0.adjustLayerOpacity(0.8)
}

代码注释:此代码演示了如何为不同天气条件定义图标外观规则。效果参数控制视觉表现,如阳光亮度或表面湿润感。

6. Swift 6.2:并发安全与跨语言互操作

Swift 6.2作为Xcode 26的核心组成部分,引入了严格的并发检查、简化的主线程编程和对WebAssembly的支持,显著提升了语言的安全性、性能和跨平台能力。

严格并发检查与数据竞争预防

Swift 6.2建立在Swift 6的并发模型之上,通过编译时检查防止数据竞争。新编译器能够识别潜在的数据竞争条件,并强制开发者明确标记跨线程共享的数据。这一特性在大型项目中尤为重要,能够避免难以调试的并发错误。

// Swift 6.2中的安全并发实践
@MainActor // 默认在主Actor运行
class DataModel: ObservableObject {
    @Published var items: [String] = []
    
    func updateItems() async {
        // 异步操作,但更新UI时自动调度到主线程
        let newItems = await fetchItems()
        items = newItems // 编译器确保线程安全
    }
}

代码注释@MainActor注解确保所有方法默认在主线程执行,避免UI更新时的线程问题。编译器会验证所有可能的并发访问路径。

WebAssembly支持与跨平台开发

通过与开源社区合作,Swift 6.2新增对WebAssembly的支持,使Swift代码能够编译为WebAssembly模块在浏览器中运行。这一特性为Swift开发者打开了Web开发的大门,允许共享业务逻辑 between iOS应用和Web应用。

C++、Java和JavaScript互操作性增强

Swift 6.2显著改善了与其他编程语言的互操作性。新的API使Swift能够更自然地调用C++代码,与Java和JavaScript的数据交换也更加高效。这对于集成现有库和跨平台开发尤其有价值。

7. 容器化框架:在Mac上原生运行Linux容器

Xcode 26的容器化框架(Containerisation Framework)允许开发者在Mac上直接创建、下载和运行Linux容器镜像,为服务器端Swift开发和跨平台测试提供统一环境。

技术基础:针对Apple芯片优化的隔离

框架基于开源技术构建,并针对Apple芯片进行了深度优化。它利用macOS的沙箱机制提供安全的容器隔离,同时通过虚拟化技术实现x86容器的无缝运行。这意味着开发者可以在Apple芯片Mac上运行传统的x86 Linux环境,无需复杂的配置。

开发工作流:本地开发与部署一致性

容器化框架的核心价值在于确保开发环境与生产环境的一致性。开发者可以定义包含所有依赖的Dockerfile,在本地构建和测试后,直接部署到服务器。这种方法消除了“在我机器上能运行”的经典问题。

# 使用Swift 6.2的Linux容器示例
FROM swift:6.2
WORKDIR /app
COPY Package.swift .
COPY Sources ./Sources
RUN swift build -c release
CMD ["./.build/release/MyServerApp"]

代码注释:此Dockerfile演示了如何为Swift服务器应用创建容器镜像。Xcode 26支持直接在IDE中构建和运行此类容器。

实践应用:微服务架构开发

考虑一个需要与多个微服务交互的iOS应用。使用容器化框架,开发者可以在本地启动完整的微服务环境,每个服务运行在独立容器中。这使前端开发能够在不依赖后端团队的情况下进行完整测试,显著加速开发周期。

8. 游戏开发工具集:Metal 4与高级图形渲染

针对游戏开发者,Xcode 26提供了全面的工具更新,包括Metal 4图形框架、Game Porting Toolkit 3和专门的Apple Games应用,为Apple平台带来主机级游戏体验。

Metal 4:专为Apple芯片设计的高级图形

Metal 4是首个专门为Apple芯片设计的图形框架,支持高级图形和机器学习技术。新特性包括在着色器中直接运行推理网络计算光照、材质和几何体,实现电影级视觉效果。

MetalFX框架:帧插值与降噪

MetalFX框架包含两个关键技术:帧插值(Frame Interpolation)和降噪(Denoising)。帧插值为每两个输入帧生成中间帧,实现更高更稳定的帧率;降噪则使实时光线追踪和路径追踪在高级游戏中成为可能。

// Metal 4着色器中的光线追踪示例
kernel void rayTracingKernel(uint2 tid [[thread_position_in_grid]]) {
    // 初始化光线
    Ray ray = generateCameraRay(tid);
    
    // 执行光线追踪
    HitResult hit = traceRay(ray);
    
    // 使用AI降噪
    if (hit.isValid) {
        float3 color = denoise(hit.color, hit.albedo, hit.normal);
        writeToFramebuffer(tid, color);
    }
}

代码注释:此Metal着色器代码演示了光线追踪与AI降噪的结合。denoise函数使用设备端AI模型减少光线追踪噪声。

Game Porting Toolkit 3与性能分析

Game Porting Toolkit 3提供更新工具用于评估和优化游戏性能。开发者可以自定义Metal Performance HUD,获取屏幕上的性能洞察和图形代码优化指导。新增的Processor Trace工具捕获每个函数调用,帮助识别最细微的性能瓶颈。

9. 辅助功能与包容性设计工具

Xcode 26强化了辅助功能工具集,使开发者能够更轻松地创建适合所有用户的应用,包括新的Declared Age Range API、增强的Voice Control和Sensitive Content Analysis框架。

Declared Age Range API:适龄内容交付

新API允许开发者根据用户年龄范围提供适龄内容,而无需收集具体出生日期。家长可以选择允许孩子分享年龄范围而不暴露敏感信息,平衡个性化体验与隐私保护。

敏感内容分析与安全体验

Sensitive Content Analysis框架帮助应用检测和处理可能不适当的内容,特别是保护未成年用户。框架在设备上运行,确保分析过程不泄露用户数据。

Voice Control的Swift代码听写

如前所述,Voice Control现在支持Swift代码听写,这不仅帮助行动不便的开发者,也为编码教育开辟了新途径。学生可以通过语音命令学习编程概念,而不必先掌握键盘输入。

10. 应用商店与分发优化

Xcode 26改进了应用分发和管理的多个环节,包括App Store Connect API增强、TestFlight集成和本地化流程优化。

可访问性营养标签与透明化

App Store产品页面现在显示可访问性营养标签,帮助用户在下载前了解应用支持的辅助功能,如VoiceOver、Voice Control、大文本支持等。这鼓励开发者优先考虑可访问性,也为用户提供了更好的选择依据。

本地化流程强化:String Catalogs与AI注释

String Catalogs在Xcode 26中获得重大增强,现在为本地化字符串提供类型安全的Swift符号,支持直接字符串访问和自动完成。AI生成的上下文注释帮助翻译人员理解字符串使用场景,提高翻译质量。

// String Catalogs中的类型安全访问
let greeting = String(localized: "WelcomeMessage", 
                      defaultValue: "Welcome, %@!",
                      comment: "主屏幕欢迎消息")

let formattedGreeting = String(format: greeting, userName)

代码注释:此代码演示了如何安全地访问本地化字符串。defaultValue提供回退值,comment帮助翻译人员理解上下文。

App Store Connect API与Webhooks

开发者现在可以使用App Store Connect API创建webhooks获取实时更新,自动化应用管理流程。API支持Apple-Hosted Background Assets和Game Center配置,使大规模应用分发更加高效。

总结

Xcode 26不仅仅是一个开发工具更新,而是Apple为应对现代应用开发挑战提供的全面解决方案。从AI辅助编程到隐私保护框架,从革命性设计语言到跨平台开发支持,这十大特性共同构成了一个强大而协调的生态系统。

开发范式转变:Xcode 26标志着从手动编码向AI协作开发的转变,智能工具处理重复任务,让开发者专注于创造性工作。

设计一致性突破:Liquid Glass设计语言和Icon Composer确保了Apple生态系统内的视觉一致性,同时为品牌表达留下了充足空间。

隐私与性能平衡:Foundation Models框架证明设备端AI能够提供强大功能而不牺牲隐私,为行业树立了新标准。

跨平台开发成熟:Swift 6.2的WebAssembly支持和容器化框架使Swift成为真正的全栈语言,统一了移动、桌面和Web开发。

正如Apple全球开发者关系副总裁Susan Prescott所言:“开发者塑造了用户在Apple平台上的体验,Xcode 26赋予他们构建更丰富、更直观应用的能力。”随着开发者社区开始探索这些新工具,我们可以期待iOS生态系统将迎来新一轮创新浪潮。

原文:xuanhu.info/projects/it…

《Flutter全栈开发实战指南:从零到高级》- 01 - 从零开始搭建你的第一个Flutter应用

Flutter开发环境全攻略:从零开始搭建你的第一个Flutter应用

引言

Flutter是Google推出的一款跨平台移动应用开发框架,它允许开发者使用一套代码库同时构建iOS和Android应用。本教程将详细介绍如何从零开始搭建Flutter开发环境,让你快速上手Flutter开发。

1. Flutter SDK安装与配置

1.1 下载Flutter SDK

首先,我们需要从Flutter官网下载最新版本的Flutter SDK。

  1. 访问 Flutter官网
  2. 选择你的操作系统(Windows、macOS或Linux)
  3. 下载对应的SDK压缩包

Flutter官网下载页面

1.2 解压并配置环境变量

Windows系统:

  1. 将下载的压缩包解压到你想要安装的目录,例如 C:\src\flutter
  2. 配置环境变量:
    • 右键"此电脑" → "属性" → "高级系统设置" → "环境变量"
    • 在"系统变量"中找到Path,点击"编辑"
    • 添加Flutter的bin目录路径:C:\src\flutter\bin

macOS/Linux系统:

# 解压到目标目录
cd ~/development
unzip ~/Downloads/flutter_macos_3.19.0-stable.zip

# 配置环境变量
echo 'export PATH="$PATH:$HOME/development/flutter/bin"' >> ~/.zshrc
source ~/.zshrc

1.3 验证安装

打开命令行工具,运行以下命令检查Flutter是否安装成功:

flutter --version

如果安装成功,你将看到类似以下的输出:

Flutter 3.19.0 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 6d1f6c8b3a (4 weeks ago) • 2024-02-21 16:33:06 -0800
Engine • revision 204e6b6c64
Tools • Dart 3.3.0 • DevTools 2.31.1

2. Android Studio/VSCode环境搭建

2.1 Android Studio安装与配置

安装Android Studio
  1. 访问 Android Studio官网 下载安装包
  2. 按照安装向导完成安装
安装Flutter和Dart插件
  1. 启动Android Studio
  2. 打开插件市场:
    • Windows/Linux: FileSettingsPlugins
    • macOS: Android StudioPreferencesPlugins
  3. 搜索并安装以下插件:
    • Flutter
    • Dart
配置Android SDK和命令行工具
  1. 打开Android Studio
  2. 进入 ToolsSDK Manager
  3. 确保已安装以下组件:
    • Android SDK
    • Android SDK Platform-Tools
    • Android SDK Build-Tools

2.2 VSCode环境配置

安装VSCode
  1. 访问 VSCode官网 下载并安装
  2. 启动VSCode
安装必要的扩展
  1. 打开扩展面板(Ctrl+Shift+XCmd+Shift+X
  2. 搜索并安装以下扩展:
    • Flutter (由Dart Code开发)
    • Dart
配置VSCode设置

在VSCode的设置中(Ctrl+,Cmd+,),添加以下配置:

{
  "dart.flutterSdkPath": "C:\\src\\flutter",
  "dart.checkForSdkUpdates": true,
  "dart.openDevTools": "flutter"
}

3. 模拟器配置与真机调试

3.1 Android模拟器配置

创建Android虚拟设备
  1. 打开Android Studio
  2. 进入 ToolsAVD Manager
  3. 点击 Create Virtual Device
  4. 选择设备类型(推荐Pixel系列)
  5. 选择系统镜像(推荐最新的Android版本)
  6. 完成配置并启动模拟器
命令行创建模拟器
# 查看可用的系统镜像
flutter emulators --create --name My_Emulator

# 启动模拟器
flutter emulators --launch My_Emulator

3.2 iOS模拟器配置(仅限macOS)

# 查看可用的iOS模拟器
flutter emulators

# 启动iOS模拟器
flutter emulators --launch apple_ios_simulator

3.3 真机调试配置

Android设备调试
  1. 在Android设备上启用开发者选项:
    • 进入 设置关于手机
    • 连续点击"版本号"7次
  2. 启用USB调试:
    • 设置开发者选项USB调试
  3. 连接设备到电脑
  4. 运行 flutter devices 查看已连接的设备
iOS设备调试(仅限macOS)
  1. 安装Xcode(从App Store下载)
  2. 连接iOS设备
  3. 在Xcode中信任开发者证书
  4. 运行 flutter devices 确认设备连接

4. 创建第一个Flutter应用

4.1 使用命令行创建项目

# 创建新项目
flutter create my_first_app

# 进入项目目录
cd my_first_app

# 运行应用(请确保有可用的设备或模拟器)
flutter run

4.2 项目结构说明

my_first_app/
├── android/          # Android平台特定代码
├── ios/              # iOS平台特定代码
├── lib/              # 主要的Dart代码
│   └── main.dart     # 应用入口文件
├── test/             # 测试代码
├── pubspec.yaml      # 项目依赖配置
└── README.md

4.3 理解初始代码

打开 lib/main.dart 文件,你将看到默认生成的代码:

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

4.4 修改并运行应用

让我们简单修改一下应用,改变标题和颜色:

// 修改MyApp类的theme属性
theme: ThemeData(
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
  useMaterial3: true,
),

// 修改MyHomePage的标题
home: const MyHomePage(title: '我的第一个Flutter应用'),

保存文件,应用将自动热重载并显示更新后的界面。

4.5 调试技巧

热重载 vs 热重启
  • 热重载 (r): 保持应用状态,快速加载代码更改
  • 热重启 (R): 重置应用状态,重新启动应用
  • 完全重启: 停止并重新启动应用
使用DevTools
# 启动DevTools
flutter pub global activate devtools
flutter pub global run devtools

DevTools提供了强大的调试功能,包括:

  • Widget检查器
  • 性能分析
  • 内存分析
  • 网络监控

5. 常见问题解决

5.1 Flutter doctor问题

运行 flutter doctor 检查环境配置,常见问题及解决方案:

Android许可证未接受:

flutter doctor --android-licenses

Xcode未配置(macOS):

sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -runFirstLaunch

5.2 网络问题

在中国大陆地区,可能需要配置镜像:

# 设置环境变量
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

5.3 设备连接问题

Android设备无法识别:

  • 检查USB调试是否开启
  • 安装对应的USB驱动程序
  • 尝试不同的USB端口

iOS设备信任问题:

  • 在设备上信任开发者证书
  • 重新连接设备

结语

恭喜!你已经成功搭建了Flutter开发环境并创建了第一个Flutter应用。通过本教程,你学会了:

  • ✅ Flutter SDK的安装与配置
  • ✅ Android Studio和VSCode的开发环境搭建
  • ✅ 模拟器和真机调试配置
  • ✅ 创建和运行第一个Flutter应用

接下来,你可以继续学习Flutter的核心概念,如Widget、State管理、路由导航等,开始构建更复杂的应用。

扩展资源

祝你Flutter开发之旅愉快!

把SwiftUI View 转为图片

方法1: 使用 drawHierarchy

extension View {
    func snapshot() -> UIImage {
        let controller = UIHostingController(rootView: self)
        let view = controller.view

        let targetSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(origin: .zero, size: targetSize)
        view?.backgroundColor = .clear

        let renderer = UIGraphicsImageRenderer(size: targetSize)

        return renderer.image { _ in
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
    }
}

注意:这个方法需要swiftUI view 设置 .edgesIgnoringSafeArea(.all)

截屏2025-10-17 14.14.12.png 完整demo:

struct ContentView: View {
    var textView: some View {
        VStack{
            Text("ABCSF1")
            Text("ABCSF2")
            Text("ABCSF3")
            Text("Hello, SwiftUI")
                .padding()
                .background(.blue)
                .foregroundStyle(.white)
                .clipShape(Capsule())
        }
        .edgesIgnoringSafeArea(.all)
    }

    var body: some View {
        VStack {
            textView

            Button("Save to image") {
                let image = textView.snapshot()

                UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
            }
        }
        .edgesIgnoringSafeArea(.all)

    }
}

extension View {
    func snapshot() -> UIImage {
        let controller = UIHostingController(rootView: self)
        let view = controller.view

        let targetSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(origin: .zero, size: targetSize)
        view?.backgroundColor = .clear

        let renderer = UIGraphicsImageRenderer(size: targetSize)

        return renderer.image { _ in
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
    }
}

方法2: 使用ImageRenderer,需要iOS16以上

struct RenderView: View {
    let text: String

    var body: some View {
        Text(text)
            .font(.largeTitle)
            .foregroundStyle(.white)
            .padding()
            .background(.blue)
            .clipShape(Capsule())
    }
}

struct ContentView: View {
    @State private var text = "Your text here"
    @State private var renderedImage = Image(systemName: "photo")
    @Environment(\.displayScale) var displayScale

    var body: some View {
        VStack {
            renderedImage

            ShareLink("Export", item: renderedImage, preview: SharePreview(Text("Shared image"), image: renderedImage))

            TextField("Enter some text", text: $text)
                .textFieldStyle(.roundedBorder)
                .padding()
        }
        .onChange(of: text) { _ in render() }
        .onAppear { render() }
    }

    @MainActor
    func render() {
        let renderer = ImageRenderer(content: RenderView(text: text))
        
        // make sure and use the correct display scale for this device
        renderer.scale = displayScale

        if let uiImage = renderer.uiImage {
            renderedImage = Image(uiImage: uiImage)
        }
    }
}

参考文章:

www.hackingwithswift.com/quick-start…

swdevnotes.com/swift/2022/…

flutter在Xcode26打包的iOS26上全屏支持右滑的问题

Weixin Image_20251016141451_203_48.png

25年的雨水太少 小区的树木枯死很多 希望缺的冬天补上

一、 右滑返回退出flutter页面

使用Xcode26刚打包的包在iOS26上测试发现右滑直接退出了flutter的页面回到了native页面,此时不管flutter页面中跳转了很多次。

  • 第一反应就是flutter新增加东西了,
  • iOS26应该新增加东西了,
  • flutter还没适配吧!

failed.2025-10-16 14_22_03.gif

二、解决

一番搜索后确认是iOS26新增的interactiveContentPopGestureRecognizer属性的,问题。那么在跳转到FlutterViewController的时候直接设置为false。

    @objc
    private func jumpToTaskVC() {
        let vc = HXDispatchMainFlutterViewController.init(withEntrypoint: nil)
        self.navigationController?.pushViewController(vc, animated: false)
        self.navigationController?.interactivePopGestureRecognizer?.isEnabled = false
        if #available(iOS 26.0, *) {
             self.navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = false
        } else {
            // DO nothing
    }

完美。

successful.2025-10-16 14_22_46.gif

END

问了AI,尝试了AI提供的所有办法都不行。 AI还是需要努力啊,无法解决人类不知道的,或者没有告诉AI的事。

iOS App小组件(Widget)显示LottieFiles动画和GIF图片

目前, iOS小组件中无法直接显示LottieFiles动画,如果要实现iOS小组件的动画效果,必须通过一些黑科技将LottieFiles动画转换为GIF图片帧,通过一些自定义字体或者时钟旋转等方式,实现iOS小组件播放动画的功能。

9.gif

Lottie动画依赖 Core Animation + CADisplayLink 来实时渲染帧动画。

但 WidgetKit 的设计理念是「静态快照」:

Widget 并不是实时渲染的 view,而是定期刷新生成的快照(snapshot)。

它运行在后台 extension 中,不允许持续的动画循环或渲染。

小组件不支持LottieFIles

即使引入 Lottie 库,也无法使用 AnimationView.play() 这种方法——因为 widget 不支持 RunLoop 或连续帧刷新。

Xcode运行时输出报错信息,表示SwiftUI 无法序列化(encode)一个自定义 UIViewRepresentable(或 NSViewRepresentable)类型:LottieView。

PotentialCrashError: BankletWidgetExtension may have crashed  
ArchivingError: failedToEncode(types: [SwiftUI.(unknown context).PlatformViewRepresentableAdaptor<BankletWidgetExtension.LottieView>])

iOS系统显示的小组件也会因为无法序列化,显示一个黄底红色的禁止小组件(下图左侧样式)。

因此,在 widget 中「播放」Lottie 动画是不被允许的。

Lottie转换为GIF图片

需要将现有的Lottie文件转换为GIF图片格式,这样才可以在小组件中显示Lottie动画。

建议使用LottieFiles的格式转换页面:lottiefiles.com/tools/lotti…

此外还有其他两种LottieFiles格式转换方法,具体请见《Lottie动画转GIF图片》文章进行了解。

小组件显示GIF图片~~~~

1、Xcode导入并配置第三方库

打开Xcode项目,点击左侧的项目名称 – PROJECT – 项目名 – Package Dependencies,点击“添加”按钮,引入GitHub第三方库ClockHandRotationKitgithub.com/octree/Cloc…)。

导入ClockHandRotationKit框架后,主应用和小组件都必须配置这一框架。

点击左侧的项目名称 – TARGETS,分别检查主应用和小组件的General – Frameworks, Libraries, and Embedded Content,是否包含ClockHandRotationKit框架。

2、导入Gif图片

在Xcode小组件项目中,创建一个Gif组,将Gif图片拖入到这个组中。

在拖入Gif组时,Targets选择主应用和小组件,如果不勾选,后面可能无法从Bundle.main.path查找。

Xcode中Gif组:

Gif图片放入Xcode项目的原因:因为需要通过Bundle.main.path访问Gif图片。

如果将Gif放到Assets资源文件夹中,就无法在文件系统里查找真实文件路径,Bundle.main.path会返回nil。

3、创建UIImage扩展和弧形视图

1、创建一个UIImage扩展文件

因为扩展代码过长,具体请见文章底部的扩展代码部分,或者参考GitHub代码(github.com/fangjunyu1/…)。

import UIKit

extension UIImage {
    static func fromBundle(_ bundle: Bundle? = nil, forName name: String?, ofType ext: String?) -> UIImage? {
        guard let path = (bundle ?? Bundle.main).path(forResource: name, ofType: ext) else {
            return nil
        }
        return UIImage(contentsOfFile: path)
    }
}

...

2、创建一个弧形视图文件。

弧形视图文件代码:

import SwiftUI

struct ArcView: Shape {
    var arcStartAngle: Double
    var arcEndAngle: Double
    var arcRadius: Double
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
                    radius: arcRadius,
                    startAngle: .degrees(arcStartAngle),
                    endAngle: .degrees(arcEndAngle),
                    clockwise: false)
        return path
    }
}

这个文件的三个变量,分别控制弧形的起始角度、结束角度和弧形半径。

4、创建Gif图片视图

创建一个SwiftUI自定义视图,将GIF动画拆分成帧,在每一帧图片上面添加一个圆弧遮罩,然后旋转所有圆弧遮罩,通过旋转效果组合成一个动态的弧形GIF展示效果。

import SwiftUI
import ClockHandRotationKit

struct GifImageView: View {
    var gifName: String // Bundle中 gif图片的名称
    
    func getGif(_ name: String) -> UIImage.GifResult? { 
        guard let path = Bundle.main.path(forResource: "gif_(name)", ofType: "gif"),
              let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
            print("未找到该数据")
            return nil
        }
        return UIImage.decodeGIF(data)
    }
    
    var body: some View {
        if let gif = getGif(gifName) {
            GeometryReader { proxy in
                let width = proxy.size.width
                let height = proxy.size.height
                
                let arcWidth = max(width, height)
                let arcRadius = arcWidth * arcWidth
                let angle = 360.0 / Double(gif.images.count)
                
                ZStack {
                    ForEach(1...gif.images.count, id: .self) { index in
                        Image(uiImage: gif.images[(gif.images.count - 1) - (index - 1)])
                            .resizable()
                            .scaledToFill()
                            .mask(
                                ArcView(arcStartAngle: angle * Double(index - 1),
                                        arcEndAngle: angle * Double(index),
                                        arcRadius: arcRadius)
                                .stroke(style: .init(lineWidth: arcWidth, lineCap: .square, lineJoin: .miter))
                                .clockHandRotationEffect(period: .custom(gif.duration))
                                .offset(y: arcRadius)
                            )
                    }
                }
                .frame(width: width, height: height)
            }
        } else {
            // 如果没有图片,显示空白占位符
            Image("png_Home0")
                .resizable()
        }
    }
}

这个代码可以理解为,首先通过getGif方法获取Gif图片的每一帧以及运行时间。

通过ZStack排列所有的图片帧,因为ZStack视图是从后往前,在ForEach循环时,也是从后往前的顺序遍历。

假设某个Gif图片共20帧,每帧在ZStack中显示的排序为:

在每一个Gif帧上设置一个mask遮罩层,Gif帧只会显示mask的视图部分,非mask的视图不显示。mask遮罩层显示一个弧形。

弧形的开始角度、结束角度都是根据Gif帧数平均计算。

每个Gif帧的mask遮罩层都会对应到弧形上,通过设置弧形的边框,让遮罩层可以更好的显示每一个Gif帧。

设置描边可以让遮罩层覆盖到视图,弧形向下偏移半径的长度。

当对应黄色区域逆时针旋转,与蓝色的View视图区域重合时,对应的Gif图片帧就会显示出来。

因为每个Gif图片帧的黄色区域不同,所以当弧形蒙版不断旋转,就可以实现Gif图片的效果。

9.gif

5、创建Gif图片小组件

import WidgetKit
import SwiftUI
import ClockHandRotationKit

struct GifView : View {
    var entry: GifWidgetEntry

    var body: some View {
        VStack {
            GifImageView(gifName: "(entry.loopAnimation)")
        }
    }
}

struct GifAnimateWidget: Widget {
    let kind: String = "GifWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: GifWidgetProvider()) { entry in
            GifView(entry: entry)
                .containerBackground(.clear, for: .widget)
        }
        .configurationDisplayName("Animation Widget")
        .description("Play animation on the desktop in a loop.")
        .supportedFamilies([.systemSmall])
    }
}

#Preview(as: .systemSmall) {
    GifAnimateWidget()
} timeline: {
    GifWidgetEntry(date: Date(), loopAnimation: "Home33")
}

在GifView视图中,显示GifImage视图。

­­­注意事项

1、主应用和小组件都需要导入框架

Xcode导入ClockHandRotationKit框架时,必须考虑导入到主应用和小组件中。

如果没有导入主应用,就会存在Xcode调试真机时,报错并断开连接的情况。

可能是主应用也需要编译小组件的代码,当缺少小组件代码运行的框架时,就会报错。

2、Xcode项目导入Gif图片

这里使用Group或者Folder等形式,管理Gif图片。在导入图片时,需要在Target Membership中勾选主应用和小组件。

可能只需要勾选主应用,因为Bundle.main.path通过主应用的包进行查询。如果这里没有勾选主应用,就会存在查不到的情况。

3、透明背景

透明背景需要使用私有API,具体请见《iOS App小组件(Widget)设置透明背景

4、控制GIF播放速度

默认按照GIF动画时间进行播放,如果想要调整GIF播放速度,可以在GifImageView视图代码中,调整mask蒙版的代码:

.mask(
    ArcView(arcStartAngle: angle * Double(index - 1),
            arcEndAngle: angle * Double(index),
            arcRadius: arcRadius)
    .stroke(style: .init(lineWidth: arcWidth, lineCap: .square, lineJoin: .miter))
    .clockHandRotationEffect(period: .custom(gif.duration * 2)) // 控制 GIF 动画速度,选择 *2 或者 * 3
    .offset(y: arcRadius)
)

5、小组件播放卡顿或空白

因为iOS 小组件内容比较低,如果Gif图片过大,帧数过多,就可能导致超过30MB内存并无法运行小组件。

目前实际测试发现5MB以内的Gif图片,小组件显示存在压力。超过5MB的Gif图片可能会直接显示空白。

建议压缩Gif图片大小,将Gif图片尽量控制在1MB以内,这样可以正常的显示/切换Gif动画。否则Gif图片越大,小组件在显示/切换的过程中,就会出现卡顿或空白的情况。

总结

本文尽量通过简单的描述,来讲解iOS实现GIF图片的效果,通过蒙版和旋转弧形实现GIF图片动画。

除此之外,还可以通过字体实现GIF动画效果,具体请见GitHub仓库WidgetAnimation(github.com/brycebostwi…)。

我除了这两种方式外,还尝试使用倒计时显示每一帧图片,但实际上并不能实现GIF动画效果,我猜测原因是小组件不支持Image动态显示。

struct GIFPlayerView: View {
    let gif: UIImage.GifResult
    @State private var currentFrameIndex = 0
    @State private var timer: Timer? = nil
    
    var body: some View {
        GeometryReader { proxy in
            let width = proxy.size.width
            let height = proxy.size.height
            
            Image(uiImage: gif.images[currentFrameIndex])
                .resizable()
                .scaledToFill()
                .frame(width: width, height: height)
                .clipped()

当使用计时器调整数组索引显示Gif图片时,小组件不会让Image动态切换,也就无法通过计时器实现GIF动画效果。

参考文章

1、Display lottie animation in iOS WidgetKit:stackoverflow.com/questions/7…

2、How to animate WidgetKit Widgets like other apps do it?

stackoverflow.com/questions/7…

3、Lottie动画转GIF图片:fangjunyu.com/2025/10/12/…

4、GitHub WidgetAnimation:github.com/brycebostwi…

5、ClockHandRotationKit:github.com/octree/Cloc…

6、WidgetsWall:github.com/MisterZhouZ…

7、【iOS】GIF小组件的巧妙实现:juejin.cn/post/739998…

8、【iOS小组件实战】gif动态小组件:juejin.cn/post/742669…

9、GitHub UIImage扩展代码:github.com/fangjunyu1/…

10、SwiftUI控制视图透明度可见区域的mask:fangjunyu.com/2025/03/20/…

11、iOS App小组件(Widget)设置透明背景:fangjunyu.com/2025/10/08/…

扩展代码

1、UIImage扩展代码

import UIKit

extension UIImage {
    static func fromBundle(_ bundle: Bundle? = nil, forName name: String?, ofType ext: String?) -> UIImage? {
        guard let path = (bundle ?? Bundle.main).path(forResource: name, ofType: ext) else {
            return nil
        }
        return UIImage(contentsOfFile: path)
    }
}

extension UIImage {
    struct GifResult {
        let images: [UIImage]
        let duration: TimeInterval
    }
    
    static func decodeBundleGIF(_ bundle: Bundle? = nil, forName name: String) async -> GifResult? {
        guard let path = (bundle ?? Bundle.main).path(forResource: name, ofType: "gif") else {
            return nil
        }
        return await decodeLocalGIF(URL(fileURLWithPath: path))
    }
    
    static func decodeLocalGIF(_  url: URL) async -> GifResult? {
        guard let data = try? Data(contentsOf: url) else {
            return nil
        }
        return decodeGIF(data)
    }
    
    static func decodeGIF(_  data: Data) -> GifResult? {
        guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
            return nil
        }
        
        let count = CGImageSourceGetCount(imageSource)
        
        var images: [UIImage] = []
        var duration: TimeInterval = 0
        
        for i in 0 ..< count {
            guard let cgImg = CGImageSourceCreateImageAtIndex(imageSource, i, nil) else { continue }
            
            let img = UIImage(cgImage: cgImg)
            images.append(img)
            
            guard let proertyDic = CGImageSourceCopyPropertiesAtIndex(imageSource, i, nil) else {
                duration += 0.1
                continue
            }
            
            guard let gifDicValue = CFDictionaryGetValue(proertyDic, Unmanaged.passRetained(kCGImagePropertyGIFDictionary).autorelease().toOpaque()) else {
                duration += 0.1
                continue
            }
            
            let gifDic = Unmanaged<CFDictionary>.fromOpaque(gifDicValue).takeUnretainedValue()
            
            guard let delayValue = CFDictionaryGetValue(gifDic, Unmanaged.passRetained(kCGImagePropertyGIFUnclampedDelayTime).autorelease().toOpaque()) else {
                duration += 0.1
                continue
            }
            
            var delayNum = Unmanaged<NSNumber>.fromOpaque(delayValue).takeUnretainedValue()
            var delay = delayNum.doubleValue
            
            if delay <= Double.ulpOfOne {
                if let delayValue2 = CFDictionaryGetValue(gifDic, Unmanaged.passRetained(kCGImagePropertyGIFDelayTime).autorelease().toOpaque()) {
                    delayNum = Unmanaged<NSNumber>.fromOpaque(delayValue2).takeUnretainedValue()
                    delay = delayNum.doubleValue
                }
            }
            
            if delay < 0.02 {
                delay = 0.1
            }
            
            duration += delay
        }
        
        guard images.count > 0 else {
            return nil
        }
        
        return GifResult(images: images, duration: duration)
    }
}

喜欢做马甲包的有福了~现在多了一招续费方式!

背景

经常做马甲包的朋友都是知道,在账号没有被封之前是好的。

苹果过审的时候,你是心高气傲。封号3,2f的时候,你是爱莫能助。如有需要请后台留言,专注AppStore各种疑难杂症!

被封禁的账号除了要做好,基本上物理隔离、收款隔离,还有一点就是付款隔离。(目前这块,其实并不严格。但是求稳肯定是隔离了问心无愧。)

省流版本:

今天就分享一个新的付款支付方式-抖*支*付!

选择入口

前往设置 -》 Apple账户 -》 付款与配送 -》 添加新的付款方式

绑定跳转

选择支付方式-》抖*,即将前往App。

绑定完毕

绑定完毕,可以查看绑定账户信息。同时,可以支付设置中配置支付信息。

绑定完成

添加完成示意图

账号申请不易,3.2f亦是雪上加霜。愿各位且行且珍惜,多行大道,毕竟陷入囧地!

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

相关推荐

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

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

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

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

# 苹果开发者邮箱,突然收到11.2通知严重么?

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

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

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

月球矩阵日志:Swift 6.2 主线程隔离抉择(下)

在这里插入图片描述

引子:性能警报背后的 “线程迷局”

月球基地的虚拟屏幕上,星核数据的传输延迟仍在跳动 ——0.1 秒的滞后,在普通人眼中微不足道,在要求 “零误差” 的月球矩阵里却堪比 “定时炸弹”。

上集里 Main Actor 默认隔离让代码摆脱了数据竞争的 “紊流”,但在本集,它似乎露出了 “性能杀手” 的獠牙。

在本篇月球探险中,您将学到如下内容:

  • 引子:性能警报背后的 “线程迷局”
  • 七、性能真相:主线程不是 “万能背锅侠”
    • (一)被忽略的 “线程跳跃成本”
    • (二)性能的 “黄金分割点”
  • 八、终极抉择:SPM 包的 “隔离适配手册”
    • (一)两类 SPM 包的 “性格差异”
    • (二)模块级设置的 “魔法代码”
  • 九、星核文明的启示:并发的 “平衡之道”
  • 结尾:矩阵背后的 “代码哲学”

零号攥紧了鼠标,目光扫过星核文明传来的最新性能报告:“线程跳跃成本”、“操作耗时阈值”、“模块隔离适配”,这些术语像散落的拼图,只有拼起来,才能看清 Main Actor 的完整面貌。

—— 零号技术员的星核接口调试手记

在这里插入图片描述


七、性能真相:主线程不是 “万能背锅侠”

零号调出基地主控电脑的线程监控日志,红色的延迟曲线刺得人眼睛发疼。

他一直担心 “主线程塞太多任务会卡顿”,但实测后才发现,这事儿根本不是 “非黑即白”—— 就像月球基地的主控电脑,处理小任务时比外派机器人更高效,只有遇到重活才需要分流。

(一)被忽略的 “线程跳跃成本”

星核文明的技术文档里藏着关键数据:线程之间的切换,就像让机器人从主控舱跑到副控舱,看似简单,实则要消耗 “调度时间”、“数据拷贝” 两道成本。

在这里插入图片描述

零号做了个对比实验:

  • 处理 1KB 的星核校验数据(小任务):主线程直接处理耗时 0.02 秒;分流到后台线程再返回主线程,耗时 0.05 秒 ——多花了 1.5 倍时间

  • 处理 100MB 的星核影像数据(重任务):主线程处理耗时 2.1 秒;后台线程处理耗时 0.8 秒 ——差距立竿见影

这就好比:给主控电脑递一张纸条(小任务),直接递过去最快;但要搬一整箱设备(重任务),肯定得叫外派机器人。

Main Actor 默认隔离的聪明之处,就在于让 “小任务默认留主线程”,避免了不必要的 “线程跑腿成本”。

(二)性能的 “黄金分割点”

零号突然明白,之前的延迟警报,根本不是主线程 “扛不住”,而是他忘了给perform函数里的网络请求 “分流”。

在这里插入图片描述

星核的电影数据接口返回速度慢,让主线程一直等着,自然拖慢了后续任务。

他立刻修改代码,给耗时的网络请求加了@concurrent,相当于给机器人发了 “外派指令”:

@MainActor
func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
  // 网络请求耗时久,用@concurrent丢到后台,主线程先去忙别的
  let (data, _) = try await @concurrent URLSession.shared.data(for: request)
  return try await decode(data)
}

运行日志瞬间变绿 —— 延迟从 0.1 秒降到 0.03 秒。

零号苦笑:这哪里是主线程的锅,分明是自己没找准 “任务分流点”。在月球矩阵里,性能优化的核心从不是 “避开主线程”,而是 “让对的任务待在对的地方”。

在这里插入图片描述

八、终极抉择:SPM 包的 “隔离适配手册”

解决了性能问题,零号转头对付更棘手的 SPM 包 —— 星核的代码库分两类:一类是处理数据传输的 “网络模块”,一类是对接基地界面的 “UI 模块”,如若给它们套一样的隔离规则,简直是 “张冠李戴”。

(一)两类 SPM 包的 “性格差异”

在这里插入图片描述

零号翻出星核模块的设计文档,像给不同机器人贴 “任务标签”:

SPM 包类型 核心需求 隔离适配方案 类比场景
网络模块(如星核数据传输) 后台并行处理、避免主线程阻塞 不设默认隔离,全模块标记 Sendable 外派机器人在副控舱独立干活
UI 模块(如基地监控界面) 主线程安全、UI 更新稳定 模块级默认隔离为 MainActor 主控舱机器人专注处理界面任务

(二)模块级设置的 “魔法代码”

星核的网络包之前一直报数据竞争错,零号给它加了 “Sendable 全标记”,又在Package.swift里关了默认隔离:

// 星核网络包的Package.swift设置
let package = Package(
  name: "StellarNetwork",
  products: [.library(name: "StellarNetwork", targets: ["StellarNetwork"])],
  targets: [
    .target(
      name: "StellarNetwork",
      // 网络包无需默认隔离,让代码自由分配线程
      swiftSettings: [.defaultIsolation(nil)]
    )
  ]
)

// 所有类和结构体加Sendable,相当于给机器人装“安全锁”
struct StellarTransmitter: Sendable {
  func sendData(_ data: Data) async throws {
    // 后台线程自由运行,无数据竞争风险
  }
}

而 UI 模块则直接开了默认隔离,省了一堆@MainActor注解:

// 基地UI包的Package.swift设置
.target(
  name: "BaseMonitorUI",
  // 模块里所有代码默认归MainActor管
  swiftSettings: [.defaultIsolation(MainActor.self)]
)

// 无需手动加@MainActor,自动在主线程运行
class MonitorViewModel {
  var screenData: [String: String] = [:]
  
  func updateScreen() async {
    // 直接更新UI数据,安全无报错
    screenData = try await fetchMonitorData()
  }
}

零号运行测试,两类包都不再报错 —— 原来 SPM 包的抉择根本不是 “要不要默认隔离”,而是 “要不要给模块‘量身定制’隔离规则”。

在这里插入图片描述

九、星核文明的启示:并发的 “平衡之道”

调试完最后一行代码,月球矩阵的信号恢复了稳定,星核文明传来一段意味深长的留言:“并发不是‘越多越好’,也不是‘主线程独大’,而是让每个任务都有‘归属地’。”

零号合上日志,终于想通了开头的问题 ——该不该开 Main Actor 默认隔离?答案藏在 “场景” 里

  1. 对于 App 目标:开!90% 的 UI 和业务代码适合主线程默认隔离,简单又安全,特殊任务用@concurrent分流即可;
  2. 对于 SPM 包:看类型!UI 包开,网络包关,用模块级设置和 Sendable 做 “精准调控”;
  3. 核心原则:默认隔离是 “安全网”,不是 “紧箍咒”。该留主线程的留,该放后台的放,就像月球基地的机器人,各司其职才最高效。

在这里插入图片描述

结尾:矩阵背后的 “代码哲学”

深夜的月球基地,主控屏幕泛着柔和的光。

零号看着稳定跳动的星核信号,突然明白:Swift 6.2 的改动,根本不是 “限制自由”,而是给混乱的并发世界立了 “秩序”。就像月球矩阵的运行逻辑 —— 不是靠 “无拘无束的代码” 维系,而是靠 “每个任务都在对的地方” 的平衡。

在这里插入图片描述

Main Actor 默认隔离,不过是 Apple 给开发者的 “一把尺子”:它让新手少踩数据竞争的坑,让老手更懂 “任务分配的艺术”。在星核文明的眼里,好的代码从不是 “技术炫技”,而是 “恰到好处的平衡”—— 就像地球与月球的引力,不多一分,不少一分,才成就了稳定的星系。

在这里插入图片描述

而那些还在争论 “该不该开默认隔离” 的开发者,或许该问问自己:你的代码,到底是 “需要自由的外派机器人”,还是 “该待在主控舱的核心设备”?想清楚这个问题,答案自然浮现。

那么,各位微秃宝子你们想清楚了吗?

感谢观赏,我们下次不见不散!8-)

在这里插入图片描述

月球矩阵日志:Swift 6.2 主线程隔离抉择(上)

在这里插入图片描述

引子:月球基地的代码警报

月球背面的 “守望者” 基地里,零号技术员的指尖在泛着冷光的虚拟键盘上翻飞 —— 他刚接到外星 “星核文明” 的紧急通讯:人类上周推送的星核数据接口程序,因并发漏洞触发了矩阵 “数据紊流”,再晚 0.3 秒就会导致地球与月球的信号断联。

在这里插入图片描述

而罪魁祸首,正藏在 Swift 6.2 那项让全宇宙开发者吵得不可开交的新特性里:Main Actor 默认隔离

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

  • 引子:月球基地的代码警报
  • 一、风暴源头:Swift 6.2 的 “主线程新规”
  • 二、实测第一弹:Xcode 26 新项目的 “默认套路”
  • 三、实测岔路:SPM 包的 “叛逆基因”
  • 四、星核文明的启示:为什么要给代码 “找主心骨”
  • 五、破局尝试:给 MovieRepository “全员归队”
  • 六、新规的魔力:代码突然 “拨开云雾见青天”
  • 上集收尾:性能的 “暗礁” 还在前方

今天,他要剖开这个特性的内核,看看这究竟是 “矩阵稳定剂”,还是 “代码枷锁”。

—— 零号技术员的星核接口调试手记

在这里插入图片描述


一、风暴源头:Swift 6.2 的 “主线程新规”

Swift 6.2 这次更新堪称 “石破天惊”,其中最扎眼的改动,是新增了一个编译器 flag—— 默认情况下,所有没标 “非隔离 nonisolated” 的代码,都会被 “绑” 到 Main Actor(主线程)上。

这可不是小打小闹的调整,相当于给混乱的并发世界立了 “主心骨”,以前 “群龙无首” 的混乱代码,现在突然有了统一的 “指挥中心”。

零号翻着星核文明传来的技术文档,越看越明白:这个改动的核心争议,在于 “是否该让主线程当默认掌舵人”。

在这里插入图片描述

要搞懂答案,得先拆两个关键问题:

  1. 并发本身就自带 “迷宫属性”,数据竞争、线程跳跃都是家常便饭,主线程默认隔离能解决这些麻烦吗?
  2. 把代码都塞进主线程,会不会像给月球基地的主控电脑 “塞太多任务”一样,反而拖慢节奏?

在这里插入图片描述

在月球矩阵里,任何技术选择都关联着地球的信号稳定,零号不敢急着妄下定论 —— 他决定先从 Xcode 26 的实测开始,摸透这套新规的 “脾气”。

二、实测第一弹:Xcode 26 新项目的 “默认套路”

零号新建了一个对接星核数据的测试项目,刚运行就发现两个 “隐藏设定” 自动开启,像基地刚启动时的 “安全协议”:

  • 全局 Actor 隔离设为 MainActor.self:所有代码默认归主线程管

  • 可访问并发(Approachable Concurrency)开启:降低并发的使用门槛

这意味着什么?零号敲了一段测试代码,每一行都像给星核接口贴了 “身份标签”:

// 这个类默认被@MainActor隔离,相当于在月球基地“主控区”运行
class MyClass {
  // 这个属性也默认归MainActor管,就像主控区里的核心数据
  var counter = 0

  // 这个异步函数默认在MainActor里执行,相当于在主控区处理星核请求
  func performWork() async {
    // 这里的操作都会被主线程“盯紧”,避免数据乱套
  }

  // 加了nonisolated,相当于“脱离主控区”,去副控舱干活
  nonisolated func performOtherWork() async {
    // 这里的代码不受MainActor管控,适合处理不碰核心数据的杂活
  }
}

// 单独的Actor类,自带“独立舱室”,不会被MainActor“接管”
actor Counter {
  var count = 0 // 这个属性只归Counter自己管,避免和主线程抢资源
}

零号盯着运行日志恍然大悟:按这个默认逻辑,只要不手动 “松绑”,代码就会一直待在主线程里 —— 就像基地里的机器人,没收到 “外派指令” 就永远待在主控区。

在这里插入图片描述

这倒是省了以前手动加 @MainActor 的功夫,但也意味着:想让代码 “去后台打杂”,必须明明白白写清楚,再也不能 “随心所欲”。

三、实测岔路:SPM 包的 “叛逆基因”

正当零号以为摸清了规律,把星核的加密模块做成 SPM 包时,意外出现了 —— 这个包居然 “不吃主线程那套”,默认根本没开 Main Actor 隔离,像基地里的 “自由派” 机器人,不手动发指令就不按常理出牌。

在这里插入图片描述

他翻了 Swift 的官方文档才知道:新创建的 SPM 包,默认不会设置defaultIsolation flag,也就是说,代码不会自动扎进 Main Actor 的 “安全区”。要改也简单,只要在 target 的swiftSettings里加一行 “强制指令”:

swiftSettings: [
    // 给SPM包手动挂上MainActor“标签”,让它融入主线程体系
    .defaultIsolation(MainActor.self)
]

更有意思的是,SPM 包不仅默认不沾主线程,连 “NonIsolatedNonSendingByDefault” 都没开 —— 这就导致它和 App 项目成了 “两类画风”:

在这里插入图片描述

  • App 项目里:非隔离的异步函数像 “跟屁虫”,调用者在哪它就在哪。如果从主线程调,它就乖乖待在主线程;

  • SPM 包里:非隔离的异步函数像 “独行侠”,不管谁调,都一头扎进后台线程,根本不看 “调用者脸色”。

“这要是混着用,不触发矩阵冲突才怪!” 零号揉了揉太阳穴 —— 星核文明之前警告的 “数据紊流”,说不定就藏在这种 “风格差异” 里。

四、星核文明的启示:为什么要给代码 “找主心骨”

零号调出上次引发故障的代码 —— 那是一个对接星核电影数据库的列表视图,当时就是因为没搞懂主线程隔离,才差点出大事。

代码长这样:

struct MoviesList: View {
  // 星核电影仓库实例,默认归MainActor管
  @State var movieRepository = MovieRepository()
  // 电影数据,也是主线程里的“核心资产”
  @State var movies = [Movie]()

  var body: some View {
    Group {
      if !movies.isEmpty {
        List(movies) { movie in
          Text(movie.id.uuidString) // 渲染星核返回的电影ID
        }
      } else {
        ProgressView() // 加载时显示的“矩阵缓冲动画”
      }
    }.task {
      do {
        // 这里报了错:传递self.movieRepository有数据竞争风险
        // 零号当时没注意:movieRepository在主线程,loadMovies却跑在后台
        movies = try await movieRepository.loadMovies()
      } catch {
        movies = []
      }
    }
  }
}

星核文明的技术顾问当时给的解释,零号至今记得很清楚:“这就像同一个仓库,前门(主线程的视图)有人拿货,后门(后台的 loadMovies)有人搬货,不撞车才怪。”

问题的根儿,在于loadMovies是 “非隔离异步函数”—— 它在后台线程能访问movieRepository,而视图又在主线程同时访问,相当于 “两个线程抢同一个资源”,数据紊流就是这么来的。

在这里插入图片描述

要解决这个问题,当时有两条出路:

  1. loadMovies跟调用它的线程 “同频”(用nonisolated(nonsending)),调用者在哪它就在哪;
  2. 直接让loadMovies归 MainActor 管,跟视图 “待在同一个舱室”。

零号当时选了第二条路 —— 毕竟视图本来就在主线程,让loadMovies也过来,相当于 “大家都在主控区干活,省得跨线程传数据”。

但新的麻烦又冒了出来:loadMovies里调的其他函数,有的不归 MainActor 管,结果编译器报错从 “视图” 转移到了 “仓库”,像 “按下葫芦浮起瓢”让人不省心。

五、破局尝试:给 MovieRepository “全员归队”

零号当时把MovieRepository改了又改,最后改成了这样 —— 相当于给整个仓库 “安上 MainActor 的标签”:

class MovieRepository {
  // 让loadMovies归MainActor管,跟视图“同处一室”
  @MainActor
  func loadMovies() async throws -> [Movie] {
    let req = makeRequest()
    let movies: [Movie] = try await perform(req)
    return movies
  }

  // 普通函数,默认跟着类走,也归MainActor管
  func makeRequest() -> URLRequest {
    let url = URL(string: "https://example.com")! // 星核电影数据接口
    return URLRequest(url: url)
  }

  // perform也归MainActor管,确保整个流程都在主线程
  @MainActor
  func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
    let (data, _) = try await URLSession.shared.data(for: request)
    // 这里又报错了:传递self给非隔离的decode有风险
    // 就像把主控区的密钥给副控舱的人,不安全
    return try await decode(data)
  }

  // decode是非隔离的,跑在后台线程
  nonisolated func decode<T: Decodable>(_ data: Data) async throws -> T {
    return try JSONDecoder().decode(T.self, from: data)
  }
}

问题卡在了decode上 —— 它是非隔离的,perform在主线程调它,相当于 “从主控区往副控舱传数据”,编译器直接亮了红灯。

零号当时想过给MovieRepositorySendable标签(相当于给仓库 “加安全锁”),但星核的电影数据里有可变状态,根本加不了。

在这里插入图片描述

最后他才发现:要是让整个MovieRepository都默认归 MainActor 管,既能安全传self,又能让decode继续在后台 “打杂”—— 这正是 Swift 6.2 新默认设置想解决的问题!

六、新规的魔力:代码突然 “拨开云雾见青天”

零号按照 Swift 6.2 的默认设置,把MovieRepository重写了一遍 —— 这次居然没加一个 @MainActor,代码却比之前清爽十倍:

class MovieRepository {
  // 默认归MainActor管,不用手动加注解,省了不少功夫
  func loadMovies() async throws -> [Movie] {
    let req = makeRequest()
    let movies: [Movie] = try await perform(req)
    return movies
  }

  // 普通函数也默认在MainActor里,跟整个类“同频”
  func makeRequest() -> URLRequest {
    let url = URL(string: "https://example.com")! // 星核接口地址
    return URLRequest(url: url)
  }

  // 异步函数默认归MainActor,流程丝滑
  func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
    let (data, _) = try await URLSession.shared.data(for: request)
    // 这里不报错了!因为整个类默认在MainActor,传self安全
    return try await decode(data)
  }

  // 加@concurrent,明确让它去后台线程,相当于“外派任务”
  @concurrent func decode<T: Decodable>(_ data: Data) async throws -> T {
    return try JSONDecoder().decode(T.self, from: data)
  }
}

零号盯着屏幕,突然明白了新规的 “高明之处”:它把 “并发开关” 反过来了 —— 以前是 “默认开并发,手动关”,现在是 “默认关并发,手动开”。

在这里插入图片描述

只需要给decode加个@concurrent,就能让它去后台 “打杂”,其他函数安安稳稳待在主线程,既没了数据竞争,又省了一堆注解。

更妙的是,遇到await的时候,主线程会 “暂停待命”,去处理其他任务 —— 就像基地主控电脑在等星核数据时,先去处理地球的信号请求,一点不浪费时间。

这哪是 “代码枷锁”,简直是 “矩阵润滑剂”啊!

在这里插入图片描述

上集收尾:性能的 “暗礁” 还在前方

零号刚想把这个发现同步给星核文明,虚拟屏幕突然弹出一条新警报:“星核数据传输延迟增加 0.1 秒,疑似主线程负载过高。”

在这里插入图片描述

他心里一沉 —— 刚才光顾着解决数据安全,却忘了最关键的问题:把代码都塞进主线程,会不会像给月球基地的主控电脑 “塞太多任务”,拖慢星核数据的传输速度呢?

而 SPM 包的抉择更棘手:如果是网络模块,总不能让它默认待在主线程吧?但如果是 UI 模块,又必须跟主线程绑定。这些问题,就像月球矩阵里没探明的 “暗礁”,藏在下一集的日志里。

在这里插入图片描述

下一集,零号将剖开 “主线程默认隔离” 的性能真相,还会给出 SPM 包的终极抉择 —— 毕竟在地球与星核的连接中,没有 “绝对正确” 的答案,只有 “最适合矩阵的选择”。

Swift 并发深度指南:非结构化任务与分离任务全解析

前言

Swift 并发编程(Swift Concurrency)中,任务(Task)是执行异步代码的最小单元。Swift 提供了三种创建任务的方式:

  • 结构化任务(Child Tasks)
  • 非结构化任务(Unstructured Tasks)
  • 分离任务(Detached Tasks)

本文将重点讲解 非结构化任务 和 分离任务,帮助你深入理解它们的区别、使用场景以及注意事项。

非结构化任务(Unstructured Tasks)

✅ 1. 什么是非结构化任务?

非结构化任务通过 Task { ... } 创建,它不会与调用它的上下文形成父子任务关系,因此 不参与结构化并发。这意味着:

  • 它的生命周期独立于调用者。
  • 调用者不需要等待它完成。
  • 它不会自动继承结构化并发的取消行为。

✅ 2. 上下文继承

虽然非结构化任务不参与结构化并发,但它会继承以下上下文信息:

继承项 说明
当前 Actor 如果在 Actor 中创建任务,它会运行在该 Actor 上
Task Local 值 会继承当前任务的局部值
任务优先级 会继承当前任务的优先级

✅ 3. 示例讲解

示例 1:在 Actor 中使用非结构化任务

actor SampleActor {
    var someCounter = 0

    func incrementCounter() {
        Task {
            someCounter += 1  // ✅ 安全访问 actor 内部状态
        }
    }
}

✅ 说明:虽然 Task 是异步的,但由于它在 Actor 内部创建,因此可以安全地访问 someCounter,无需使用 await

示例 2:在 @MainActor 上下文中创建任务

@MainActor
func fetchData() {
    Task {
        // ✅ 这个任务运行在 MainActor 上
        let data = await fetcher.getData()  // 不会阻塞主线程
        self.models = data  // ✅ 主线程更新 UI
    }
}

✅ 说明:await 不会阻塞主线程,主线程在等待期间可以继续处理其他任务。

示例 3:fire-and-forget 风格日志函数

func log(_ string: String) {
    print("LOG:", string)
    Task {
        await uploadMessage(string)  // ✅ 异步上传日志
        print("message uploaded")
    }
}

✅ 说明:调用 log 函数后无需等待上传完成,适合日志、埋点等场景。

分离任务(Detached Tasks)

✅ 1. 什么是分离任务?

分离任务通过 Task.detached { ... } 创建,它是非结构化任务的一种特殊形式,不继承任何上下文:

  • 不继承当前 Actor(即使你在 @MainActor 中创建)
  • 不继承 Task Local 值
  • 不继承任务优先级

✅ 2. 示例讲解

示例 1:在 Actor 中使用分离任务

actor SampleActor {
    var someCounter = 0

    func incrementCounter() {
        Task.detached {
            // ❌ 错误:不能直接在非隔离上下文中访问 actor 状态
            // someCounter += 1
            await self.someCounter += 1  // ✅ 需要显式使用 await
        }
    }
}

⚠️ 说明:分离任务不在 Actor 上运行,因此访问 actor 状态必须通过 await

示例 2:在 @MainActor 中使用分离任务

@MainActor
func fetchData() {
    Task.detached {
        // ✅ 运行在后台线程
        let data = await fetcher.getData()
        self.models = data  // ❌ 注意:此时更新 UI 可能不在主线程
    }
}

⚠️ 说明:分离任务不会运行在 MainActor 上,因此更新 UI 时需要注意线程安全。

非结构化任务 vs 分离任务对比

特性 非结构化任务(Task {}) 分离任务(Task.detached)
是否参与结构化并发
是否继承 Actor
是否继承 Task Local 值
是否继承优先级
是否推荐优先使用 ❌(仅在必要时使用)

使用建议与最佳实践

✅ 使用非结构化任务的场景:

  • 从同步函数中调用异步函数(如 viewDidLoad 中调用异步接口)
  • 实现 fire-and-forget 风格的操作(如日志上传)
  • 在 Actor 或 MainActor 中执行异步任务,但仍希望继承上下文

⚠️ 使用分离任务的场景(谨慎):

  • 明确希望脱离当前 Actor 和上下文
  • 执行与当前任务完全无关的后台任务
  • 不需要结构化并发的取消或等待机制

总结与扩展思考

✅ 总结:

  • Task {} 是最常用的方式,适用于大多数异步任务。
  • Task.detached 是高级工具,仅在确实需要脱离上下文时使用。
  • 分离任务不会继承 Actor,因此访问 actor 状态时必须使用 await
  • 非结构化任务不会阻塞调用者,适合异步后台处理。

扩展思考:

  1. 在 SwiftUI 中使用非结构化任务:
struct ContentView: View {
    @State private var data: String = ""

    var body: some View {
        Text(data)
            .task {
                // ✅ SwiftUI 提供的 task 修饰符会自动管理生命周期
                data = await fetchData()
            }
    }
}

✅ 说明:SwiftUI 的 .task 会在视图消失时自动取消任务,适合生命周期绑定。

  1. 日志系统中的实际应用:
class Logger {
    func log(_ message: String) {
        print("本地日志:", message)
        Task {
            await uploadLog(message)
        }
    }

    private func uploadLog(_ message: String) async {
        // 模拟网络上传
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        print("日志上传完成:", message)
    }
}

✅ 说明:调用 log 后立即返回,不阻塞主线程,适合埋点、日志收集等。

  1. 使用 TaskGroup 实现结构化并发
await withTaskGroup(of: String.self) { group in
    for i in 1...3 {
        group.addTask {
            return "任务 \(i) 完成"
        }
    }

    for await result in group {
        print(result)
    }
}

✅ 说明:与 Task {} 不同,TaskGroup 提供结构化并发,适合并行任务管理。

学习资料

  1. www.donnywals.com/understandi…

Swift 6 新关键字 `sending` 深度指南——从 `@Sendable` 到 `sending` 的进化之路

背景:Swift 6 的“并发安全”红线

在 Swift 5 时代,开启 Strict Concurrency 后,以下代码会报错:

class MyClass { var count = 0 }

func foo() {
    let obj = MyClass()
    Task {            // 🚨 Capture of 'obj' with non-sendable type 'MyClass'
        obj.count += 1
    }
}

Swift5时 Task的初始化方法定义

public init(
  priority: TaskPriority? = nil,
  operation: @Sendable @escaping () async -> Success
)

原因:Taskoperation 被标注为 @Sendable,意味着闭包只能捕获 Sendable 的值。

Swift 6 变化:同样的代码,不再报错。因为 Task 的签名被悄悄改成了 sending 闭包。

Swift6之后的Task初始化定义

public init(name: String? = nil, 
        priority: TaskPriority? = nil, 
       operation: sending @escaping @isolated(any) () async -> Success)

Sendablesending:一字之差,天壤之别

比较项 Sendable sending
特性 类型(struct / class / actor) 值(实例、闭包)
作用对象 类型(struct / class / actor) 值(实例、闭包)
关键词位置 类型声明处 参数/变量前
编译器要求 “永远线程安全” “转移后不再使用”即可
典型场景 全局共享、长期存活 一次性移交、临时捕获
示例 final class Box: Sendable { ... } func f(_: sending () -> Void)

一句话记忆:Sendable 是“终身荣誉”,sending 是“一次性通行证”。

sending 解决了什么痛点?

痛点回顾

@Sendable 闭包要求所有捕获变量都线程安全,导致大量临时对象被迫做成 Sendable,甚至强行加锁,过度设计。

sending 的思路

编译器不再要求对象本身线程安全,只保证:

  • 该值移交给闭包后,原作用域不再访问;
  • 从而不会出现数据竞争。

合法 vs 非法 示例

✅ 合法:一次性移交

func foo() async {
    let obj = MyClass()
    Task {                // obj 被“sending”进任务
        obj.count += 1    // 只在任务内部使用
    }
    // 下面再访问 obj 会编译错误
}

❌ 非法:移交后仍访问

func foo() async {
    let obj = MyClass()
    Task { // Sending value of non-Sendable type '() async -> ()' risks causing data races
        obj.count += 1
    }
    print(obj.count)  
}

编译器会精准指出:later accesses could race。

自定义 sending 函数——语法与注意点

class ImageCache {
    func warmUp() {
        
    }
}

/// 自定义与 Task 相同能力的“发送”函数
func runLater(_ body: sending @escaping () async -> Void) {
    Task { await body() }
}

// 使用
func demo() async {
    let cache = ImageCache()          // 非 Sendable
    runLater {
        await cache.warmUp()          // 安全:cache 不再被外部持有
    }
}

语法小结:

  • sending 写在参数类型最前面,与 @escaping 位置相同。
  • 只能用于值(不能修饰类名)。
  • 编译器会插入隐形转移检查(类似 Rust 的 move 语义)。

@Sendable 的对比实验

实验项 @Sendable 闭包 sending 闭包
捕获非 Sendable 对象 ❌ 编译失败 ✅ 允许
捕获后外部再访问 ❌ 编译失败 ❌ 编译失败
跨并发域传递 ✅ 安全 ✅ 安全(靠“一次性”)
长期全局共享 ✅ 适合 ❌ 不适合

结论:@Sendable 负责“终身安全”,sending 负责“临时过户”。

实战场景:什么时候该自己写 sending

  1. 异步回调框架

    你的框架提供 func deferWork(_: sending () async -> Void),让用户把任意对象塞进来,而无需强迫它们做成 Sendable

  2. 并行 Map/Reduce 工具

    把大块非线程安全数据切成临时片,通过 sending 交给子任务,主线程后续不再触碰。

  3. 与遗留代码的接缝

    旧代码里大量 NSObject 子类无法改成 Sendable,用 sending 包装一次性异步迁移,渐进式现代化。

注意事项 & 最佳实践

  1. 不要返回 sending

    目前 sending 只能用于参数,不能作为返回类型,防止“转移”语义被滥用。

  2. 不要捕获 sending 变量再逃逸

    编译器会阻止你把 sending 闭包再次存到全局变量或属性,确保“生命周期唯一”。

  3. 单元测试多线程场景

    即使编译器通过,也要写压力测试:

   (0..<1000).concurrentForEach { _ in
       await foo()   // 确保无数据竞争
   }
  1. 与 actor 搭配更香

    sending 当作“进入 actor 世界的门票”:

    • 主线程生成临时对象 → sending 交给 actor → 后续所有访问都在 actor 内部,零锁代码。

一张图总结(建议保存)

Sendable
├─ 终身线程安全
├─ 可被任意并发环境长期持有
└─ 实现代价高(锁、不可变、snapshot)

sending
├─ 一次性“过户”
├─ 编译器保证“原主人不再碰”
└─ 实现代价低,适合临时对象

写在最后

  • sending 不是 @Sendable 的替代品,而是互补品:

    前者解决“临时搬迁”,后者解决“长期共处”。

  • 在 Swift 6 的并发宇宙里,数据竞争被提前到编译期。

    理解并善用 sending,可以让你少写 80 % 的锁,少改 80 % 的旧代码,同时不牺牲线程安全。

iOS26适配指南之UISearchController

介绍

在 iOS 26 中,Apple 对 UISearchController 做出了两项重要改进:

  1. 搜索栏在 UINavigationItem 中的布局更加灵活。
  2. 可以直接将搜索栏集成到 UIToolbar 中。

搜索栏在导航栏中的新布局

iOS 26 中如果 UISearchController 集成在 UINavigationItem,默认情况下搜索栏会显示在底部,如果希望像之前在顶部显示,可以将 UINavigationItem 的preferredSearchBarPlacement属性设置为UINavigationItem.SearchBarPlacement.stacked

案例

代码

import UIKit

class ViewController: UIViewController {
    lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "abc")
        tableView.dataSource = self
        tableView.rowHeight = 60.0
        tableView.translatesAutoresizingMaskIntoConstraints = false
        return tableView
    }()

    lazy var searchController: UISearchController = {
        let controller = UISearchController(searchResultsController: nil)
        controller.searchBar.searchBarStyle = .minimal
        controller.searchBar.placeholder = "搜索学校"
        return controller
    }()

    let schoolArray = ["清华大学", "北京大学", "中国人民大学", "北京交通大学", "北京工业大学",
                       "北京航空航天大学", "北京理工大学", "北京科技大学", "中国政法大学",
                       "中央财经大学", "华北电力大学", "北京体育大学", "上海外国语大学", "复旦大学",
                       "华东师范大学", "上海交通大学", "同济大学", "上海财经大学", "华东理工大学"]

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(tableView)
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])

        navigationController?.navigationBar.prefersLargeTitles = true
        navigationItem.largeTitle = "大学列表"
        navigationItem.searchController = searchController
        // 恢复成之前的顶部显示
        navigationItem.preferredSearchBarPlacement = .stacked
    }
}

// MARK: - UITableViewDataSource
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return schoolArray.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "abc", for: indexPath)
        cell.textLabel?.text = schoolArray[indexPath.row]
        return cell
    }
}

效果

导航栏.gif

UISearchController支持UIToolbar集成

iOS 26 之前 UISearchController 只能出现在导航栏或者内容视图顶部,而现在可以将搜索栏直接放入 UIToolbar,打造一种更轻盈、紧凑的搜索体验。

案例

代码

import UIKit

class ViewController: UIViewController {
    lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "abc")
        tableView.dataSource = self
        tableView.rowHeight = 60.0
        tableView.translatesAutoresizingMaskIntoConstraints = false
        return tableView
    }()

    lazy var searchController: UISearchController = {
        let controller = UISearchController(searchResultsController: nil)
        controller.searchBar.searchBarStyle = .minimal
        controller.searchBar.placeholder = "搜索学校"
        return controller
    }()

    let schoolArray = ["清华大学", "北京大学", "中国人民大学", "北京交通大学", "北京工业大学",
                       "北京航空航天大学", "北京理工大学", "北京科技大学", "中国政法大学",
                       "中央财经大学", "华北电力大学", "北京体育大学", "上海外国语大学", "复旦大学",
                       "华东师范大学", "上海交通大学", "同济大学", "上海财经大学", "华东理工大学"]

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(tableView)
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])

        navigationController?.navigationBar.prefersLargeTitles = true
        navigationItem.largeTitle = "大学列表"
        navigationItem.searchController = searchController
        navigationController?.setToolbarHidden(false, animated: false)
        // iOS26新增,允许将searchBar集成到UIToolbar
        navigationItem.searchBarPlacementAllowsToolbarIntegration = true
        let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
        let addBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: nil)
        let refreshBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: nil)
        // 将searchBar集成到UIToolbar
        toolbarItems = [addBarButtonItem, navigationItem.searchBarPlacementBarButtonItem, flexibleSpace, refreshBarButtonItem]
    }
}

// MARK: - UITableViewDataSource
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return schoolArray.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "abc", for: indexPath)
        cell.textLabel?.text = schoolArray[indexPath.row]
        return cell
    }
}

效果

工具栏.gif

Flutter 在 iOS 26 模拟器跑不起来?其实很简单

在之前的《Flutter 完成全新 devicectl + lldb 的 Debug JIT 运行支持》我们提到,在 iOS 26 上为了更好的 Debug 体验,Flutter 在将开发和调试场景切换到了 devicectl + lldb ,从而支持 JIT 运行和 hotload,不过暂时这部分还在 master 没有 3.35 版本。

上述说的这个调整主要影响真机 Debug ,不会影响 Release 和模拟器。

所以 3.35 版本虽然也能在 iOS 26 上进行 Debug 开发,但是在 Xcode 26 的真机上的体验会相对较差,比如 timeout 和耗时是比较常见的情况。

但是最近的一些开发者里发现,它们在 iOS 26 模拟器上也“随机”出现无法运行的情况,运行时会出现 Unable to find a destination matching the provided destination specifie 这样的提示,而在之前的 iOS 18.6 模拟器又运行良好:

Uncategorized (Xcode): Unable to find a destination matching the provided destination specifier:
                { id:6B4F9D28-C76C-4146-9527-E844395B4434 }

        Available destinations for the "Runner" scheme:
                { platform:macOS, arch:arm64, variant:Designed for [iPad,iPhone], id:00006020-000221002EE8C01E, name:My Mac }
                { platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }
                { platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }

这是 iOS 26 模拟器和 Flutter 的适配问题吗?其实问题确实是适配导致,但是却不是 Flutter 的问题,而是一些插件和模拟器之间的适配问题,实际上问题是:

用的插件不支持 “ARM 模拟器”,而你默认使用的 iOS 26 模拟器只支持 ARM 。

而解决问题的方式也很简单,只需在 Mac 上安装 Rosetta ,然后从 Xcode 中移除 iOS 26 平台,然后运行以下命令:

xcodebuild -downloadPlatform iOS -architectureVariant universal

重新下载的会是具有通用架构支持的 iOS 26,而不仅仅是基于 Apple 的 ARM 架构默认配置:

所以,解决方案是强制 Xcode 下载 iOS 26 模拟器的“通用”版本,而不是默认的“Apple Silicon”,所以你首先要通过 Xcode -> Settings -> Components -> iOS 26.0 info symbol 确定你的模拟器架构:

删除后重新下载“通用”模拟器,通过 xcodebuild -downloadPlatform iOS -architectureVariant universal 之后,就可以看到通用的 iOS 26 模拟器组件以及 Rosetta 模拟器:

当然,Rosetta 只能说是一个临时的解决方式,核心还是要看哪些插件仍然无法运行 ARM ,所以对于这个问题,更建议的是:

可以创建一个新的 Flutter 项目,然后逐个现在添加插件,看看哪些插件无法在 iOS 26 模拟器上运行,从而找出哪个插件配置错误,因为有可能只是老旧插件 ARCHS 配置错误,它不一定真的就不支持 arm64

所以这次的问题核心并不是 Flutter 的兼容问题,这也是为什么有的人发现,换了个电脑居然有可以跑的原因,主要是升级 Xcode16 之后模拟器重新安装后默认只支持 ARM 架构,如果你的插件之前配置或者设置并没有完全兼容,那么就会让问题暴露出来。

所以,这只是升级 iOS26 下的微小插曲,后面有时间再介绍更大的坑。

参考资

github.com/flutter/flu…

iOS 26 UIKit和Swift上的更新

标识符(Identifier)范围扩展与反引号

详细介绍

  • Swift 6.2 显著扩展了标识符可用字符范围;配合反引号可以使用空格、数字开头或与关键字冲突的名称作为标识符。
  • 典型用途:桥接外部接口命名、演示代码、避免与关键字冲突、为 DSL 提高可读性。
  • 注意:建议仅在必要时使用,保证团队可读性与一致性。

示例代码

// 反引号可用于包含空格/数字/关键字等非常规标识符
struct Person {
    var `full name`: String
    var `class`: String // 关键字作为属性名
}

func `run task`(_ value: Int) -> Int { value * 2 }

let 🧭 = "north" // 扩展的可用字符示例(Emoji)
let p = Person(`full name`: "Ada Lovelace", `class`: "VIP")
let output = `run task`(21)
print(p.`full name`, p.`class`, 🧭, output)

字符串插值支持默认值

详细介绍

  • 可选值插入字符串时,可在插值处直接提供默认值:当可选为 nil 时自动使用默认值。
  • 相比以往使用 ?? 的写法,插值默认值语义更直观、噪音更少。

示例代码

let nickname: String? = nil
let age: Int? = nil

print("Hi, \(nickname, default: \"Guest\")")               // Guest
print("Age: \(age, default: 18)")                         // 18

let payload: [String: String]? = ["city": "Hangzhou"]
print("City: \(payload?["city"], default: \"Unknown\")")

InlineArray(定长数组)

详细介绍

  • 新增定长数组类型 InlineArray<N, Element>,在栈上紧凑存储,具备更好的性能与缓存局部性,适合小容量、频繁创建/销毁的场景。
  • 可使用类型推断省略容量参数(当字面量可确定大小时)。

示例代码

var numbers: InlineArray<4, Int> = [1, 2, 3, 4]
var letters: InlineArray = ["A", "B", "C"]

var sum = 0
for n in numbers { sum += n }
print("sum =", sum) // 10

for (i, ch) in letters.enumerated() {
    print(i, ch)
}

enumerated() 返回类型遵守 Collection

详细介绍

  • enumerated() 的返回类型在 6.2 起遵守 Collection 协议,可直接用于需要集合语义的 API(如 SwiftUI List)。
  • 优势:无需 Array(...) 包装,减少不必要的分配与拷贝。

示例代码

let names = ["ZhangSan", "LiSi", "WangWu", "ZhaoLiu"]

// 直接在 enumerated() 上链式使用 Collection 能力(无需 Array(...) 包装)
let evenIndexed = names
    .enumerated()
    .filter { $0.offset % 2 == 0 }
    .map(\.element)

print(evenIndexed) // ["ZhangSan", "WangWu"]

并发编程语义调整与 @concurrent

详细介绍

  • 行为变化:6.2 之前,nonisolated 异步函数会在后台线程执行;6.2 起默认在调用者的 actor 上执行。
  • 新增 @concurrent
    • 让函数在后台线程运行(即使从主线程调用)。
    • 创建与调用者分离的新隔离域。
    • 所有参数与返回值必须符合 Sendable
  • 适用:耗时/CPU 密集或潜在阻塞型任务(大量数据转换、I/O 操作等)。

示例代码

actor SomeActor {
  // CPU 密集型任务:后台并发执行,参数/返回须 Sendable
  @concurrent
  nonisolated func heavyCompute(_ input: [Int]) async -> Int {
    input.reduce(0, +)
  }
}

@MainActor
func demo() async {
  let data = Array(0...1_000_00) // 10 万项
  let result = await SomeActor().heavyCompute(data)
  print("result =", result)
}

UIScene 打开外部文件

详细介绍

  • 场景: App 内存在不受自身支持的文件类型,需要委托系统或其他 App 打开。
  • 能力点: 使用 UIWindowScene.open(_:options:completionHandler:) 打开位于沙盒可访问位置的文件 URL。
  • 要点:
    • 将 Bundle 文件拷贝至可写目录(如 tmp/)再打开,避免只读路径限制。
    • 首选前台激活的 UIWindowScene;无前台场景时给出用户可理解的降级提示。
    • 处理回调 success 与错误路径,必要时提示“缺少可处理此类型的 App”。

示例代码

import UIKit

final class OpenFileViewController: UIViewController {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let src = Bundle.main.url(forResource: "sample", withExtension: "zip") else { return }
        openExternally(fileURL: src)
    }

    @MainActor
    private func openExternally(fileURL: URL) {
        let dst = URL.temporaryDirectory.appendingPathComponent(fileURL.lastPathComponent)
        try? FileManager.default.removeItem(at: dst)
        do {
            try FileManager.default.copyItem(at: fileURL, to: dst)
        } catch {
            print("copy failed: \(error)")
            return
        }

        guard let scene = UIApplication.shared.connectedScenes
            .compactMap({ $0 as? UIWindowScene })
            .first(where: { $0.activationState == .foregroundActive }) else {
            print("no active scene")
            return
        }

        scene.open(dst, options: nil) { success in
            print(success ? "已交由系统/他端 App 打开" : "打开失败或无可用 App")
        }
    }
}

UIColor HDR 曝光(Exposure/Linear Exposure)

详细介绍

  • 场景: 在支持 EDR/HDR 的设备上呈现高亮度色彩与过曝细节。
  • 能力点: UIColor 新增 exposurelinearExposure 构造,UIColorWellUIColorPickerViewController 支持 HDR 选择。
  • 要点:
    • HDR 显示依赖硬件与系统显示设置,SDR 屏幕回退为常规显示。
    • 可以设置 maximumLinearExposure 限定取色上限;吸管可通过 supportsEyedropper 控制。

示例代码

import UIKit

final class HDRColorViewController: UIViewController {
    private lazy var colorWell: UIColorWell = {
        let well = UIColorWell()
        well.title = "HDR 背景"
        well.maximumLinearExposure = 2.0
        well.supportsEyedropper = false
        well.addTarget(self, action: #selector(onColor), for: .valueChanged)
        return well
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor(red: 1, green: 0, blue: 0, alpha: 1, exposure: 2.5)
        view.addSubview(colorWell)
        colorWell.center = view.center
        colorWell.sizeToFit()
    }

    @objc private func onColor() {
        view.backgroundColor = colorWell.selectedColor
    }
}

UISlider 刻度与样式(TrackConfiguration/Style)

详细介绍

  • 场景: 需要“离散刻度”与“无拇指轨迹”选择器体验(音量档位、配置项选择)。
  • 能力点: sliderStyletrackConfiguration 支持内置刻度与自定义刻度,并可仅允许落在刻度上。
  • 要点:
    • numberOfTicks 快速生成均分刻度;或提供 ticks 自定义不均匀刻度。
    • allowsTickValuesOnly=true 时配合手动“吸附”提升易用性。
    • neutralValue 可以设定一个基准点,让进度条可以分成左右两个进度,类似音效均衡器中的默认值,或参数的零点
    • enabledRange 定义滑块的有效范围,范围之外不可以交互

示例代码

import UIKit

final class TickedSliderViewController: UIViewController {
    private lazy var slider: UISlider = {
        let s = UISlider()
        s.sliderStyle = .default
        var cfg = UISlider.trackConfiguration = UISlider.TrackConfiguration(
        allowsTickValuesOnly: true,
        neutralValue: 0.5,
        enabledRange: 0...1,
        numberOfTicks: 11 // 0.0 ~ 1.0, 11 个刻度
    ) 
        cfg.allowsTickValuesOnly = true
        s.trackConfiguration = cfg
        s.addTarget(self, action: #selector(onChange), for: .valueChanged)
        return s
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        slider.frame = CGRect(x: 20, y: view.center.y, width: view.bounds.width - 40, height: 44)
        view.addSubview(slider)
    }

    @objc private func onChange(_ sender: UISlider) {
        print(slider.value)
        // 吸附到最近刻度,测试enabledRange时注释下面代码
        let ticks: Float = 10
        sender.value = round(sender.value * ticks) / ticks
    }
}

UIViewController 转场 zoom 支持 UIBarButtonItem 触发

详细介绍

  • 场景: 以导航栏按钮为触发源的 zoom 转场,突出从按钮“放大”至目标页的空间感。
  • 要点:
    • 设置 preferredTransition = .zoom { ... } 并返回触发的 UIBarButtonItem
    • 保证回调中的 zoomedViewController 类型正确。

示例代码

import UIKit

final class ZoomSourceVC: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .add, primaryAction: UIAction { [weak self] _ in
            let next = UIViewController()
            next.view.backgroundColor = .systemPink
            next.preferredTransition = .zoom { context in
                guard context.zoomedViewController === next else { return nil }
                return self?.navigationItem.rightBarButtonItem
            }
            self?.present(next, animated: true)
        })
    }
}

UIButton 的 Liquid Glass 与 SF Symbols 动画切换

详细介绍

  • 要点:
  • UIButton的Configuration新增glass、clearGlass、prominentGlass、prominentClearGlass方法,实现 Liquid Glass 风格。
  • 新增symbolContentTransition 实现 SF Symbols 的带动画替换。
  • 能力点:
    • UIButton.Configuration.glass()
    • UIButton.Configuration.clearGlass()
    • UIButton.Configuration.prominentGlass()
    • UIButton.Configuration.prominentClearGlass()
    • symbolContentTransition

示例代码

import UIKit

final class GlassButtonViewController: UIViewController {
    private let button = UIButton(type: .system)

    override func viewDidLoad() {
        super.viewDidLoad()
        var cfg = UIButton.Configuration.prominentGlass()
        cfg.title = "点赞"
        cfg.image = UIImage(systemName: "hand.thumbsup")
        cfg.preferredSymbolConfigurationForImage = .init(pointSize: 20, weight: .regular)
        cfg.symbolContentTransition = UISymbolContentTransition(.replace, options: .speed(0.12))
        button.configuration = cfg
        button.isSymbolAnimationEnabled = true
        button.addAction(UIAction { [weak self] _ in self?.toggle() }, for: .primaryActionTriggered)
        button.frame = CGRect(x: 100, y: 200, width: 180, height: 56)
        view.addSubview(button)
    }

    private func toggle() {
        let filled = (button.configuration?.image == UIImage(systemName: "hand.thumbsup.fill"))
        button.configuration?.image = UIImage(systemName: filled ? "hand.thumbsup" : "hand.thumbsup.fill")
    }
}

UIVisualEffectView 的 UIGlassEffect 与 Container

详细介绍

  • 场景: - UIGlassEffect 是 iOS 26 引入的 UIVisualEffect 的子类,用来呈现系统级的 “Liquid Glass(液态玻璃)”材质:半透明、折射、高光、随环境和尺寸自适应的玻璃视觉效果。它通常与 UIVisualEffectView 配合使用。
  • 能力点:
    • 外观样式:支持至少 .regular 与 .clear 等玻璃样式,视觉随暗/亮模式与背景内容自动适配(更大尺寸更不透明、更小尺寸更清晰)
    • 可交互性(isInteractive) :设置为 true 后,系统会为玻璃上的交互元素提供内建触感反馈、缩放 / 弹跳等交互行为,使自定义控件与系统控件在交互感受上一致
    • 着色(tintColor) :可为玻璃设置 tintColor,系统会自动生成“vibrant”版本供玻璃上的内容使用,便于做高亮或品牌色
    • 角与形状(cornerConfiguration) :Glass 默认是胶囊(capsule)形状;WWDC 中演示了 cornerConfiguration 用于自定义圆角或相对于容器自动适配的行为(例如 .containerRelative),使玻璃在接近容器角时自动改变角半径。注意:beta 迭代中该 API 的表现可能有调整
    • 容器/合并(UIGlassContainerEffect) :支持把多个 glass 元素放进一个容器进行合成与“合并”动画(小玻璃靠近时会像水滴合并),用 UIGlassContainerEffect + UIVisualEffectView 进行组织

示例代码

final class GlassEffectViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        makeGlassContainers()
        makeLiquidGlassExample()
    }
    func makeGlassContainers() {
        // 创建容器 effect(多个 glass 元素会合并视觉)
           let container = UIGlassContainerEffect()
           let containerView = UIVisualEffectView(effect: container)
           containerView.frame = view.bounds
           containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
           view.addSubview(containerView)

           // 创建两个玻璃子项
           func makeGlassElement(frame: CGRect, tint: UIColor) -> UIVisualEffectView {
               let e = UIGlassEffect(style: .regular)
               e.tintColor = tint
               e.isInteractive = **false******
               let v = UIVisualEffectView(effect: e)
               v.frame = frame
               v.layer.cornerRadius = frame.height / 2
               v.clipsToBounds = **true******
               return v
           }
           let a = makeGlassElement(frame: CGRect(x: 60, y: 200, width: 120, height: 50), tint: .systemBlue)
           let b = makeGlassElement(frame: CGRect(x: 180, y: 200, width: 120, height: 50), tint: .systemPink)
           containerView.contentView.addSubview(a)
           containerView.contentView.addSubview(b)
    }
    // 在 UIViewController 中示例
    **func** makeLiquidGlassExample() {
        guard #available(iOS 26.0, *) else {
            // 回退:普通模糊
            let blur = UIBlurEffect(style: .systemMaterial)
            let blurView = UIVisualEffectView(effect: blur)
            blurView.frame = CGRect(x: 40, y: 120, width: 240, height: 72)
            blurView.layer.cornerRadius = 12
            blurView.clipsToBounds = true
            view.addSubview(blurView)
            return
        }

        // 创建玻璃 effect(可设置 style)
        **let** glassEffect = UIGlassEffect(style: .regular)
        glassEffect.tintColor = .systemGray5
        glassEffect.isInteractive = true       // 启用交互感
        
        // 使用 UIVisualEffectView 承载 effect
        let glassView = UIVisualEffectView(effect: glassEffect) // 先为 nil,稍后动画 materialize
        glassView.frame = CGRect(x: 40, y: 120, width: 240, height: 72)
        glassView.layer.cornerRadius = 12
        glassView.clipsToBounds = true

        // 在 contentView 添加内容(label 会自动成为 vibrant)
        let label = UILabel(frame: glassView.contentView.bounds.insetBy(dx: 12, dy: 8))
        label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        label.text = "Liquid Glass Button"
        label.textAlignment = .center
        label.textColor = .label
        glassView.contentView.addSubview(label)
        view.addSubview(glassView)
    }
}

UIImageView Symbol Animations:drawOn/drawOff

详细介绍

  • 场景: 更具“手绘描边感”的开关式动画效果展示。
  • 要点: 使用 addSymbolEffect(.drawOn/.drawOff, options: .speed(_)),搭配合适的 SymbolConfiguration

示例代码

import UIKit

final class SymbolDrawViewController: UIViewController {
    private let imageView: UIImageView = {
        let cfg = UIImage.SymbolConfiguration(pointSize: 96, weight: .thin)
        return UIImageView(image: UIImage(systemName: "bolt", withConfiguration: cfg))
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        imageView.center = view.center
        imageView.frame.size = CGSize(width: 160, height: 160)
        view.addSubview(imageView)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        imageView.addSymbolEffect(.drawOff, options: .speed(0.12))
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
            self.imageView.addSymbolEffect(.drawOn, options: .speed(0.12))
        }
    }
}

UIView 圆角配置 UICornerConfiguration(可动画)

详细介绍

  • 能力点: 通过 cornerConfiguration 以组合/胶囊/均匀/单边等方式定义圆角,且可被动画过渡。
  • 用法: capsule()uniformCorners(radius:)corners(topLeftRadius:...)uniformEdges(leftRadius:rightRadius:)

示例代码

import UIKit

final class CornerConfigViewController: UIViewController {
    private let demo = UIView(frame: CGRect(x: 140, y: 200, width: 120, height: 120))

    override func viewDidLoad() {
        super.viewDidLoad()
        demo.backgroundColor = .systemBlue
        demo.cornerConfiguration = .uniformCorners(radius: 12)
        view.addSubview(demo)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        UIView.animate(withDuration: 1) {
            self.demo.cornerConfiguration = .corners(topLeftRadius: 6, topRightRadius: 24, bottomLeftRadius: 24, bottomRightRadius: 6)
        }completion: { _ in
            UIView.animate(withDuration: 1) {
                self.demo.cornerConfiguration = .capsule()
            }completion: { _ in
                UIView.animate(withDuration: 1) {
                    self.demo.cornerConfiguration = .uniformEdges(leftRadius: 25, rightRadius: 29)
                }completion: { _ in
                    UIView.animate(withDuration: 1) {
                        self.demo.cornerConfiguration = .uniformEdges(topRadius: 12, bottomRadius: 20)
                    }
                }
            }
        }
    }
}

动画选项 .flushUpdates(自动追踪变更)

详细介绍

  • 能力点: .flushUpdates 自动追踪 @Observable 数据或 AutoLayout 约束变更并添加动画,无需手动 layoutIfNeeded()
  • 建议: 数据驱动优先;必要时配合 UIViewPropertyAnimator

示例代码

import UIKit

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

final class FlushUpdatesViewController: UIViewController {
    private let box = UIView()
    private var w: NSLayoutConstraint!
    private var h: NSLayoutConstraint!
    private let model = Model()

    override func viewDidLoad() {
        super.viewDidLoad()
        box.backgroundColor = .systemRed
        box.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(box)
        w = box.widthAnchor.constraint(equalToConstant: 80)
        h = box.heightAnchor.constraint(equalToConstant: 80)
        NSLayoutConstraint.activate([
            w, h,
            box.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            box.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }

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

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        UIView.animate(withDuration: 1.0, delay: 0, options: .flushUpdates) {
            self.model.bg = .systemBlue
        } completion: { _ in
            _ = UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 1.0, delay: 0, options: .flushUpdates) {
                self.w.constant = 220
                self.h.constant = 220
            }
        }
    }
}

UITabBarController 最小化行为与底部辅助视图

详细介绍

  • 能力点:
    • tabBarMinimizeBehavior = .onScrollDown:向下滚动时仅保留首个 Tab 与搜索 Tab 图标,中部显示 UITabAccessory
    • bottomAccessory:为 TabBar 上方增设工具条等辅助视图。

示例代码

import UIKit

final class TabsVC: UITabBarController {
    override func viewDidLoad() {
        super.viewDidLoad()
        tabs.append(configTab(title: "聊天", image: "message", id: "chats"))
        tabs.append(configTab(title: "通讯录", image: "person.2", id: "contacts"))
        tabs.append(configTab(title: "发现", image: "safari", id: "discover"))
        tabs.append(configTab(title: "我", image: "person", id: "me"))
        tabs.append(configSearchTab(title: "搜索"))
        selectedTab = tabs.last
        tabBarMinimizeBehavior = .onScrollDown
        bottomAccessory = UITabAccessory(contentView: UIToolbar())
    }

    private func configTab(title: String, image: String, id: String) -> UITab {
        UITab(title: title, image: UIImage(systemName: image), identifier: id) { _ in
            let vc = UIViewController()
            let scroll = UIScrollView(frame: UIScreen.main.bounds)
            scroll.backgroundColor = .secondarySystemBackground
            scroll.contentSize = CGSize(width: UIScreen.main.bounds.width, height: 1600)
            vc.view.addSubview(scroll)
            return UINavigationController(rootViewController: vc)
        }
    }

    private func configSearchTab(title: String) -> UISearchTab {
        UISearchTab { _ in
            let vc = UIViewController()
            vc.view.backgroundColor = .systemBackground
            return UINavigationController(rootViewController: vc)
        }
    }
}

iPadOS Menu Bar(UIMainMenuSystem)

详细介绍

  • 场景: iPadOS 具备 macOS 风格菜单栏,支持注入快捷键与自定义 UIMenu
  • 要点: 通过 UIMainMenuSystem.shared.setBuildConfiguration 在运行时构建或替换菜单层级。

示例代码

import UIKit

final class MenuBarViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let config = UIMainMenuSystem.Configuration()
        UIMainMenuSystem.shared.setBuildConfiguration(config) { builder in
            let refresh = UIKeyCommand(input: "R", modifierFlags: [.command], action: #selector(self.refresh))
            refresh.title = "Refresh"
            refresh.image = UIImage(systemName: "arrow.clockwise")
            builder.insertElements([refresh], beforeMenu: .about)

            let sort = UIMenu(title: "Sort", children: [
                UICommand(title: "By Name", action: #selector(self.sortByName)),
                UICommand(title: "By Date", action: #selector(self.sortByDate))
            ])
            builder.insertSibling(sort, afterMenu: .help)
        }
    }

    @objc private func refresh() { view.backgroundColor = .systemTeal }
    @objc private func sortByName() { view.backgroundColor = .systemGreen }
    @objc private func sortByDate() { view.backgroundColor = .systemOrange }
}

Update Properties 轻量 UI 更新

详细介绍

  • 能力点: UIViewController.updateProperties() / UIView.updateProperties() 用于不触发布局的轻量 UI 更新。
  • 适合: 修改文本/颜色/可见性这类不需要触发 layoutSubviews() 的更新。
  • 补充: setNeedsUpdateProperties() 可手动请求一次更新;与 @Observable 协同可自动追踪。

示例代码

import UIKit

@Observable final class BannerModel { var text = "Hello"; var color: UIColor = .label }

final class UpdatePropsViewController: UIViewController {
    private let label = UILabel()
    private let model = BannerModel()

    override func viewDidLoad() {
        super.viewDidLoad()
        label.font = .systemFont(ofSize: 40, weight: .bold)
        label.textAlignment = .center
        label.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 100)
        label.center = view.center
        view.addSubview(label)
    }

    override func updateProperties() {
        super.updateProperties()
        label.text = model.text
        label.textColor = model.color
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        model.text = "iOS26"
        model.color = .systemBlue
        // 手动触发一次
        setNeedsUpdateProperties()
    }
}

UIKit 支持 @Observable(自动追踪 UI 更新)

详细介绍

  • 概念: @Observable 是一个 属性包装器(Property Wrapper) ,用于标记类或结构体,使其内部属性 自动可观察(observable) 。被标记的对象会自动生成 可监听的事件,UI 或其他订阅者可以自动响应属性变化,而不必手动发布通知。类似 Combine 的 @Published + ObservableObject,但不依赖 Combine,更加轻量和原生。
  • 能力点: UIKit 直接追踪 @Observable 类实例属性变化,自动驱动 layoutSubviews()/viewWillLayoutSubviews().flushUpdates 动画。
  • 兼容性: 向下可至 iOS 18,需在 Info.plist 添加 UIObservationTrackingEnabled=YES

示例代码

import UIKit

@Observable final class PhoneModel { var name: String; var os: String; init(name: String, os: String) { self.name = name; self.os = os } }

final class PhoneCell: UITableViewCell {
    var model: PhoneModel? { didSet { setNeedsLayout() } }
    private let nameLabel = UILabel(); private let osLabel = UILabel()
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        [nameLabel, osLabel].forEach { contentView.addSubview($0) }
        nameLabel.font = .boldSystemFont(ofSize: 22); osLabel.font = .systemFont(ofSize: 16)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    override func layoutSubviews() {
        super.layoutSubviews()
        nameLabel.frame = CGRect(x: 20, y: 12, width: bounds.width - 40, height: 28)
        osLabel.frame = CGRect(x: 20, y: 44, width: bounds.width - 40, height: 22)
        nameLabel.text = model?.name; osLabel.text = model?.os
    }
}

final class PhoneListVC: UIViewController, UITableViewDataSource {
    private let table = UITableView(frame: .zero, style: .plain)
    private let data = [
        PhoneModel(name: "iPhone 16", os: "iOS 18"),
        PhoneModel(name: "iPhone 16 Pro", os: "iOS 18")
    ]
    override func viewDidLoad() {
        super.viewDidLoad()
        table.dataSource = self; table.rowHeight = 78
        table.register(PhoneCell.self, forCellReuseIdentifier: "cell")
        table.frame = view.bounds; view.addSubview(table)
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            self.data[0].name = "iPhone 17"; self.data[0].os = "iOS 26"
        }
    }
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { data.count }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! PhoneCell
        cell.model = data[indexPath.row]
        return cell
    }
}

强类型通知(NotificationCenter.MainActorMessage/AsyncMessage)

详细介绍

  • 能力点: 编译期类型检查替代字符串通知名 + userInfo 方式,提升线程/类型安全。
  • 两类: 主线程消息 MainActorMessage 与异步消息 AsyncMessage

示例代码

import UIKit

public final class NotifySubject { static let shared = NotifySubject() }

public struct TitleChanged: NotificationCenter.MainActorMessage {
    public typealias Subject = NotifySubject
    public static var name: Notification.Name { .init("TitleChanged") }
    let title: String
}

final class TypedNotificationVC: UIViewController {
    private var token: NotificationCenter.ObservationToken!
    private let label = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        label.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 60)
        label.center = view.center; label.textAlignment = .center; view.addSubview(label)
        token = NotificationCenter.default.addObserver(of: NotifySubject.shared, for: TitleChanged.self) { [weak self] msg in
            self?.label.text = msg.title
        }
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
       Task {
            //子线程发送,在主线程收到
            NotificationCenter.default.post(TitleChanged(title: "Updated"), subject: NotifySubject.shared)

        }
    }

    deinit { NotificationCenter.default.removeObserver(token) }
}

从 Auto Layout 原理看:为什么 UITableView.tableHeaderView 无法自动撑开?

Auto Layout 已经普及十余年,但 UIKit 的某些角落仍然坚守着古老的 frame。 UITableView.tableHeaderView 就是一个经典例子。明明内部是 Auto Layout 布局,却依然要手动设置 frame 才能显示正常。为什么?

本文将从 Auto Layout 的求解原理 出发,系统地解释:

为什么 tableHeaderView 不能自动撑开、 为什么必须显式地用 frame 回写高度、 以及这背后体现的 UIKit 设计哲学。

一、一个看似“奇怪”的现象

假设我们有如下代码:

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let header = tableView.tableHeaderView
    tableView.tableHeaderView = header
}

直觉上,在 viewDidLayoutSubviews 阶段,Auto Layout 已经求解完所有布局,headerView 内部子视图的约束也都确定了,那我重新给 tableHeaderView 赋个值,不就自动刷新高度了吗?”

现实却是:

❌ 不会自动撑开。

headerView 的高度仍然是旧值,UITableView 不会自动更新。

于是我们不得不写出这段“古典式”代码:

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    guard let header = tableView.tableHeaderView else { return }

    header.layoutIfNeeded()
    let height = header.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height

    if header.frame.height != height {
        var frame = header.frame
        frame.size.height = height
        header.frame = frame
        tableView.tableHeaderView = header
    }
}

为什么我们要手动回写 frame? 为什么 Auto Layout 不能自动把内部布局的结果反映到外层容器?

二、Auto Layout 是如何工作的?

Auto Layout 是一个基于约束方程求解的布局系统

在内部,它维护着一个线性方程组: A * x = b

  • A:约束系数矩阵(由 NSLayoutConstraint 转换而来)
  • x:待求变量(视图的几何属性:x、y、width、height)
  • b:常量项(superview 尺寸、constant 值、margin 等)

✅ 关键点:

Auto Layout 是单向求解

父视图的几何属性作为输入常量,

子视图的位置和尺寸作为未知量求解。

也就是说:

  • Auto Layout 会更新「子视图」的 frame;

  • 但不会反向修改「父视图」的 frame。

除非——父视图本身也被纳入了上层的约束系统中。


三、一个具体的例子

label.topAnchor.constraint(equalTo: header.topAnchor, constant: 10)
label.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -10)

Auto Layout 方程为:

y_label_top - y_header_top = 10
y_header_bottom - y_label_bottom = 10

对引擎来说:

  • header.top、header.bottom 是常量输入(等号右边的 b);

  • label.top、label.bottom 是未知变量(x)。

求解结果:

  • label 的 frame 被更新;
  • header 的 frame 不会动(它是常量)。

四、为什么 tableHeaderView 是“特殊的容器”

tableHeaderView 在 UITableView 的视图层级中如下:

UITableView
 ├── UITableViewWrapperView
 ├── tableHeaderView
 ├── UITableViewCell
 └── tableFooterView

当我们设置:

tableView.tableHeaderView = header

UIKit 内部做的其实是:

- (void)setTableHeaderView:(UIView *)view {
    _tableHeaderView = view;
    [self addSubview:view];
    view.frame = (0, 0, self.bounds.size.width, view.frame.size.height);
    [self _updateHeaderLayout];
}

UITableView 仅使用 header.frame.height 来确定表头区域大小,并不会把 headerView 加入 Auto Layout 求解系统。

换句话说:

  • headerView 内部的 Auto Layout 是一个独立系统
  • headerView 本身的 frame 是外部输入;
  • UITableView 不会“读取” headerView 内部约束求出的理想高度。

五、从数学角度看:为什么不会更新 frame

我们可以把 Auto Layout 的行为分成三种情况:

层级关系 Auto Layout 中角色 是否被求解更新 frame
普通 subview 未知量 (x) ✅ 会被更新
superview(有上层约束) 中间变量 ✅ 会被更新
superview(根节点 / 容器) 输入常量 (b) ❌ 不会更新

tableHeaderView 恰好是第三种: 它是一个 Auto Layout 系统的根节点, 其 frame 是输入常量,Auto Layout 仅解内部子视图的位置。

六、为什么 Auto Layout 不设计为“子撑父”?

从工程角度,这种设计非常有必要:

1️⃣ 防止循环依赖

如果子视图的变化会自动修改父视图的尺寸,

可能导致整个视图树上行传播、性能灾难,甚至无限循环。

2️⃣ 保证布局稳定性

UIKit 的设计是“确定性求解”:

每个容器只负责内部布局,不修改外部边界。

这样一次 layout pass 的结果是可预测的。

3️⃣ 历史兼容性

UITableView 诞生于 iOS 2 时代,Auto Layout 出现在 iOS 6。

UITableView 的核心滚动、复用机制基于 frame 偏移。

若强行将其纳入 Auto Layout,性能与兼容性都会受影响。


七、那为什么 systemLayoutSizeFitting 有用?

因为 systemLayoutSizeFitting 是一种「受控的反向测量机制」。 它的语义是:

“在保持内部约束满足的情况下,请告诉我这个 view 理想的尺寸。”

内部其实是临时创建一个 Auto Layout 系统:

  • 假设某个宽度;
  • 解出最小满足约束的高度;
  • 返回结果(不会修改 frame)。

我们手动取回结果后,再用 frame 更新外部系统。

这就实现了“安全的单向同步”。


八、总结:Auto Layout 与 frame 的分界线

类型 是否在 Auto Layout 系统中 Auto Layout 是否更新其 frame
普通子视图 ✅ 是 ✅ 会更新
父视图(有上级约束) ✅ 是 ✅ 会更新
父视图(系统根节点) ❌ 否 ❌ 不会更新
tableHeaderView ❌ 独立系统根 ❌ 不会更新

所以:tableHeaderView 是 Auto Layout 系统的「根容器」。它的 frame.height 是约束方程的已知常量(b),不会被 Auto Layout 求解更新。这就是为什么我们必须用手动的 frame 回写方式更新其高度。


九、结尾:理解边界,才能真正理解 Auto Layout

Auto Layout 不是“响应式几何系统”,它是一个分层、单向、局部求解的约束引擎。而 tableHeaderView 正是一个经典的边界案例:它提醒我们——理解 Auto Layout 的边界,比熟练使用约束更重要。

Swift 基础语法全景(二):可选型、解包与内存安全

为什么需要 Optional?—— 把“没有值”做成类型

Objective-C 用 nil 指针表示“无”,但运行时才发现野指针;

Swift 把“可能有值 / 可能没有”编译期就写进类型系统,消灭空指针异常。

// 错误:普通 Int 永远不能为 nil
var age: Int = nil      // ❌ Compile-time error

// 正确:可选型才能表达“缺值”
var age: Int? = nil     // ✅

Optional 的本质——语法糖背后的 enum

// 伪代码,真实定义在标准库
enum Optional<Wrapped> {
    case none          // 无值
    case some(Wrapped) // 有值
}

因此 Int? 只是 Optional<Int> 的简写。

你可以手动拼出 Optional:

let x: Optional<Int> = .some(5)
let y: Int? = .none

产生 Optional 的 6 大场景

  1. 可失败构造器
let str = "123"
let num = Int(str)   // 返回 Int?,因为 "abc" 无法转数字
  1. 字典下标
let dict = ["a": 1]
let value = dict["b"] // Int?,键缺失时为 nil
  1. 反射 & KVO
  2. 异步回调“结果/错误”
  3. 链式访问可能中断
  4. 服务端 JSON 解析字段缺失

解包 4 件套

方式 语法 适用场景 风险
强制解包 ! optional! 100% 确定有值 运行时崩溃
可选绑定 if let if let x = optional { } 临时只读常量
守护式 guard let guard let x = optional else { return } 提前退出(如函数/作用域中)
nil-coalescing ?? optional ?? default 提供兜底值

代码对比:

// 1. 强制解包—— Demo 可用,生产禁止
let serverPort = Int("8080")!   // 若配置写错直接崩溃

// 2. if let—— 最常用
if let port = Int("8080") {
    print("绑定端口:\(port)")
}

// 3. guard let—— 函数早期退出,减少嵌套
func connect(host: String, portText: String) -> Bool {
    guard let port = Int(portText) else { return false }
    // port 从这一行开始是非可选
    return true
}

// 4. ?? —— 给默认值,代码最短
let port = Int("8080") ?? 80

可选链 Optional Chaining—— 一句话 安全穿透

class Address {
    var street: String?
}
class Person {
    var address: Address?
}

let bob: Person? = Person()
let streetLength = bob?.address?.street?.count ?? 0
// 任意环节 nil 立即返回 nil,不崩

隐式解包 Optional(IUO===implicit unwrap optional)—— 99% 的场景你不需要

语法:类型后加 ! 而非 ?

var name: String!       // 隐式解包
print(name.count)       // 编译器帮你偷偷加 `!`

看似方便,实际埋雷:

  • 值后来变成 nil → 运行时崩
  • 与 Objective-C 接口对接时,系统 API 可能标记为 IUO,仍需手动判空

官方建议:只在“初始化后立刻有值,且之后不会 nil” 使用,例如 Storyboard IBOutlet。

其他场景用普通 Optional + guard let 最稳。

内存安全四件套——编译期即消灭悬垂指针

Swift 在编译期强制以下规则:

  1. 变量使用前必须初始化(Definite Initialization)
let x: Int
print(x)   // ❌ 报错:使用前未初始化
  1. 数组越界立即崩溃
let arr = [1, 2, 3]
let v = arr[5]   // 运行期崩溃,而非缓冲区溢出
  1. 对象释放后无法访问(ARC + 强引用)
  2. 并发访问冲突检测(Swift 5.5+ Actor & Sendable)

Optional 实战:解析 JSON 字段

struct User: Decodable {
    let id: Int
    let name: String
    let avatar: URL?   // 用户可能没上传头像
}

let json = """
{"id": 1, "name": "Alice"}
""".data(using: .utf8)!

do {
    let user = try JSONDecoder().decode(User.self, from: json)
    let url = user.avatar?.absoluteString ?? "default.png"
    print(url)
} catch {
    print(error)
}

利用 Optional 天然表达“字段缺失”,无需写大量 if xxx != NSNull 判断。

性能Tips:Optional 会多占内存吗?

  • Optional 底层会多 1 个 byte 存放“是否有值”标记,对齐后几乎无感知。
  • 在值类型栈空间,编译器会做内联优化,不用担心“装箱”开销。
  • 高频率调用处(如 3D 顶点数据)可用 UnsafeBufferPointer 避开 Optional。

小结 & checklist

  1. 永远优先用 if let / guard let 而非 !
  2. 对外暴露的 API 返回 Optional,表明“可能失败”
  3. 隐式解包 只留给 @IBOutlet 或立即初始化的常量
  4. 可选链 + nil-coalescing 可让代码保持“一行表达”
  5. 编译期内存安全四件套让 C 式野指针错误几乎绝迹

Swift 基础语法全景(一):从变量到类型安全

常量与变量:let vs var

  1. 声明语法
// 常量:一次赋值,终身不变
let maximumLoginAttempts = 10        // 最大尝试次数,业务上不允许修改

// 变量:可反复写入
var currentAttempt = 0               // 当前尝试次数,失败+1
  1. 延迟赋值

只要「第一次读取前」完成初始化即可,不必一行写完。

var randomGenerator = SystemRandomNumberGenerator()
let isDevEnvironment = randomGenerator.next() % 3 == 0
let timeout: Int
if isDevEnvironment {
    timeout = 100                   // 开发环境宽松一点
} else {
    timeout = 10                    // 生产环境严格
}
// 编译器会检查所有分支都赋值,否则报错
  1. 一次声明多个
let red = 0xFF0000, green = 0x00FF00, blue = 0x0000FF
var x = 0, y = 0, z = 0

命名规则:Unicode 可用,但别作死

✅ 合法

let π = 3.14159
let 欢迎 = "Hello"
let 🐶🐮 = "dogcow"

❌ 非法

let 3x = 1        // 不能以数字开头
let a-b = 0       // 不能含运算符
let private = 1   // 虽能编译,但与访问控制关键字冲突,别这么干

基本数据类型一览表

类型 说明 字面量示例 备注
Int 平台字长 42, -7 32 位平台 == Int32;64 位 == Int64
UInt 无符号平台字长 42 仅当位运算/内存布局时才用
Int8/16/32/64 指定位宽 127 与 C 交互、网络协议、二进制文件
Double 64 位浮点 3.14159, 1.25e2 默认推断类型
Float 32 位浮点 3.14 内存/带宽敏感场景
Bool 真/假 true, false 不能用 0/1 代替
String UTF-8 文本 "Hello" 值类型,拷贝即复制(写时优化)

整数字面量“花式写法”

let decimal = 17
let binary = 0b10001       // 0b 前缀
let octal = 0o21           // 0o 前缀
let hex = 0x11             // 0x 前缀

// 增加可读性
let oneMillion = 1_000_000
let rgb = 0xFF_FF_FF_00

类型推断与类型注解

  1. 推断
let meaningOfLife = 42        // 推断为 Int
let pi = 3.14159              // 推断为 Double(不是 Float)
  1. 显式注解
var message: String = "Hello" // 显式告诉编译器
// 如果不给初始值,必须写类型
var score: Int
score = 100
  1. 多变量同类型
var a, b, c: Double           // 3 个都是 Double

数值类型转换——“必须显式”

Swift 没有隐式类型转换,防止溢出 Bug。

let age: UInt8 = 25
let weight: UInt16 = 76

// 错误:age + weight          // 类型不一致
let total = UInt16(age) + weight // ✅ 显式构造

浮点与整数互转:

let x = 3
let d = Double(x) + 0.14159     // 3.14159

let fraction: Double = 4.75
let whole = Int(fraction)       // 4,截断(不会四舍五入)

类型别名 typealias——给长名字起小名

typealias Byte = UInt8
typealias AudioSample = UInt16

let maxAmplitude = AudioSample.min   // 0

工程场景:

  • 与 C API 交互时,把 UInt32 起别名叫 CRC32,语义清晰。
  • 以后底层类型换成 UInt64 时,改一行即可,业务层无感知。

Print & 字符串插值

let name = "Swift"
print("Hello, \(name)!")     // Hello, Swift!

// 自定义 terminator
print("Loading...", terminator: "")   // 不换行

注释:可嵌套的多行注释

/* 外层
   /* 内层 1
      /* 内层 2 */
   */
*/

利用嵌套,可以快速“整块注释”掉代码,而不用担心内部已有注释冲突。

分号:可加可不加

let a = 1; let b = 2          // 同一行多条语句才需要

小结 & 工程化思考

  1. 默认用 Int、Double,除非有明确位宽需求。
  2. 常量优先(let),减少可变状态。
  3. 命名用英文/中文均可,但团队要统一;CI 可加 --strict-conventions 检查。
  4. 类型转换显式写,让 Code Review 一眼看出截断/溢出风险。
  5. typealias 不仅为了“少打字”,更是语义抽象,跨平台迁移利器。
❌