普通视图

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

Swift 5.9 新特性揭秘:非复制类型的安全与高效

作者 iOS新知
2025年7月11日 18:04

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

image.png

前言

在 Swift 中,类型默认是可复制的。这种设计简化了开发过程,因为它允许值在赋值给新变量或传递给函数时轻松复制。

然而,这种便利有时会导致意想不到的问题。例如,复制单次使用的票据或重复连接数据库可能会导致无效状态或资源冲突。

为了解决这些问题,在 Swift 5.9 中引入了非复制类型。通过将类型标记为~Copyable 来实现,我们可以显式地阻止 Swift 复制它。

这保证了值的唯一所有权,并施加了更严格的约束,从而降低了出错的风险。接下来让我们详细了解一下非复制类型。

非复制类型的示例

以下是一个非复制类型的简单示例:

struct SingleUseTicket: ~Copyable {
    let ticketIDString
}

与常规值类型的行为不同,当我们将非复制类型的实例分配给新变量时,值会被移动而不是复制。如果我们尝试在稍后使用原始变量,会得到编译时错误:

let originalTicket = SingleUseTicket(ticketID: "S645")
let newTicket = originalTicket

print(originalTicket.ticketID) // 报错 'originalTicket' used after consume

需要注意的是,类不能被声明为非复制类型。所有类类型仍然是可复制的,通过保留和释放对对象的引用来实现。

非复制类型中的方法

在非复制类型中,方法可以读取、修改或消费self

借用方法

非复制类型中的方法默认是借用borrowing 的。这意味着它们只能读取实例,允许安全地检查实例而不影响其有效性。

struct SingleUseTicket: ~Copyable {
    let ticketID: String
    
    func describe() {
        print("This ticket is \(ticketID).")
    }
}

let ticket = SingleUseTicket(ticketID: "A123")

// 打印 `This ticket is A123.`
ticket.describe()

可变方法

可变方法mutating 提供了对self 的临时写访问,允许在不使实例无效的情况下进行修改。

struct SingleUseTicket: ~Copyable {
    var ticketID: String

    mutating func updateID(newID: String) {
        ticketID = newID
        print("Ticket ID updated to \(ticketID).")
    }
}

var ticket = SingleUseTicket(ticketID: "A123")

// 打印 `Ticket ID updated to B456.`
ticket.updateID(newID: "B456")

消费方法

消费方法consuming 接管self 的所有权,一旦方法完成就使实例无效。这对于完成或处置资源的任务非常有用。在调用方法后,任何尝试访问实例的操作都会导致编译错误。

struct SingleUseTicket: ~Copyable {
    let ticketID: String
    
    consuming func use() {
        print("Ticket \(ticketID) used.")
    }
}

func useTicket() {
    let ticket = SingleUseTicket(ticketID: "A123")
    ticket.use()
    
    ticket.use() // 报错 'ticket' consumed more than once
}

useTicket()

需要注意的是,我们不能消费存储在全局变量中的非复制类型,因此在我们的示例中我们将代码包装在useTicket() 函数中。

非复制类型在函数参数中的应用

当将非复制类型作为参数传递给函数时,Swift 要求我们为该函数指定所有权模型。我们可以将参数标记为借用borrowing、输入输出inout 或消费consuming,每种标记提供不同级别的访问权限,类似于类型内部的方法。

借用参数

借用所有权允许函数临时读取值,而不消耗或修改它。

func inspectTicket(_ ticket: borrowing SingleUseTicket) {
    print("Inspecting ticket \(ticket.ticketID).")
}

输入输出参数

输入输出参数inout 提供了对值的临时写访问,允许函数修改它,同时将所有权返回给调用者。

func updateTicketID(_ ticketinout SingleUseTicket, to newID: String) {
    ticket.ticketID = newID
    print("Ticket ID updated to \(ticket.ticketID).")
}

消费参数

当一个参数被标记为消费时,函数完全接管该值的所有权,使其对于调用者无效。例如,如果我们有一个消费方法,我们可以在函数中使用它,而无需担心在函数外部使用该值。

func processTicket(_ ticket: consuming SingleUseTicket) {
    ticket.use()
}

析构函数和丢弃操作符

非复制结构体和枚举可以像类一样拥有析构函数deinit,它们会在实例生命周期结束时自动运行。

struct SingleUseTicket: ~Copyable {
    let ticketID: Int
    
    deinit {
        print("Ticket deinitialized.")
        
        // 清理逻辑
    }
}

然而,当一个消费方法和一个析构函数都执行清理时,可能会有冗余操作的风险。为了解决这个问题,Swift 引入了丢弃操作符discard

通过在消费方法中使用discard self,我们可以显式阻止调用析构函数,从而避免重复逻辑:

struct SingleUseTicket: ~Copyable {
    let ticketID: Int
    
    consuming func invalidate() {
        print("Ticket \(ticketID) invalidated.")
        
        // 清理逻辑
        
        discard self
    }
    
    deinit {
        print("Ticket deinitialized.")
        
        // 清理逻辑
    }
}

另外需要注意的是,只有当我们的类型包含可轻松销毁的存储属性时,才能使用discard。不能包含引用计数、泛型。

总结

最近几年,swift 出了很多新特性,非复制类型是其中之一,实际开发中,非复制类型很少用到,但是了解这些特性,可以让我们在开发中更加得心应手。随着 Swift 的不断发展,这些类型代表了语言在性能和正确性方面的重大进步。

但是这些越来越复杂的特性也让 swift 初学者望而却步,希望这篇文章能帮助大家了解非复制类型,在实际开发中,如果需要使用非复制类型,可以参考这篇文章。

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

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

iOS Block

作者 无数山
2025年7月11日 16:07
  1. block 本质上是是一个oc对象,内部也有isa指针。这个对象内部封装了函数调用地址以及函数调用环境(参数参数、返回值、捕获的外部变量)

  2.  int age = 20;
     void (^block)(void) = ^{
     nslog(@"age is %d",age)
     }
     
     struct __main_block_impl_0 {
     struct __block_impl impl;
     struct __main_block_desc_9 *desc
     int age
     }
    
  3.  int c = 1000; // 全局变量
     static int d = 10000; // 静态全局变量
     
     int main(int argc, const char * argv[]) {
         @autoreleasepool {
     
             int a = 10; // 局部变量
             static int b = 100; // 静态局部变量
             void (^block)(void) = ^{
                  NSLog(@"a = %d",a);
                  NSLog(@"b = %d",b);
                  NSLog(@"c = %d",c);
                  NSLog(@"d = %d",d);
              };
              a = 20;
              b = 200;
              c = 2000;
              d = 20000;
              block();
         }
         return 0;
     }
     
     // ***************打印结果***************
     2020-01-07 15:08:37.541840+0800 CommandLine[70672:7611766] a = 10
     2020-01-07 15:08:37.542168+0800 CommandLine[70672:7611766] b = 200
     2020-01-07 15:08:37.542201+0800 CommandLine[70672:7611766] c = 2000
     2020-01-07 15:08:37.542222+0800 CommandLine[70672:7611766] d = 20000
    
  4. 全局变量不会捕获,直接访问

  5. 静态局部变量,捕获的是变量的地址,所以在block外面修改值以后,也会改变

  6. 普通变量,会直接捕获。外面在修改值,block内部是新生成了一个变量,不改变值

  7. _NSGlobalBlock_如果一个block里面没有访问普通局部变量,也就是没有捕获任何值,就是这种global类型,存在数据区。继承链:_nsgloableblock_ :nsblock:nsobject

  8. 如果一个block里面访问了普通局部变量,那他就是一个_nsstackblock_,他在内存中存放在栈区,特点是其释放不受开发者控制,都是系统操作,如果对他惊醒了copy,就会把这个block复制到堆上。

昨天 — 2025年7月11日iOS

如何在 visionOS 上使用 MPS 和 CIFilter 实现特殊视觉效果

2025年7月11日 09:58

说明

在 visionOS 开发中,视觉效果一直都是开发的一个难点。尽管苹果推出了 ShaderGraph 来简化 Shader 的开发,在此基础上我开源了 RealityShaderExtension 框架来帮助降低 Shader 开发的门槛,但在实际开发中,我们仍然面临两个问题:

  • 数学与几何知识要求太高,难以开发出满意的效果
  • 某些效果如 高斯模糊GaussianBlur直方图Histogram 单纯依靠 ShaderGraph 难以编写的,且运行效率不佳

image.png

UnityMaterial.gif

苹果针对 ShaderGraph 功能不够强大的弱点,给出的解决方案是:使用 LowLevelTexture + Compute Shader 更加灵活的实现各种算法功能,然而手写 Metal Compute Shader 代码依然是非常困难的。

不过,苹果有一个已经高度优化的 Compute Shader 框架:Metal Performance Shaders ,我们可以直接与 LowLevelTexture 一起使用。

同时,经过研究,在 UIKit 中常用的 CIFilter 图片处理框架,也是可以与 LowLevelTexture 一起使用的,这样就无需再手动编写各种算法代码了。

同时,不仅是图片可以处理,视频也可以继续使用 AVPlayer 播放的同时,添加 MPS/CIFilter 进行处理。

图片处理

对图片处理时,MPS 和 CIFilter 的基本步骤是一样的:

  • 处理流程: MPS/CIFilter -> LowLevelTexture -> TextureResource -> UnlitMaterial

Image(MPS)

使用 MPS 进行处理时:

  • 只需要通过 commandBufferLowLevelTesxture 中获取目标纹理 outTexture
  • 将源纹理和目标纹理传递给 MPS filter 即可。

关键代码如下:

func populateMPS(inTexture: MTLTexture, lowLevelTexture: LowLevelTexture, device: MTLDevice) {

    // Set up the Metal command queue and compute command encoder,
    .....

    // Create a MPS filter.
    let blur = MPSImageGaussianBlur(device: device, sigma: model.blurRadius)

    // set input output
    let outTexture = lowLevelTexture.replace(using: commandBuffer)
    blur.encode(commandBuffer: commandBuffer, sourceTexture: inTexture, destinationTexture: outTexture)

    
    // The usual Metal enqueue process.
    .....
}

ScreenRecording_06-28-2025 16-38-15_1.2025-07-02 14_31_36.gif

Image(CIFilter)

使用 CIFilter 进行处理时:

  • 需要根据 outTexturecommandBuffer 创建一个 CIRenderDestination
  • [可选] 为了更好与 Metal 协作,最好创建一个 GPU-Based CIContext
  • [可选] 如果遇到颜色空间显示不正确,可以设置 options 中 .workingColorSpace 为 sRGB 等。
  • 最后调用 ciContext.startTask 将处理后的图片写入 CIRenderDestination 中。

关键代码如下:

let blur = CIFilter(name: "CIGaussianBlur")

func populateCIFilter(inTexture: MTLTexture, lowLevelTexture: LowLevelTexture, device: MTLDevice) {

    // Set up the Metal command queue and compute command encoder,
    .......
    
    // Set the CIFilter inputs
    blur?.setValue(CIImage(mtlTexture: inTexture), forKey: kCIInputImageKey)
    blur?.setValue(model.blurRadius, forKey: kCIInputRadiusKey)

    // set input output
    let outTexture = lowLevelTexture.replace(using: commandBuffer)
    let render = CIRenderDestination(mtlTexture: outTexture, commandBuffer: commandBuffer)

    // Create a Context for GPU-Based Rendering
    let ciContext = CIContext(mtlCommandQueue: commandQueue,options: [.cacheIntermediates: false, .workingColorSpace: CGColorSpace(name: CGColorSpace.sRGB)!])

    if let outImage = blur?.outputImage {
        do {
            try ciContext.startTask(toRender: outImage, to: render)
        } catch  {
            print(error)
        }
    }

    // The usual Metal enqueue process.
    ......
}

ScreenRecording_06-28-2025 16-38-15_1.2025-07-02 14_32_15.gif

视频处理

视频处理要稍微复杂一些,需要创建 AVMutableVideoComposition 来从 AVPlayer 中获取视频帧信息再进行处理,处理后的视频继续在 AVPlayer 中直接播放,也可以另外导出到 LowLevelTexture 中进行显示。

注意:视频处理在老版本的(即 Xcode 16.4 中原始的) Vision Pro 模拟器中不能正常工作,在新的模拟器“Apple Vision Pro 4K” 中 使用 CIFilter 处理后的颜色显示不正确。不过在真机测试中,都是正常的。

Video(CIFilter)

  • 处理流程:[ CIFilter + AVMutableVideoComposition + AVPlayerItem ] -> VideoMaterial

好消息是,苹果针对 CIFilter 有一个简单方案:

  • 在创建 AVMutableVideoComposition 时创建一个闭包
  • 在闭包中通过 AVAsynchronousCIImageFilteringRequest 获取适合 CIFilter 处理的视频帧数据
  • 源视频数据直接传给 CIFilter 处理后,重新写入 AVAsynchronousCIImageFilteringRequest 即可播放出模糊后的视频。
let asset: AVURLAsset....


let playerItem = AVPlayerItem(asset: asset)

let composition = try await AVMutableVideoComposition.videoComposition(with: asset) { request in
    populateCIFilter(request: request)
}
playerItem.videoComposition = composition


// Create a material that uses the VideoMaterial
let player = AVPlayer(playerItem: playerItem)
let videoMaterial = VideoMaterial(avPlayer: player)

真正的处理代码也非常简单,将 CIFilter 的输出重新写入到 request 中即可:

let ciFilter = CIFilter(name: "CIGaussianBlur")

func populateCIFilter(request: AVAsynchronousCIImageFilteringRequest) {
    let source = request.sourceImage
    ciFilter?.setValue(source, forKey: kCIInputImageKey)
    ciFilter?.setValue(model.blurRadius, forKey: kCIInputRadiusKey)

    if let output = ciFilter?.outputImage {
        request.finish(with: output, context: ciContext)
    } else {
        request.finish(with: FilterError.failedToProduceOutputImage)
    }
}

ScreenRecording_06-28-2025 16-38-15_1.2025-07-02 14_27_20.gif

Video(MPS)

  • 处理流程: [ MPS + AVMutableVideoComposition + AVPlayerItem ] -> LowLevelTexture -> TextureResource -> UnlitMaterial

通过 MPS 来处理视频要更加复杂一些:

  • 我们需要自定义一个 customVideoCompositorClass ,赋值给 AVMutableVideoComposition
  • 实现它的协议方法,指定输入和输出的像素格式
  • startRequest() 中获取视频帧并转换为 MTLTexture ,由 MPS 进行处理
  • [可选] 将源视频写入回去,这样就能在 AVPlayer 中继续播放源视频

自定义一个 SampleCustomCompositor,并赋值给 composition.customVideoCompositorClass

let composition = try await AVMutableVideoComposition.videoComposition(withPropertiesOf: asset)
composition.customVideoCompositorClass = SampleCustomCompositor.self

let playerItem = AVPlayerItem(asset: asset)
playerItem.videoComposition = composition

SampleCustomCompositor 需要指定我们需要的视频帧像素格式,然后就可以在 startRequest() 中获取到对应格式的视频帧,进行模糊处理。

class SampleCustomCompositor: NSObject, AVVideoCompositing {
    .....
    // 指定我们需要的视频帧格式。一定要设置 kCVPixelBufferMetalCompatibilityKey,否则与 Metal 会出现兼容性问题,导致黑屏等
    var sourcePixelBufferAttributes: [String: any Sendable]? = [
        String(kCVPixelBufferPixelFormatTypeKey): [kCVPixelFormatType_32BGRA],
        String(kCVPixelBufferMetalCompatibilityKey): true // Critical! 非常重要
    ]
    // 我们处理后返回的视频帧格式
    var requiredPixelBufferAttributesForRenderContext: [String: any Sendable] = [
        String(kCVPixelBufferPixelFormatTypeKey):[kCVPixelFormatType_32BGRA],
        String(kCVPixelBufferMetalCompatibilityKey): true
    ]

    ....


    func startRequest(_ request: AVAsynchronousVideoCompositionRequest) {

        .....

        let requiredTrackIDs = request.videoCompositionInstruction.requiredSourceTrackIDs
        let sourceID = requiredTrackIDs[0]
        let sourceBuffer = request.sourceFrame(byTrackID: sourceID.value(of: Int32.self)!)!

       
        Task {@MainActor in
            // 将模糊后的视频输出到 LowLevelTexture 中
            populateMPS(sourceBuffer: sourceBuffer, lowLevelTexture: SampleCustomCompositor.llt!, device: SampleCustomCompositor.mtlDevice!)
        }
        // 保持原视频继续输出
        request.finish(withComposedVideoFrame: sourceBuffer)
    }


    @MainActor func populateMPS(sourceBuffer: CVPixelBuffer, lowLevelTexture: LowLevelTexture, device: MTLDevice) {

        .....

        // Now sourceBuffer should already be in BGRA format, create Metal texture directly
        var mtlTextureCache: CVMetalTextureCache? = nil
        CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &mtlTextureCache)

        let width = CVPixelBufferGetWidth(sourceBuffer)
        let height = CVPixelBufferGetHeight(sourceBuffer)
        var cvTexture: CVMetalTexture?
        let result = CVMetalTextureCacheCreateTextureFromImage(
            kCFAllocatorDefault,
            mtlTextureCache!,
            sourceBuffer,
            nil,
            .bgra8Unorm,
            width,
            height,
            0,
            &cvTexture
        )
        let bgraTexture = CVMetalTextureGetTexture(cvTexture)
  
        // Create a MPS filter with dynamic blur radius
        let blur = MPSImageGaussianBlur(device: device, sigma: Self.blurRadius)
 
        // set input output
        let outTexture = lowLevelTexture.replace(using: commandBuffer)
        blur.encode(commandBuffer: commandBuffer, sourceTexture: bgraTexture, destinationTexture: outTexture)

        // The usual Metal enqueue process.
        ....
    }
}

使用 customVideoCompositorClass + MPS ,可以在 AVPlayer 输出源视频(下图左)的同时,在 LowLevelTexture 中输出模糊后的视频(下图右): ScreenRecording_06-28-2025 16-38-15_1.2025-07-02 14_26_31.gif

参考

项目完整示例:github.com/XanderXu/MP…

参考资料:

Swift 6.2:江湖再掀惊涛浪,新功出世震四方

2025年7月11日 09:38

在这里插入图片描述

概述

江湖代有才人出,各领风骚数百年。

自 Swift 语言横空出世,便在 iOS 开发的武林中搅动风云。如今 WWDC 25 之上,Apple 闭门三年炼就的《Swift 6.2 真经》终见天日,书中所载新功个个精妙绝伦,足以让开发者们的代码功力更上一层楼。

在这里插入图片描述

在本篇武林秘闻中,各位少侠将领悟到如下奥义:

  1. 并发迷局终得解:"nonisolated (nonsending)" 与 "defaultIsolation" 双剑合璧 1.1 第一式:"nonisolated (nonsending)" 调和阴阳 1.2 第二式:"defaultIsolation" 定鼎乾坤
  2. "@concurrent" 破界令牌:独辟蹊径的旁门绝学
  3. 内存管理新心法:InlineArray 与 Span 的 "缩骨功" 3.1 InlineArray:栈上藏兵,招之即来 3.2 Span:内存视图,隔岸观火
  4. 严格内存安全的 “金钟罩”:让内存错误无处遁形
  5. 语言互操新经脉:Swift 与 C++ "打通任督二脉"
  6. 与 Java 的跨界合作
  7. 结语:新功在手,江湖我有

今日便由老夫为诸位少侠拆解其中奥秘,且看这新功如何改写江湖格局。 Let's go!!!;)


1. 并发迷局终得解:"nonisolated (nonsending)" 与 "defaultIsolation" 双剑合璧

往昔江湖,并发编程堪称开发者的 "鬼门关"。

多少英雄好汉在此折戟沉沙 —— 同步函数与异步函数如同正邪两道,运行规则大相径庭;主 Actor 调用时更是冲突不断,轻则编译器怒目相向,重则数据走火入魔,当真令人闻风丧胆。

Swift 6.2 携 "nonisolated (nonsending)" 与 "defaultIsolation" 两大神功而来,恰似倚天屠龙双剑合璧,专破这经脉错乱之症。

1.1 第一式:"nonisolated (nonsending)" 调和阴阳

此功专为理顺函数调用的 "阴阳二气" 所创。

标注此功的函数,既能保持独立姿态(nonisolated),又不会随意发送数据(nonsending),恰似一位守礼的侠客,既不依附门派,又不轻易出手伤人:

// 旧制:异步函数常因隔离问题"走火入魔"
actor DataManager {
    var value: Int = 0
    
    // 欲调用此函数,需先过编译器"三关"
    func fetchData() async -> Int {
        return value
    }
}

// 新功:nonisolated (nonsending) 让函数"独善其身"
actor DataManager {
    var value: Int = 0
    
    nonisolated(nonsending) func fetchData() async -> Int {
        // 可安全访问非隔离数据,或执行独立逻辑
        return 42
    }
}

这般写法,如同给函数戴上 "君子剑",既保持独立风骨,又不伤邻里(其它 Actor),实乃解决隔离冲突之必备良药。

1.2 第二式:"defaultIsolation" 定鼎乾坤

此功堪称主 Actor 门派的 "盟主令"—— 可在包级别(package)定下规矩:凡未明确 "叛逃" 的代码,皆默认归入主 Actor 麾下

这便如武林盟主昭告天下:"未投他派者,皆听我号令",瞬间省去无数手动标注的繁琐:

// 在Package.swift中启用盟主令
swiftSettings: [
    .defaultIsolation(MainActor.self), // 立主Actor为盟主
    .enableExperimentalFeature("NonisolatedNonsendingByDefault")
]

// 此后代码自动归入主Actor,无需再画蛇添足
class ViewModel {
    func updateUI() async {
        // 默认为主Actor内运行,可安心操作UI
        print("UI更新无虞")
    }
}

有了这盟主令,UI 相关代码自归其位,后台任务也能各安其职,当真 "物各有主,井然有序"。

在这里插入图片描述

2. "@concurrent" 破界令牌:独辟蹊径的旁门绝学

江湖之大,总有需要 "特立独行" 之时。

Swift 6.2 推出的 "@concurrent" 令牌,恰如一张 "通关文牒",持此令牌者,可脱离调用者的经脉,另辟新的隔离语境,堪称 "破界而行" 的旁门绝学。

此令牌虽威力无穷,却有铁律约束:仅能授予 "nonisolated" 函数

若将其用于 Actor 门派的招式,除非该招式已明确 "脱离门派"(标注 nonisolated),否则便是 "违规练功",日后必遭编译器反噬:

// 正道:nonisolated函数持令牌,名正言顺
actor NetworkClient {
    @concurrent
    nonisolated func fetchImage() async throws -> UIImage {
        // 脱离NetworkClient的隔离,另起炉灶
        let data = try await URLSession.shared.data(from: url)
        return UIImage(data: data.0)!
    }
}

// 禁忌:未脱离门派却持令牌,必遭反噬
actor NetworkClient {
    @concurrent // 编译器怒喝:"此等叛逆,当诛!"
    func fetchImage() async throws -> UIImage {
        // 此乃禁忌招式,万万不可学
    }
}

这令牌的妙用在于:当你需要一个 "临时工"(独立隔离的函数),又不想让它沾染主 Actor 的 "门派气息" 时,只需授予此令,便能让其 "独来独往,自成一派"。

3. 内存管理新心法:InlineArray 与 Span 的 "缩骨功"

内存管理向来是秃头少侠们的 "内功根基",根基不牢地动山摇,招式再花也难成大器。

Swift 6.2 推出的 InlineArray 与 Span,恰似两套 "缩骨功",能将内存占用压缩到极致,运行速度却如离弦之箭。

在这里插入图片描述

3.1 InlineArray:栈上藏兵,招之即来

寻常数组(Array)如同 "堆上营寨",虽容量可观,却需耗费时间搭建(堆内存分配)。

InlineArray 则是 "栈上藏兵",固定大小,随用随取,省去了营寨搭建的功夫:

// 声明一个可容纳5名"士兵"(Int)的栈上营寨
var inlineArray: InlineArray<Int, 5> = [1, 2, 3, 4, 5]

// 直接取用,无需等待营寨搭建
inlineArray[2] = 100
print(inlineArray) // [1, 2, 100, 4, 5]

此功最适合 "小股特种暗杀部队"(固定大小、数量不多的数据),如游戏中的坐标点、传感器的实时数据等,调用时快如闪电,绝不拖泥带水。

3.2 Span:内存视图,隔岸观火

Span 堪称 "内存望远镜"—— 它不持有内存,仅提供一片连续内存的 "视图",既能安全访问,又不占用额外空间,恰似隔岸观火,知全局而不添柴也:

let buffer: [UInt8] = [0x01, 0x02, 0x03, 0x04]

// 用望远镜观察内存,从索引1开始,看3个元素
let span = buffer[1..<4].withSpan { $0 }
print(span.count) // 3
print(span[0]) // 0x02

在解析二进制数据(如网络协议、文件格式)时,Span 能让你 "按图索骥",无需复制数据即可精准操作,实乃 "事半功倍" 之法。

4. 严格内存安全的 “金钟罩”:让内存错误无处遁形

内存安全问题一直是 iOS 开发中的一个 “心腹大患”,稍有不慎就可能导致程序崩溃、数据丢失等严重后果。

Swift 6.2 引入了严格内存安全特性,就像是为程序穿上了一层坚固的 “金钟罩”,能够有效地抵御各种内存错误的侵袭。

在以往的开发中,指针操作、内存分配与释放等操作常常隐藏着许多危险,少侠们需要花费大量的精力去确保内存的正确使用。而现在,启用严格内存安全特性后,编译器会对代码进行更加严格的检查,一旦发现潜在的内存安全问题,就会及时发出警告。

在这里插入图片描述

例如,在 Xcode 中,我们可以在项目的构建设置中将 “Strict Memory Safety” 设置为 “yes” 来启用这一特性。重新构建后,编译器会仔细检查代码中的每一处内存操作,如是否存在悬空指针、是否有内存泄漏等问题。

在这里插入图片描述

如果发现问题,编译器会给出详细的错误提示,帮助微秃少侠们及时修复,就像在江湖中,有了一位明察秋毫的武林前辈时刻提醒我们招式中的破绽,让我们能够及时修正,避免陷入东方不败的危险境地。

5. 语言互操新经脉:Swift 与 C++ "打通任督二脉"

江湖之中,门派林立,Swift 与 C++ 便如两大武学世家,各有精妙却隔阂甚深。

Swift 6.2 新修的 "互操经脉",终于让两派高手得以 "切磋武艺,互通有无"。

在这里插入图片描述

如今在 Swift 中调用 C++ 代码,恰似 "少林高僧学武当太极",招式转换自然流畅:

// C++中的"铁砂掌"函数
// int strike(int strength, int times);

// Swift中直接施展,无需翻译
import CppMartialArts

let damage = strike(100, 3) // 调用C++函数,如探囊取物
print("造成伤害:\(damage)")

更妙的是,C++ 的类也能在 Swift 中 "返璞归真",仿佛戴上 "易容面具",外观是 Swift 类,内里却是 C++ 的筋骨:

// C++的"Sword"类在Swift中可用
let mySword = CppSword(length: 1.2)
mySword.sharpen() // 调用C++方法
let damage = mySword.cut(target: "enemy")

这般互通,恰似武林大会上各派高手同台竞技,取长补短,当真 "海纳百川,有容乃大"。

6. 与 Java 的跨界合作

Swift 6.2 还为与 Java 的互操作性提供了更好的支持。

在这里插入图片描述

在一些跨平台开发场景中,Swift 与 Java 的交互需求日益增长。现在,Swift 6.2 使得 Swift 代码与 Java 代码之间的通信和协作变得更加容易,仿佛在两个不同的武林世界之间搭建了一座坚固的桥梁。

例如,在某些需要与 Java 后端服务进行交互的 iOS 应用中,Swift 6.2 的新特性可以帮助开发者更高效地实现数据传输和功能调用,大大提升了开发效率,让微秃少侠们能够在不同语言的 “江湖” 中自由穿梭,实现更强大的应用功能,并且希望少掉几根头发。

7. 结语:新功在手,江湖我有

Swift 6.2 的诸位新功,或解并发之困,或强内存之基,或通语言之隔,恰如为开发者打通了 "任督二脉",从此代码之路再无阻塞。

江湖路远,挑战常新,但只要手握这些新功秘籍,便能 "运筹帷幄之中,决胜千里之外"。诸位少侠,何不即刻闭关修炼,待功成之日,便是横行代码江湖之时!

在这里插入图片描述

记住,真正的高手,从不困于招式,而是善用利器。Swift 6.2 这柄神兵已交你手,接下来,便看你如何在开发的江湖中,写下属于自己的传奇!

感谢各位秃头少侠们的观赏,我们青山不改、绿水长流,江湖再见、后会有期!8-)

苹果内购IAP(一) Transaction 数据含义

作者 小亲亲亲0
2025年7月10日 20:13

以下是你提供的 StoreKit 2 Transaction JSON 各字段含义解析:

字段 类型 含义
transactionId String 本次交易的唯一标识符(Apple 服务器生成)。每次用户购买或续订时都不同。
originalTransactionId String 原始交易 ID。对于自动续订订阅,首次购买时生成,后续续订会复用该 ID,用来关联同一订阅链。
webOrderLineItemId String 用于 App Store 后端报表的行项目 ID,可用于跨平台(如 Web、iOS)或者后台对账。
bundleId String App 的 Bundle Identifier,表示是哪一个应用发起了这笔交易。
productId String 购买的内购商品标识符(Product Identifier),即你在 App Store Connect 中配置的 ID。
subscriptionGroupIdentifier String 订阅组 ID,属于同一组的订阅产品互斥,同组内同一用户只能激活一个订阅方案。
purchaseDate Number 本次交易的购买时间,Unix 毫秒数(UTC)。例如 1752148768000 表示 2025‑12‑10 08:19:28 UTC。
originalPurchaseDate Number 原始购买时间,Unix 毫秒数。对于续订,将是首次购买的时间。
expiresDate Number 订阅或试用期的到期时间,Unix 毫秒数。到期后如果未续订,就视为订阅结束。
quantity Int 购买的数量。对于订阅通常为 1,消费型商品可能大于 1。
type String 内购类型,此处 "Auto-Renewable Subscription" 表示自动续订订阅;其他可能值还有 "Non-Consumable""Consumable""Non-Renewing Subscription"
deviceVerification String Base64 编码的设备验证令牌,用于防篡改校验,可发送到服务器并与 Apple 校验。
deviceVerificationNonce String 随机生成的唯一值,与 deviceVerification 配合使用,保证验证请求的唯一性。
inAppOwnershipType String 购买归属类型:
"PURCHASED":用户已购买
"FAMILY_SHARED":通过家庭共享获得
"UNKNOWN":未知或无权访问
signedDate Number 交易数据在设备上签名的时间,Unix 毫秒数,用于校验数据新鲜度。
environment String 交易环境:
"Sandbox":沙盒测试
"Production":正式环境
transactionReason String 触发此次交易的原因:
"PURCHASE":用户主动购买
"RENEWAL":自动续订
"INTERACTIVE_RENEWAL":用户在订阅管理界面手动续订等
storefront String App Store 区域码(ISO 国家/地区简码),如 "CHN" 表示中国大陆。
storefrontId String App Store 地区标识符,用于更精细的区域分发。
price Int 以最小货币单位表示的价格。例如 59000 表示 ¥590.00(CNY)。
currency String 货币代码,如 "CNY""USD"
appTransactionId String App 级别的交易 ID,可用于客户端内部追踪。同一个 transactionId,可能在不同平台(iOS/macOS)下有不同 appTransactionId

💡 小贴士

  • 时间字段 (purchaseDateexpiresDate 等) 都是以毫秒为单位的 Unix 时间戳,需根据时区转换展示给用户。
  • 服务器校验:通常你会将 deviceVerification + deviceVerificationNonce 或整个 JSON 发送到后台,由后台调用 Apple 验证接口进行二次校验,确保证据未被篡改。
  • 续订关联originalTransactionId 贯穿同一次订阅链,方便后台做续订统计和账单归因。

以上字段含义能帮助你在客户端和后台实现精确的订阅状态管理与数据对账。 🚀

type 字段一般都有什么样是值

type 字段对应了内购项目的种类,在 StoreKit 2 的 Transaction JSON 中,你常见到以下几种值:

  • Consumable
    消耗型商品,一次购买后即“用完”,可重复购买(如游戏内金币、道具)。
  • Non-Consumable
    非消耗型商品,一次购买永久有效(如解锁专业版功能、永久皮肤)。
  • Non-Renewing Subscription
    非自动续期订阅,到期后需要用户手动续订(如半年课程访问权限)。
  • Auto-Renewable Subscription
    自动续期订阅,到期后由系统自动续费(如月度/年度会员)。

transactionReason 一般都有什么值,含义什么

transactionReason 表示触发此次交易的原因,在 StoreKit 2 的 Transaction JSON 中,常见的取值有三种:

  • PURCHASE
    用户主动发起的首次购买或在 App 内直接购买订阅时产生的交易。Apple Developer
  • RENEWAL
    自动续订订阅到期后,由系统在后台自动发起的续订交易。Apple Developer
  • INTERACTIVE_RENEWAL
    用户在 App Store 的订阅设置界面(或通过 App 内提供的入口)主动续订已过期或已取消的订阅时产生的交易。Stack Overflow

📌 小贴士

  • 这三种原因涵盖了 用户购买系统自动续订用户手动续订 的主要场景。
  • 当你在 Transaction.updates 或者通过 App Store Server API 查询到交易 JSON 时,可以根据 transactionReason 字段判断当前是首次购买、自动续订,还是交互式续订,以便做不同的业务逻辑处理(如提示用户“已续订”或“请手动续订”)。

WWDC 25 风云再起:SwiftUI 7 Charts 心法从 2D 到 3D 的华丽蜕变

2025年7月10日 21:41

在这里插入图片描述

概述

在 iOS 开发这个波谲云诡的江湖中,SwiftUI 可谓是一位后起之秀,以其简洁明快的招式迅速在 UI 框架领域中崭露头角。

在这里插入图片描述

而其中的 Charts 框架,更是如同江湖中的 “数据可视化宝典”那样,让各位秃头少侠们能够轻松将复杂的数据转化为直观易懂的图表。

在本篇武林秘籍中,列位少侠将会领悟如下招式:

    1. 江湖起源——原有 Charts 框架的武学修炼
    1. 江湖变革——SwiftUI Charts 3D 图表来袭
    1. 轻功飞行——SurfacePlot 打造梦幻曲面图
    1. 握剑诀——交互与视角
    1. 3D 图表修炼的注意事项
    • 5.1 避免数据过载
    • 5.2 谨慎选择图表类型
    • 5.3 性能优化不容忽视
    1. 尾声:3D 图表开启数据可视化新纪元

那还等什么呢?各位初入江湖的豪客和“大虾”们,请随老夫一起进入图表的美妙世界吧!Let's go!!!;)


1. 江湖起源——原有 Charts 框架的武学修炼

何曾几时,WWDC 2020(SwiftUI 4.0,iOS 16)江湖风起云涌,数不清的英雄豪杰和兵器纷繁复杂,数据可视化在其中占据了举足轻重的地位。

而正是在这样的背景下,SwiftUI 的 Charts 框架横空出世,犹如一位初出茅庐的少年剑客,虽然招式简单,却也灵活迅捷,迅速得到了武林各大门派的认可。

在这里插入图片描述

初入江湖的菜鸟侠客们可以通过它迅速绘制出柱状图、折线图和饼状图等基础武学,简单易懂,入门容易。记得那年,当 18 岁的列位秃头少侠们在代码中添加 Chart 时,心中便涌起一股非凡的成就感,仿佛宝子们也成为了数据江湖中的一员。

如下便是一个简单的柱状图的代码示例,犹如初入武林的少年,凭借一把利剑便能出奇制胜:

import SwiftUI
import Charts

struct SimpleBarChart: View {
    let data: [Double] = [2, 3, 5, 7, 11, 13]

    var body: some View {
        Chart(data) { value in
            BarMark(x: .value("Value", value))
        }
        .chartXAxis {
            AxisMarks()
        }
    }
}

这就是“基本剑法”,以简洁利落见长,正如初出茅庐的剑客,刚刚踏入这个江湖那样的飘逸洒脱:

在这里插入图片描述

随着修炼的深入,少侠们会逐渐意识到,这种图表只能为大家在江湖中打下些许基础,但它也暴露出了一些不足。

在这里插入图片描述

虽然二十步之内,便可知敌人风吹草动,然而一旦对手修炼到更高的境界,单靠这种平凡的武学便显得不足以应对复杂的数据纷争。

2. 江湖变革——SwiftUI Charts 3D 图表来袭

谁能想象,江湖中的一场风云变幻,竟会让 Charts 框架焕发新生。

在WWDC 25 上,苹果总舵主为这门熟悉的武功奥义又注入了新的活力,推出了 Chart3D。这不再是寻常的剑法,而是进入了三维的殿堂。就像一位绝世大侠,早已超越了平面世界的束缚,开始在三维空间中闪展腾挪。

在这里插入图片描述

这时的 Chart3D,犹如一位有了深厚内力的高手,能在三维空间中挥洒自如。宝子们不再仅仅是直线和曲线的过客,而是能在空间中将数据点、曲面、视角交织成一幅立体图景。

无论是点的组合,还是面的铺展,或是交互的旋转,每一处都透露着数据与现实世界的紧密联系。

快看这段代码,仿佛是大侠抬手之间,便可将复杂的数据织入眼前的三维世界那样畅快淋漓:

struct ContentView: View {
    let data = [(2,5), (3,7), (5,11), (7,8), (11,20), (13,10)]

    var body: some View {
        NavigationStack {
            Chart3D(data.indices, id: \.self) { index in
                let item = data[index]
                PointMark(
                    x: .value("X", item.0),
                    y: .value("Y", item.1),
                    z: .value("Z", index)
                )
                .foregroundStyle(.red.gradient)
            }
            .chartXAxisLabel("X轴")
            .chartYAxisLabel("Y轴")
            .chartZAxisLabel("Z轴")
            .navigationTitle("Charts 心法展示")
        }
    }
}

此时,数据不仅停留在纸面上,而是跃然于三维空间之中,犹如剑客挥舞长剑,刺破苍穹:

在这里插入图片描述

现在,列为微秃小少侠们可以随心所欲地操控每一个数据点的位置,仿佛掌握了整个空间的节奏,就问你们赞不赞呢?

3. 轻功飞行——SurfacePlot 打造梦幻曲面图

在武林中,总有一些高手以轻盈如风的身法著称,他们步伐矫健,时隐时现。而 SurfacePlot 就如同这般,能够以平滑的曲面将两个变量之间的关系展现得淋漓尽致。

在这里插入图片描述

它能让小伙伴们不再拘泥于线条和点,而是化繁为简,把复杂的数据关系化作一张优美的曲面,轻盈地在三维空间中随意漂浮。

如果宝子们想描绘一条像“乾坤大挪移”般自由流畅的曲线,那便可以借助 SurfacePlot 来用数学函数得偿所愿。

下面的代码犹如一套行云流水的剑法,将数学的深奥与图形的简洁相结合,点滴之间尽显工艺之精妙:

import SwiftUI
import Charts

struct SurfacePlotChart: View {
    var body: some View {
        NavigationStack {
            Chart3D {
                SurfacePlot(x: "X", y: "Y", z: "Z") { x, z in
                    let h = hypot(x, z)
                    return sin(h) / h * 2
                }
                .foregroundStyle(.heightBased)
            }
            .chartXScale(domain: -10...10)
            .chartZScale(domain: -10...10)
            .chartYScale(domain: -0.23...10)
            .navigationTitle("Chart3D 心法传功")
        }
    }
}

这就像是武林中的一招“飞燕回旋”,优雅、流畅,同时又极具威力:

在这里插入图片描述

当各位微秃侠客们把数学函数转化为三维曲面,它就不再只是抽象的公式,而是化身为一场精彩的武林争斗,令人叹为观止。

4. 握剑诀——交互与视角

然而,江湖变幻莫测,如何在纷繁复杂的数据中保持清醒的视角,便成为了每个剑客面临的严峻挑战。

Chart3D 的强大之处不仅仅在于它能够描绘出三维的世界,更在于它提供了丰富的交互功能,能够根据需要调整视角,让宝子们从不同角度观看同一数据的图表,仿佛随时可以恣意改变剑法招式那般美妙。

在这里插入图片描述

以下代码示范了如何通过手势控制图表的旋转角度,并且设置初始的视角与相机投影,这种灵活性犹如大侠挥剑的自由度,让小伙伴们在数据的世界里遨游无碍:

struct ContentView: View {
    @State var data = [(Int,Int)]()
    
    private func createData() {
        for _ in 0...99 {
            let x = Int.random(in: 0...100)
            let y = Int.random(in: 0...100)
            data.append((x, y))
        }
    }
    
    @State private var pose = Chart3DPose(
        azimuth: .degrees(20),    // 水平角度
        inclination: .degrees(15) // 垂直角度
    )

    var body: some View {
        NavigationStack {
            Chart3D(data.indices, id: \.self) { index in
                let item = data[index]
                PointMark(
                    x: .value("X", item.0),
                    y: .value("Y", item.1),
                    z: .value("Z", index)
                )
                .foregroundStyle(.red.gradient)
            }
            .task {
                createData()
            }
            .chartXAxisLabel("X轴")
            .chartYAxisLabel("Y轴")
            .chartZAxisLabel("Z轴")
            .chart3DPose($pose)
            .navigationTitle("Charts 心法展示")
        }
    }
}

宝子们可以像大侠操控剑气那般,调整图表的角度与视野,每一次变化,都能带来全新的观感与体验:

在这里插入图片描述

同样我们略施小计,之前的曲面图也可以如法炮制:

在这里插入图片描述

从此,数据的世界也因此变得不再单调,而是充满了无限可能。

5. 3D 图表修炼的注意事项

5.1 避免数据过载

虽然 3D 图表能够展示丰富的数据信息,但在使用时也要注意避免数据过载。过多的数据点或过于复杂的数据维度,会让图表变得混乱不堪,就像武林高手在战斗中面对过多的敌人,反而会陷入困境。开发者需要对数据进行筛选和精简,突出重点,确保图表清晰可读性。

5.2 谨慎选择图表类型

不同的图表类型适用于不同的数据展示场景,在使用 3D 图表时,要根据数据的特点和分析目的,谨慎选择合适的图表样式。

例如,3D 柱状图适合用于对比数据,3D 散点图适合分析数据之间的关系,而 3D 饼图则不太适合这些场景,因为在三维空间中,饼图的角度和比例可能会让人产生视觉误解。

选择合适的图表类型,就像武林高手选择了合适的兵器,才能发挥出最大威力。

5.3 性能优化不容忽视

由于 3D 图表的渲染和计算量较大,容易对应用的性能产生影响。因此,在开发过程中,要注重性能优化。

可以采用异步加载数据、使用高效的数据结构和算法、合理利用缓存等方法,确保图表的加载和交互丝一般流畅顺滑,不给用户带来卡顿体验。否则,就像内力不足的武林高手,招式施展起来也会大打折扣。

6. 尾声:3D 图表开启数据可视化新纪元

从最初的 2D 图表到如今的 3D 图表,SwiftUI 7 的 Charts 框架在数据可视化的江湖中不断进化,为开发者们提供了越来越强大的工具。

3D 图表的出现,不仅让数据可视化变得更加生动、直观,也为武林高手们开辟了一片全新的江湖。

在这里,宝子们可以凭借自己的智慧和技艺,运用 3D 图表这一绝世神功,将数据的魅力展现得淋漓尽致,为用户带来前所未有的数据探索体验。

在这里插入图片描述

在未来的 iOS 开发江湖中,3D 图表必将成为开发者们手中的一把利器,助力他们在数据可视化领域中披荆斩棘,创造出更多令人惊叹的应用。而每一位开发者,都将在这个充满机遇与挑战的江湖中,书写属于自己的传奇故事。让我们怀揣着对技术的热爱和追求,勇敢地踏入这片新江湖,探索 3D 图表的无限未来吧!

此时,数据的江湖,已经不再是一个简单的平面,而是充满了三维空间的无限元宇宙,正如你们已然成为了这片江湖中举世无双的大侠一样,棒棒哒!

那么,感谢各位少侠的观赏!再会啦!8-)

Flutter与iOS混合开发交互

作者 Engandend
2025年7月10日 18:26

1、安装Flutter环境

1、下载SDK并安装

docs.flutter.cn/get-started…

2、 配置环境

如果 ~/.zshenv 文件存在,请在文本编辑器中打开 Zsh 环境变量文件 ~/.zshenv。如果不存在,请创建 ~/.zshenv
export PATH=$HOME/development/flutter/bin:$PATH加入到文件的最后面

创建Flutter项目

以Flutter为主

以Flutter为主:意思是直接创建完整的flutter项目,里面就已经包含了iOS、Android等工程。直接用即可

在需要的目录中 执行 flutter create aiflutter

配置

进入iOS文件夹

这里需要注意: 需要用到CocosPods将Flutter作为组件导入到项目,但是Flutter并没有直接生成Podfile文件。需要自己init一个

在进行 Podfile install时,会有警告。 如果想要去掉警告,需要按照以下方式修改。但是修改之后会运行不起来

image.png

正确的应该是选中Debug.xcconfig、Release.xcconfig

image.png

直接运行会报错:

Command PhaseScriptExecution failed with a nonzero exit code

这是由于Run Script的脚本找不到正确路径

需要修改 Podfile文件

具体如下:

source 'https://github.com/CocoaPods/Specs.git'

# Uncomment this line to define a global platform for your project

platform :ios, '13.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

def flutter_root
  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
  unless File.exist?(generated_xcode_build_settings_path)
    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
  end

  File.foreach(generated_xcode_build_settings_path) do |line|
    matches = line.match(/FLUTTER_ROOT\=(.*)/)
    return matches[1].strip if matches
  end
  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
  use_frameworks!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
  target 'RunnerTests' do
    inherit! :search_paths
  end
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
  end
end

在使用过程中, 因为iOS工程是其他人创建后给我的,在进行pod install的时候,出现了路径找不到的报错: 修改这个路径

image.png

此时如果直接运行,将会报错

Unable to load contents of file list: '/Target Support Files/Pods-Runner/Pods-Runner-frameworks-Debug-input-files.xcfilelist'

需要回到Flutter项目目录下,执行flutter run.
其实在执行flutter create flutterdemo完成的时候,就已经提示了

In order to run your application, type:
  $ cd test
  $ flutter run
Your application code is in test/lib/main.dart.

报错

如果报错: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.

Build Settings中搜索ENABLE_USER_SCRIPT_SANDBOXING 将其设置为NO,如果原本是NO,设置为YES后运行一次后再改为NO

错误处理
1、确保执行过flutter run
2、在Build Settings中搜索ENABLE_USER_SCRIPT_SANDBOXING 将其设置为NO
3、使用pod init新建一个podfile文件并修改里面的内容
4、PROJECT -> info -> Configurations中的Debug、Release 设置为对应的 Debug.xcconfig、Release.xcconfig
5、确认ios/Flutter路径下的Generated.xcconfig中的配置FLUTTER_ROOT、FLUTTER_APPLICATION_PATH是否正常

总结

1、安装FlutterSDK并配置其环境

2、使用命令创建Flutter项目flutter create flutterdemo

3、执行cd flutterdemo 和 flutter run命令

4、导入podfile文件并执行pod install命令

以iOS为主

以iOS为主意思是:手动创建一个iOS工程,将Flutter作为一个组件导入到iOS项目中

1、创建一个iOS工程AIIOSDemo,并进行pod
2、在同级目录下新建Flutter项目:flutter create -t module my_flutter\

image.png 3、在podfile中引入flutter

source 'https://github.com/CocoaPods/Specs.git'

platform :ios, '15.0'


# 1、在文件顶部添加 flutter_application_path
flutter_application_path = '../my_flutter'     #这里是刚才创建的flutter module名称
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')


target 'iOSDemo' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  pod 'SnapKit'
  
  pod 'LookinServer', :subspecs => ['Swift'], :configurations => ['Debug']
    
  
  // 2、引入路径
  install_all_flutter_pods(flutter_application_path)
  
end


# 3、添加这个 post_install 块
post_install do |installer|
  flutter_post_install(installer)
end

页面跳转

从原生跳转到Flutter页面 可参考链接

在使用Flutter之前,需要先注册GeneratedPluginRegistrant

//在AppDelegate中定义全局的flutterEngine
 lazy var flutterEngine: FlutterEngine = FlutterEngine(name: "com.brainco.gameEngine")


 private func initEngine() {
     // 在用到Flutter之前,要先注册这个方法
     //这个要在跳转方法之前运行环境,也可以在appdelegate里面启动就初始化,环境运行需要时间,单写在跳转方法里面靠前位置是不可以的。
     flutterEngine.run();
     GeneratedPluginRegistrant.register(with: flutterEngine);
 }
 
  • 直接以FlutterViewController为页面 在原生页面初始化按钮,并添加点击事件,在事件中实现以下代码:
   
    func jumpToFlutterPage() -> Void {
        
        let flutterEngine = (UIApplication.shared.delegate as? AppDelegate)?.flutterEngine
        let flutterViewController = FlutterViewController(engine: flutterEngine!, nibName: nil, bundle: nil)
        
        /*
        // 可以通过MethodChannel传递参数
        let channel = FlutterMethodChannel(
            name: "com.example.app/flutter",
            binaryMessenger: flutterViewController.binaryMessenger
        )
        
        // 可选 -- 设置初始路由或传递参数
        channel.invokeMethod("initialRoute", arguments: "/targetPage")
         
        // 可选 -- 设置监听,执行Flutter调用原生的方法
        channel.setMethodCallHandler{[weak self] (call, result) in
            guard let strongSelf = self else { return }
            print("flutter 给到我 method:\(call.method) arguments:\(String(describing: call.arguments))")
        }
         */
        self.navigationController?.pushViewController(flutterViewController, animated: true)
    }
  • 将Flutter作为ChildViewController加入原生的viewController

class FlutterCustomViewController: BaseViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        
        let flutterEngine = (UIApplication.shared.delegate as? AppDelegate)?.flutterEngine
        let flutterViewController = FlutterViewController(engine: flutterEngine!, nibName: nil, bundle: nil)
        
        /*
        // 可以通过MethodChannel传递参数
        let channel = FlutterMethodChannel(
            name: "com.example.app/flutter",
            binaryMessenger: flutterViewController.binaryMessenger
        )
        
        // 可选 -- 设置初始路由或传递参数
        channel.invokeMethod("initialRoute", arguments: "/targetPage")
         
        // 可选 -- 设置监听,执行Flutter调用原生的方法
        channel.setMethodCallHandler{[weak self] (call, result) in
            guard let strongSelf = self else { return }
            print("flutter 给到我 method:\(call.method) arguments:\(String(describing: call.arguments))")
        }
         */
        
        self.addChild(flutterViewController)
        self.view.addSubview(flutterViewController.view)
        flutterViewController.view.snp.makeConstraints{make in
            make.left.right.top.bottom.equalToSuperview()
        }
    }

iOS与Flutter交互

Flutter 与原生存在三种交互方式 可参考链接

三种 Channel 之间互相独立,各有用途,但它们在设计上却非常相近。每种 Channel 均有三个重要成员变量:

  • name: 【重要参数】String类型,代表 Channel 的名字,也是其唯一标识符需要和Fluter中的定义保持一致
  • messager:【重要参数】BinaryMessenger 类型,代表消息信使,是消息的发送与接收的工具
  • codec: MessageCodec 类型或 MethodCodec 类型,代表消息的编解码器

MethodChannel

一般用于传递方法调用(method invocation)通常用于Flutter调用原生中某个方法

举例:使用场景-Flutter需要获取原生生成的用户UUID,并传递UUID做存储操作


// 引入Flutter
import Flutter

@objc class AppDelegate: FlutterAppDelegate {

// 枚举的方式定义方法名
enum FlutterMethodType: String {
    case saveUUID       = "saveUUID"        ///< 保存 UUID
    case getUUID        = "getUUID"         ///< 获取 UUID
}


    let controller = window?.rootViewController as! FlutterViewController

// 初始化参数,并设置回调handle

     func MethodChannelRegist(controller: FlutterViewController) {
        let methodChannel_channer = FlutterMethodChannel(
            name: "com.example/ai/snowflake",
            binaryMessenger: controller.binaryMessenger
        )
        methodChannel_channer.setMethodCallHandler { [weak self] (call, result) in
            guard let self = self else { return }
            self.flutterMethodChanner_channer(call: call, result: result)
        }
    }
    
    // flutter 调用 swift
    private func flutterMethodChanner_channer(call: FlutterMethodCall, result: FlutterResult) -> Void {
        if call.method == FlutterMethodType.getUUID.rawValue {
            let uuid = "uuid"
            result(uuid)
        }else if call.method == FlutterMethodType.saveUUID.rawValue {
            let success = true
            result(success)
        } else {
            result(FlutterMethodNotImplemented)
        }
    }
    
}

image.png

BasicMessageChannel

它是可以双端通信的,Flutter 端可以给 iOS 发送消息,iOS 也可以给 Flutter 发送消息。

// 全局,方便随时可以发送消息
 var basicMessageChannel: FlutterBasicMessageChannel? = nil
 
 // 其他和MethodChannel基本一致
 func BasicMessageChannelRegist(controller: FlutterViewController) {
        basicMessageChannel = FlutterBasicMessageChannel(name: "com.example/ai/snowflake",
                                                             binaryMessenger: controller.binaryMessenger)
        
        basicMessageChannel?.setMessageHandler { [weak self] (call, result) in
            guard let self = self else { return }
            self.flutterMethodChanner_channer(call: call as! FlutterMethodCall, result: result)
        }
        
        // 相比MethodChannel 最重要的区别就是这个 可以主动向Flutter发送消息
        basicMessageChannel?.sendMessage(["name":"隔壁老王","age":25])
        
    }
    
    // flutter 调用 swift
    private func flutterMethodChanner_channer(call: FlutterMethodCall, result: FlutterResult) -> Void {
        if call.method == "methodOne" {
            
        }else if call.method == "methodTwo" {
            
        } else {
            result(FlutterMethodNotImplemented)
        }
    }
 

image.png

EventChannel

只能是原生发送消息给 Flutter 端,例如监听手机电量变化,网络变化,传感器等。

func eventChannelRegist(controller: FlutterViewController) {
        let eventChannel = FlutterEventChannel(
          name: "com.example.demo/event",
          binaryMessenger: controller.binaryMessenger
        )
        eventChannel.setStreamHandler(self)
    }
    
    // MARK: FlutterStreamHandler
    var eventSink: FlutterEventSink? = nil
    func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        self.eventSink = events
        return nil
    }
    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        self.eventSink = nil
        return nil
    }
    
    func sendEvent(data: Any) {
        eventSink?(data) // 主动发送数据到 Flutter
      }

image.png

记录一次Flutter项目上传App Store Connect出现“Validation failed”错误的问题

作者 Lucifer晓
2025年7月10日 16:25

描述

  • Flutter老项目,在flutter3.7之前的版本创建的
  • 现在用Flutter 3.16.9版本适配后,运行iOS都很正常
  • Xcode上 Product -> Archive 打包成功
  • 上传到App Store Connect,在validate环节报错,如下:

企业微信截图_b6551138-9586-4b55-aca0-420e99c9e670.png 错误日志:

...
Invalid Bundle. The bundle Runner.app/Frameworks/App.framework does not support the minimum OS Version specified in the Info.plist.
...

分析

现阶段App Store Connect 要求上传的ipa包的支持的最低系统版本要高于iOS 12.0,应该是项目里的配置或者一些第三方库的支持版本小于12.0了。需要从项目本身和第三方库两个方面着手处理。

处理

  1. 项目支持版本号设置:项目/ios/Podfile文件上面设置 platform :ios, '12.0' image.png

  2. 调整第三方库的支持版本号,同样是修改 项目/ios/Podfile文件 image.png

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
    end
  end
end

3.修改AppFrameworkInfo.plist文件里的MinimumOSVersion的值,一些老项目MinimumOSVersion的值都小于12.0(本次是有11.0调整为12.0)。 image.png

GoogleAdsOnDeviceConversion 库的作用与用法

作者 山水域
2025年7月10日 15:30

GoogleAdsOnDeviceConversion 库详细报告

1. 概述

GoogleAdsOnDeviceConversion 是一个专为 iOS 应用设计的软件开发工具包(SDK),用于实现设备端转换测量(On-Device Conversion Measurement)。它的核心功能是帮助开发者在用户设备上直接收集和处理广告转换数据(如应用安装、重新安装等),以支持隐私保护的广告效果归因。该库特别适用于无法直接集成 Google Analytics for Firebase (GA4F) 的场景,或者需要独立 SDK 的情况。

1.1 主要作用

  • 隐私保护:通过在设备端处理转换数据,减少敏感数据传输,符合严格的隐私法规(如 GDPR)。
  • 独立性:提供独立于 Firebase 的解决方案,适合多样化的开发需求。
  • 广告效果优化:帮助广告主了解 iOS 应用广告的效果,优化营销策略。

1.2 适用场景

  • iOS 应用开发者希望在保护用户隐私的同时,测量广告转换效果。
  • 无法或不愿使用 Google Analytics for Firebase 的项目。
  • 需要与第三方归因平台(如 Adjust)集成的场景。

2. 集成方式

GoogleAdsOnDeviceConversion 支持通过 CocoaPods 和 Swift Package Manager 集成。以下是详细步骤:

2.1 使用 CocoaPods

  1. 打开项目的 Podfile,添加以下行:
    pod 'GoogleAdsOnDeviceConversion'
    
  2. 在终端中运行以下命令:
    pod install --repo-update
    

2.2 使用 Swift Package Manager

  1. 在 Xcode 中,点击 File > Add Packages
  2. 在搜索栏输入以下 GitHub 仓库 URL:
    https://github.com/googleads/google-ads-on-device-conversion-ios-sdk
    
  3. 选择版本(建议选择 "Up to Next Major Version"),然后点击 Add Package
  4. Xcode 将自动解析并下载依赖项。

2.3 注意事项

  • 如果您的应用使用 Firebase Analytics 11.14.0 或更高版本,GoogleAdsOnDeviceConversion 可能已自动包含,无需手动添加。
  • 确保您的 Xcode 版本为 16.0 或更高,目标 iOS 版本为 12.0 或更高。

3. 使用方法

以下是如何在 Swift 中使用 GoogleAdsOnDeviceConversion 的详细步骤。

3.1 Swift 中的使用

在 Swift 项目中,您需要执行以下步骤:

3.1.1 导入库

在需要使用该库的 Swift 文件顶部添加:

import GoogleAdsOnDeviceConversion
3.1.2 设置首次启动时间

在应用启动时(例如,在 AppDelegateSceneDelegateapplication(_:didFinishLaunchingWithOptions:) 方法中),设置应用的首次启动时间:

ConversionManager.sharedInstance.setFirstLaunchTime(Date())

此步骤对于准确的转换归因至关重要。

3.1.3 获取转换信息

使用以下方法获取聚合的转换信息,通常用于应用安装(.installation):

ConversionManager.sharedInstance.fetchAggregateConversionInfo(for: .installation) { aggregateConversionInfo, error in
    if let info = aggregateConversionInfo {
        // 使用 aggregateConversionInfo,例如作为 odm_info 参数
        print("Aggregate Conversion Info: \(info)")
    } else if let error = error {
        print("Error fetching conversion info: \(error)")
    }
}
  • aggregateConversionInfo 是一个字符串(例如 "abcdEfadGdaf"),可作为 odm_info 查询参数传递给广告平台。

3.3 示例代码

以下是一个完整的 Swift 示例,展示如何在应用启动时设置和获取转换信息:

import UIKit
import GoogleAdsOnDeviceConversion

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // 设置首次启动时间
        ConversionManager.sharedInstance.setFirstLaunchTime(Date())
        
        // 获取转换信息
        ConversionManager.sharedInstance.fetchAggregateConversionInfo(for: .installation) { aggregateConversionInfo, error in
            if let info = aggregateConversionInfo {
                print("Aggregate Conversion Info: \(info)")
                // 示例:将 info 传递给广告平台
                // let url = URL(string: "https://your-ad-platform.com?odm_info=\(info)")!
            } else if let error = error {
                print("Error: \(error.localizedDescription)")
            }
        }
        return true
    }
}

4. 与其他 SDK 的集成

4.1 与 Firebase Analytics

  • 如果您的应用使用 Firebase Analytics 11.14.0 或更高版本,GoogleAdsOnDeviceConversion 可能已自动包含,无需手动添加。
  • 若需手动集成,确保 Podfile 中未重复添加依赖。

4.2 与 Adjust SDK

如果您使用 Adjust SDK 进行归因,可以通过 Adjust 的 ODM 插件启用设备端转换测量:

  • Podfile 中添加:
    pod 'Adjust/AdjustGoogleOdm'
    pod 'GoogleAdsOnDeviceConversion', '2.0.0'
    
  • 在应用启动时尽早调用 Adjust SDK 的 initSdk 方法,例如:
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        Adjust.initSdk(with: yourConfig)
        return true
    }
    
  • 如果需要延迟 SDK 初始化,可使用 Adjust 的 First Session Delay 功能。
  • Adjust SDK 会自动处理首次启动时间捕获,确保归因准确。

5. 注意事项

以下是使用 GoogleAdsOnDeviceConversion 时需要注意的关键点:

方面 细节
首次启动时间 确保 setFirstLaunchTime() 设置的日期是应用的实际首次启动时间,否则可能影响归因准确性。
地域限制 该功能在欧洲经济区(EEA)、英国和瑞士不可用(参考 Google Ads Help)。
Firebase 集成 使用 Firebase Analytics 11.14.0 或更高版本时,无需手动添加此库。
版本兼容性 Adjust SDK 已测试版本 2.0.0 的 GoogleAdsOnDeviceConversion,建议验证新版本的兼容性。
隐私合规性 确保您的应用披露如何使用 Google 服务处理数据(参考 Google Ads 隐私披露)。

6. 总结

GoogleAdsOnDeviceConversion 库是 iOS 应用开发者精确衡量 Google Ads 广告系列带来的应用安装和后续应用内操作的关键工具。其主要目标是在严格遵守用户隐私标准的前提下,增强广告系列的优化和报告能力 。  

从功能上看,它是一个“设备端转化衡量插件”,旨在与 Firebase Analytics SDK 和 Google 的应用归因合作伙伴 (AAP) SDK 协同工作 。这种集成表明它并非一个独立的广告 SDK,而是一个专门的实用工具,用于在更广泛的 Google Ads 和 Firebase 生态系统中提高转化数据的精确度。  

该库在设计上持续强调“隐私保护” ,并特别提及“去标识化的临时信号” 。这表明 Google 正在对不断变化的隐私法规(如通用数据保护条例 (GDPR))和平台级变化(如 Apple 的应用跟踪透明度 (ATT) 框架)做出战略性和前瞻性的调整。通过在用户设备上直接进行归因,而无需将可识别的个人数据传输到设备之外,Google 旨在提供强大而有效的衡量能力,这些能力本身就符合现代隐私期望。这种方法将该库定位为在隐私受限环境中保持广告有效性的必要适应。

SwiftUI 新手必读:如何用纯 SwiftUI 在应用中实现分段控制?

作者 iOS新知
2025年7月10日 13:04

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

前言


在现代应用程序开发中,分段控制(Segmented Control)是一种常用的界面元素,它由多个水平排列的部分组成,每个部分都可以作为一个互斥的按钮供用户选择。

在 SwiftUI 中,虽然没有专门的视图来实现分段控制,但苹果将其视为 Picker 的一种变体,这种设计理念其实是非常合理的,因为它们在功能上具有相似性。

接下来就来看看如何在 SwiftUI 中创建分段控制。

分段控制的概念

在 UIKit 中,我们熟悉的 UISegmentedControl 是专门用于实现这种控件的,而在 SwiftUI 中,Picker 则是其等效的实现方式。

Picker 是 SwiftUI 中用于选择互斥值的一种控件,与 UIKit 的 UIPickerView 类似。通过使用 Picker,我们可以轻松地创建分段控制。

如何创建分段控制

既然分段控制是 Picker 的一种变体,我们可以通过创建一个 Picker 视图,并通过 pickerStyle 修饰符应用 SegmentedPickerStyle() 来实现。以下是一个简单的示例:

struct ContentView: View {
    @Stateprivatevar selectedColorIndex = 0
    privatelet colors = ["红色""绿色""蓝色"]
    var body: some View {
        VStack {
            Picker("选择颜色", selection: $selectedColorIndex, content: {
                ForEach(0..<colors.count) { index in
                    Text(colors[index]).tag(index)
                }
            })
            .pickerStyle(SegmentedPickerStyle()) // <1>
            Text("选中的颜色 : \(colors[selectedColorIndex])")
        }
    }
}

在这个示例中,我们定义了一个 Picker,用于选择用户喜欢的颜色,并通过 SegmentedPickerStyle() 使其显示为分段控制的样式。

看下效果:

image.png

使用场景与建议

虽然分段控制和 Picker 在功能上相似,但它们在使用场景上有一些区别。

分段控制通常用于在不同的视图之间切换,例如在地图应用中,用户可以通过分段控制在地图、公交和卫星视图之间切换。而 Picker 则更适合用于从一长串选项中进行选择。

结论

SwiftUI 通过将分段控制视为 Picker 的一种变体,使得开发者能够以一种简洁而直观的方式实现这一功能。通过合理使用分段控制,我们可以为用户提供更友好的界面交互体验。

希望这篇文章能够帮助你更好地理解和使用 SwiftUI 中的分段控制。如果你有任何问题或建议,请在评论区告诉我们!

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

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

哪些产品符合免备案的骚操?看看你的产品符合吗?

作者 iOS研究院
2025年7月10日 09:31

前言

免备案其实是AppStore对于个人开发者示好的一种方式,也体现出AppStore更多的一个包容性。

由于后台询问是否符合AppStore免备案的骚操作有点多,所以干脆单独拿出来讲一下。希望可以帮助到更多对此感到困惑的同行们

其实这条福利更多是提供给单机的产品。

如何界定单机?

单机的界定并不是我觉得是单机,那就是单机!

从传统意义上来讲,单机就是不联网的App。但是对于AppStore来讲并没有这个严格的界定。上篇文章关于免备案骚操作工具的产品,其实本身也是存在网络请求的。

首当其冲的就是AppStore自带的内购请求,作为个人开发者唯一变现的通道,其实本身依旧依赖于网络请求的渲染。

另外,顺利免备案的应用也调用的其他网络请求。但其实核心具备以下要素:

1.不进行任何信息的收集,同时本身免登录、不记录用户收集,不追踪用户设备。

2.低频调用接口。因为工具类需要使用一些基本鉴权功能,防止被流量攻击以及用户基本管理。

3第三方广告API。作为白嫖用户最好的通道,也是产品最低的保障,这点无可或缺。这里主要是代指国内知名厂商,比如:头条的穿山甲、腾讯的优量汇、快手SDK以及百度常青藤。 其他非大厂的变现SDK建议慎重使用。

不使用任何接口

最简单粗暴符合,免备案的条件的其实就是屏蔽任何网络请求。那么,聪明的你就要问了,这还怎么变现?

其实只能用同样最简单粗暴的付费下载!如果,既要安全,又要收集,还要长久。那么还是花点钱备案吧,免得提心吊胆。

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

相关推荐

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

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

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

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

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

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

知识星球

更多Appstore咨询问题,请关注知识星球。「提供1v1上架指导,帮助开发者解决Appstore的疑难杂症,助力每一位开发者!」

依赖注入(六):架构黄金标准:为何选择Coordinator,以及如何用好它

作者 tangzzzfan
2025年7月10日 02:07

在前面的分享中,我们已经建立了对“显式依赖注入”的深刻认同。现在,我们面临最后一个,也是最关键的架构决策:如何组织我们应用的导航逻辑?我们是应该改良现有的Router模式,还是全面转向Coordinator模式?

我将首先清晰地辨析CoordinatorRouter的本质区别,以阐明我们做出选择的理由。然后,我将结合MVVM和现代并发框架,为大家呈现一套完整的Coordinator模式最佳实践。

第一部分:正本清源 - Coordinator vs. Router

在很多讨论中,CoordinatorRouter经常被混用,但它们在设计哲学和实现细节上有着天壤之别。

特性 Router (URL-based 或 Protocol-based) Coordinator
核心隐喻 全局“电话总机”或“URL调度中心” 专职“旅行团导游”
核心职责 响应一个标识符(URL或协议),触发一个未知的跳转 管理一个完整、具象的业务流程(Use Case)
调用方式 Router.open("app://profile?id=123") profileCoordinator.start(userId: "123")
参数传递 通常是弱类型(字符串),难以传递复杂对象 强类型,可以通过构造函数或方法传递任何对象
依赖管理 隐式(黑盒):调用方不知道目标页面需要什么 显式(白盒):Coordinator负责为页面注入其所有依赖
导航控制 全局或分散,导航逻辑(push/present)与业务流分离 内聚,由Coordinator自身完全控制其流程内的导航
生命周期 通常是全局单例 有自己的生命周期,可以随业务流创建和销毁

结论:为何必须选择Coordinator?

Router模式的根本问题在于它是一个服务定位器(Service Locator)。它鼓励了“主动查询”的行为,隐藏了依赖关系,牺牲了类型安全和可测试性。任何试图改良Router的努力,都只是在为一个有缺陷的地基做表面装修。

Coordinator模式是一次架构思想的升维。它天生就与依赖注入思想完美契合:

  • 它自身通过DI被创建和配置。
  • 它作为DI的执行者,为它所管理的MVVM栈注入依赖。
  • 它将导航逻辑(“How”)与业务意图(“What”)清晰地分离开来。

因此,我们的决策是明确且坚定的:在应用内部导航中,全面采用Coordinator模式,彻底摒弃基于服务定位器的Router模式。 仅在需要响应外部事件(推送、H5)时,可以保留一个极轻量的URLDispatcher,其唯一作用是解析URL并启动一个根Coordinator


第二部分:最佳实践 - Coordinator + MVVM + 现代并发

现在,让我们进入实战环节。以下是一套完整的、值得在所有新项目中推行的最佳实践。

  • DI容器 (Swinject): 超级工厂。只在组合根中配置,负责创建一切。
  • Coordinator: 交通指挥。负责业务流、依赖组装和导航。
  • MVVM (View - ViewModel): 场景呈现者。ViewModel处理逻辑和状态,View负责渲染。

2. 核心原则:清晰的通信边界

  • Coordinator -> ViewModel: 通过构造函数注入依赖(如Service)。
  • ViewModel -> Coordinator: 绝对不能反向持有Coordinator的引用! ViewModel通过响应式信号(Combine/AsyncStream)或闭包向外发送导航“意图”。
  • ViewModel <-> View: 通过@StateObject/@ObservedObject@Published属性进行双向绑定。

3. 完整示例:一个支持回调的“商品选择”流程

想象一个场景:在创建订单页面,需要弹出一个商品选择页面,选择完商品后,需要将商品ID回调给创建订单页面。

步骤1:定义Coordinator协议和父子关系

protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }
    func start()
}

// 方便地管理子Coordinator
extension Coordinator {
    func addChild(_ childCoordinator: Coordinator) {
        childCoordinators.append(childCoordinator)
    }
    
    func removeChild(_ childCoordinator: Coordinator) {
        childCoordinators = childCoordinators.filter { $0 !== childCoordinator }
    }
}

步骤2:实现ProductSelectionCoordinator

import Combine

class ProductSelectionCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    private let resolver: Resolver

    // 使用Combine的Subject来传递回调结果,类型安全
    let selectionResult = PassthroughSubject<String, Never>()
    private var cancellables = Set<AnyCancellable>()

    init(resolver: Resolver, navigationController: UINavigationController) {
        self.resolver = resolver
        self.navigationController = navigationController
    }

    func start() {
        let viewModel = resolver.resolve(ProductSelectionViewModel.self)!
        let viewController = ProductSelectionViewController(viewModel: viewModel)
        
        // 监听ViewModel的输出
        viewModel.didSelectProduct
            .sink { [weak self] productId in
                // 1. 将结果通过自己的Subject发射出去
                self?.selectionResult.send(productId)
            }
            .store(in: &cancellables)
        
        viewModel.didCancel
            .sink { [weak self] in
                // 2. 空转结果,表示取消
                self?.selectionResult.send(completion: .finished)
            }
            .store(in: &cancellables)

        // 通常以模态形式弹出
        navigationController.present(UINavigationController(rootViewController: viewController), animated: true)
    }
}

步骤3:实现ProductSelectionViewModel

import Combine

class ProductSelectionViewModel: ObservableObject {
    private let productService: ProductServicing
    
    // 输出给Coordinator的信号
    let didSelectProduct = PassthroughSubject<String, Never>()
    let didCancel = PassthroughSubject<Void, Never>()
    
    // 输出给View的状态
    @Published var products: [Product] = []
    
    init(productService: ProductServicing) {
        self.productService = productService
    }
    
    // 供View调用的方法
    func selectProduct(at index: Int) {
        let productId = products[index].id
        didSelectProduct.send(productId)
    }
    
    func cancelButtonTapped() {
        didCancel.send()
    }

    @MainActor
    func fetchProducts() async {
        self.products = await productService.fetchAll()
    }
}

步骤4:在父Coordinator (CreateOrderCoordinator) 中使用它

class CreateOrderCoordinator: Coordinator {
    // ...
    func showProductSelection() {
        let selectionCoordinator = resolver.resolve(ProductSelectionCoordinator.self, argument: self.navigationController)!
        addChild(selectionCoordinator)
        
        selectionCoordinator.selectionResult
            .sink(receiveCompletion: { [weak self] _ in
                // 无论成功或取消,流程结束,都移除子Coordinator
                self?.removeChild(selectionCoordinator)
                self?.navigationController.dismiss(animated: true)
            }, receiveValue: { [weak self] productId in
                // 成功获取到商品ID,更新自己的ViewModel
                self?.viewModel.update(with: productId)
            })
            .store(in: &cancellables)
            
        selectionCoordinator.start()
    }
    // ...
}

4. 易错点与最终建议

  • 生命周期管理是关键: 必须通过addChildremoveChild来正确管理Coordinator的生命周期,否则将导致内存泄漏。
  • 通信必须单向: ViewModel绝不能知道Coordinator的存在。通信永远是ViewModel -> Coordinator的单向信号。
  • DI容器的纯洁性: ViewModelView中绝对不能出现container.resolve的代码。依赖必须在初始化时注入。

总结:一套值得信赖的架构

通过将DI容器CoordinatorMVVM三者有机结合,我们建立了一个分层清晰、职责单一、高度可测的黄金架构。它解决了传统MVC和Router模式的种种弊病,为我们构建复杂、可维护的大型应用提供了坚实的基础。

在第七篇中,我将会介绍一个 MVVMC 的实际使用示例,敬请期待。

依赖注入(五):DI是一种思想,而非特定工具——工厂、抽象与组合根

作者 tangzzzfan
2025年7月10日 01:42

在过去的四篇文章中,我们从DI的基础理论聊到手写容器,再到Swinject框架实战,最后还探讨了它在声明式UI中的应用。至此,我们对“使用DI容器”已经有了非常深入的了解。

但是,如果我们将目光仅仅局限在Swinject这样的“DI容器”上,就可能会陷入“手里拿着锤子,看什么都是钉子”的思维定式。

今天,我们要回归本源,再次强调一个核心观点:依赖注入(DI)是一种设计思想,而不是某一个特定的工具或框架。 理解了这一点,我们就能在不同的场景下,选择最恰当的方式来实现解耦,而不是一味地追求“上容器”。

1. DI思想的多种实现形态

DI思想的本质是将依赖的创建和管理责任从使用者内部转移到外部。实现这一目标,我们有多种武器可选。

a. 手动DI (Manual DI)

这其实是我们最开始接触,也是最朴素的DI形式。它不借助任何框架,直接在代码中通过构造函数或属性来传递依赖。

// 在某个负责组装的类(比如一个Factory或者Coordinator)中
func makeProfileScene() -> UIViewController {
    // 手动创建和注入依赖
    let apiService = APIService()
    let userCache = UserCache()
    let userManager = UserManager(api: apiService, cache: userCache)
    let viewModel = ProfileViewModel(manager: userManager)
    let viewController = ProfileViewController(viewModel: viewModel)
    return viewController
}

优点:

  • 零依赖: 不需要引入任何第三方框架。
  • 极其直观: 代码如何执行一目了然,没有“黑魔法”。
  • 编译时安全: 所有依赖关系都在编译时确定。

缺点:

  • 代码冗长: 当依赖链很长或很复杂时,组装代码会变得非常繁琐。

适用场景:

  • 小型项目或独立模块。
  • 在应用的“组合根”(后面会详谈)进行最高层的对象组装。
  • 当你希望对依赖的创建有最细粒度的控制时。

b. 工厂模式 (Factory Pattern)

工厂模式是面向对象设计中的经典模式,它本身就是DI思想的一种体现。工厂类封装了创建对象的复杂逻辑,调用者只需向工厂请求一个对象,而无需关心其内部是如何被创建和组装的。

// 一个专门负责创建ViewController的工厂
class ViewControllerFactory {
    // 工厂自身也可能有依赖,通过构造函数注入
    private let apiService: APIService
    private let userManager: UserManager

    init(apiService: APIService, userManager: UserManager) {
        self.apiService = apiService
        self.userManager = userManager
    }

    func makeLoginViewController() -> LoginViewController {
        let viewModel = LoginViewModel(apiService: self.apiService)
        return LoginViewController(viewModel: viewModel)
    }

    func makeProfileViewController() -> ProfileViewController {
        let viewModel = ProfileViewModel(manager: self.userManager)
        return ProfileViewController(viewModel: viewModel)
    }
}

优点:

  • 封装创建逻辑: 将复杂的创建过程从业务代码中分离出来。
  • 职责单一: 工厂的职责就是创建,非常清晰。

适用场景:

  • 当对象的创建过程比较复杂,包含一些配置或判断逻辑时。
  • 作为DI容器的一种轻量级替代方案,用于管理某一类特定对象(如所有ViewController)的创建。

c. 服务协议 (Service Protocol) 与“协议式服务发现”的辨析

我们一直在强调面向协议编程是DI的基石。在Swift的生态中,协议(Protocol)是一个极其强大的工具,它也被用于一些组件化方案中,实现所谓的“协议式服务发现”。我们必须清晰地辨析它与我们所提倡的依赖注入模式的区别。

“协议式服务发现”模式剖析

这种模式通常会有一个全局的管理者(比如叫RouterServiceComponentManager),并结合泛型来实现服务的注册和发现。

// ---- 这种模式的典型实现 ----

// 1. 公共模块定义协议
public protocol ProfileServiceProvider {
    func getProfileViewController(for userId: String) -> UIViewController
}

// 2. 在Profile组件中实现并“注册”
class ProfileModule: ProfileServiceProvider { /*...*/ }
// 通过某种机制,比如启动时扫描或手动编码,将 `ProfileServiceProvider.self` 和 `ProfileModule.new` 关联起来
RouterService.shared.register(service: ProfileServiceProvider.self, implementation: ProfileModule.init)

// 3. 在使用方,通过泛型协议“发现”服务
// 这个 `rs` 属性可能是一个通过 @dynamicMemberLookup 实现的语法糖
let profileVC = RouterService.rs.profileServiceProvider.getProfileViewController(for: "123")
navigationController.push(profileVC)

// 泛型解析的背后逻辑
extension RouterService {
    func service<T>(for protocolType: T.Type) -> T? {
        // ... 从注册表中查找并返回实现类的实例 ...
    }
}
// 所以 RouterService.rs.profileServiceProvider 实际上是调用了 service(for: ProfileServiceProvider.self)

为什么我们要警惕并避免这种模式?

尽管这种方式利用了协议和泛型,看起来很“Swift-y”,并且实现了模块间的解耦,但它从根本上违反了依赖注入的核心原则,并退化为了**服务定位器(Service Locator)**模式,其危害我们在第一篇中已经深入讨论过:

  1. 隐藏依赖(幽灵依赖): 任何一个需要ProfileServiceProvider的类,在其公开接口(如init)上完全看不出来。依赖关系深埋在实现细节中,你需要通读代码才能发现它调用了RouterService。这严重破坏了代码的可读性和可维护性。
  2. 耦合到全局定位器: 所有业务代码都与RouterService.shared这个全局单例紧密耦合。这使得代码单元测试变得极其困难。为了测试一个ViewModel,你必须去处理或模拟这个全局的RouterService,而不是简单地在创建ViewModel时传入一个Mock对象。
  3. 职责不清: ViewModel或ViewController的职责应该是处理业务逻辑和UI状态,而不应该关心它的依赖是从哪里来的。让它自己去“定位”服务,是典型的职责不清。

我们的选择:明确的依赖注入

依赖注入坚持:一个类需要什么,就必须在它的构造函数里明确声明。

// 依赖注入的方式
class SomeCoordinator {
    private let profileServiceProvider: ProfileServiceProvider // 依赖是明确的成员变量

    init(profileServiceProvider: ProfileServiceProvider) { // 通过构造函数注入
        self.profileServiceProvider = profileServiceProvider
    }
    
    func showProfile() {
        let vc = profileServiceProvider.getProfileViewController(for: "123")
        // ...
    }
}

结论: “协议式服务发现”是一种伪装成现代模样的服务定位器。它解决了模块解耦,却以牺牲代码清晰度、可测试性和职责单一性为代价。因此,在我们的实践中,应当明确拒绝这种模式,始终选择通过构造函数进行显式依赖注入。

2. 架构的关键节点:“组合根” (Composition Root)

我们一直在说“依赖由外部提供”,那么这个“外部”的尽头是哪里?应用程序总得有一个地方,负责创建所有这些依赖,并将它们“粘合”在一起。这个地方,就叫做组合根 (Composition Root)

组合根是应用中唯一一个可以引用具体实现,并将它们与业务代码中的抽象(协议)连接起来的地方。

在典型的iOS App中,组合根通常位于:

  • UIKit App: AppDelegateSceneDelegate
  • SwiftUI App: @main 入口的 App struct。
// 在SceneDelegate中,这里是我们的组合根
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    var assembler: Assembler! // Assembler是组合根的一部分

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        // --- 这是组合根的核心区域 ---
        setupDIContainer()
        
        let rootCoordinator = assembler.resolver.resolve(AppCoordinator.self)!
        // --------------------------

        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = rootCoordinator.start() // 启动应用
        window?.makeKeyAndVisible()
    }

    private func setupDIContainer() {
        assembler = Assembler([
            ServiceAssembly(),
            ViewModelAssembly(),
            CoordinatorAssembly()
            // ...所有模块的Assembly都在这里被组装
        ])
    }
}

理解“组合根”的概念至关重要,因为它回答了“DI容器应该在哪里被创建和使用”的问题。答案是:DI容器本身(或其Assembler)应该只存在于组合根中。 其他所有业务代码(ViewModel、Service等)都应该是“纯洁”的,它们不应该知道DI容器的存在,只通过构造函数接收自己需要的依赖。

这可以防止我们将DI容器滥用为我们第一篇中批评的“服务发现器”。

3. 总结与思想升华

依赖注入是一种深刻影响我们代码组织方式的设计思想。它的目标是追求高内聚、低耦合

  • DI容器(如Swinject) 是实现这一思想的强大工具,特别适合管理复杂应用中的众多依赖。
  • 手动DI和工厂模式 则是更轻量、更直接的实现方式,在简单场景下同样有效。
  • 面向协议编程 是实现DI价值的基石。
  • 组合根 是我们应用DI原则,同时又保持架构清晰的“圣地”。

作为架构师或资深开发者,我们需要具备根据不同项目规模和复杂度,选择最合适DI实现方式的能力。可能是一个全功能的DI容器,也可能只是一组精心设计的工厂类,甚至只是在组合根中的手动注入。

理解了DI是一种思想而非特定工具,我们就拥有了更大的架构自由度和灵活性。

在最后一篇中,我们将进行一场关键的辩证讨论:我们团队中已经存在的“路由解耦”方案,与我们现在提倡的DI思想,究竟是竞争关系还是可以协同作战的盟友?我们将给出最终的架构决策建议。

敬请期待!

依赖注入(四):当DI遇见声明式UI,从Flutter Riverpod反思SwiftUI的最佳实践

作者 tangzzzfan
2025年7月10日 01:17

前三篇我们已经为iOS原生开发的DI打下了坚实的基础。现在,让我们把目光投向更广阔的领域,看看在现代化的声明式UI范式下,DI的思想是如何演进和应用的。这对于我们组内同时拥有Swift和Flutter技术栈的同学来说,尤其有价值。

今天,我们要讨论一个非常前沿且重要的话-题:依赖注入在声明式UI框架中应该如何实践?

随着SwiftUI和Flutter的兴起,我们的UI构建方式已经从命令式(“去做这个,然后做那个”)转变为声明式(“UI应该是这个状态”)。这种转变不仅仅影响了视图层,它也深刻地改变了我们对状态管理和依赖注入的思考方式。

组内有很多Flutter的同事对Riverpod框架非常熟悉。这是一个绝佳的机会,我们可以通过对比Riverpod的设计哲学,来反思和探索在SwiftUI中进行依赖注入的最佳实践。

1. 求同存异,先看Flutter的Riverpod

对于不熟悉Riverpod的Swift同学,可以把它简单理解为一个“超级强大”的DI和状态管理框架。它的核心是 Provider

一个Provider就是一个“提供者”,它可以向UI(在Flutter中是Widget)提供任何东西:

  • 一个服务的实例(如APIService
  • 一个计算后的值(如从多个状态组合出的新值)
  • 一个完整的ViewModel(在Flutter中通常叫StateNotifierChangeNotifier

Riverpod的核心特点:

  1. 与UI框架深度融合: Provider的生命周期和作用域可以与Widget树紧密绑定。当一个Widget不再需要某个Provider时,Provider可以被自动销毁(autoDispose),非常高效地管理内存。
  2. 天生的响应式: 当一个Provider所提供的数据发生变化时,所有“监听”了这个Provider的UI组件都会自动重建(刷新),以展示最新的状态。
  3. 编译时安全: 它摆脱了Flutter早期DI框架Provider需要依赖BuildContext(上下文)的缺点,可以在任何地方安全地访问,且没有运行时风险。
  4. 不仅仅是DI,更是状态管理: 这是最关键的一点。Riverpod通过Provider统一了“依赖注入”和“状态管理”这两个概念。你可以用同样的方式去“提供”一个无状态的APIService和一个有状态的CounterViewModel
// Riverpod 示例
// 1. 提供一个服务
final apiServiceProvider = Provider((ref) => ApiService());

// 2. 提供一个ViewModel(StateNotifier)
final counterProvider = StateNotifierProvider<Counter, int>((ref) {
  // 可以在这里读取其他provider!实现了依赖注入
  final apiService = ref.watch(apiServiceProvider);
  return Counter(apiService); // Counter是StateNotifier的子类
});

// 3. 在UI中使用
class MyScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 读取ViewModel的状态
    final count = ref.watch(counterProvider);
    // 读取ViewModel本身以调用其方法
    final counterNotifier = ref.read(counterProvider.notifier);

    return Scaffold(
      body: Center(child: Text('$count')),
      floatingActionButton: FloatingActionButton(
        onPressed: () => counterNotifier.increment(),
      ),
    );
  }
}

2. 对比Swift世界的DI:核心差异

通过观察Riverpod,我们可以发现它与我们之前讨论的Swinject等传统DI框架的核心差异:

  • 关注点不同:

    • Swinject (传统DI): 更关注 “对象图的构建” (Object Graph Construction)。它的核心任务是在应用启动时或需要时,正确地创建和连接好所有对象。它对UI层是“无知”的。
    • Riverpod: 更关注 “状态的提供与消费” (State Provision and Consumption)。它与UI层紧密耦合,其设计初衷就是为了服务于响应式的UI刷新。
  • 生命周期/作用域不同:

    • Swinject: 它的作用域(.container, .graph)与Container实例和resolve调用相关,与UI组件的生命周期没有直接关系。
    • Riverpod: Provider的生命周期可以和Widget的生命周期完全同步,实现了更精细、自动化的管理。

3. SwiftUI中的DI最佳实践

那么,在SwiftUI中,我们应该如何借鉴这些思想呢?

a. 苹果的原生方案:@EnvironmentObject

SwiftUI提供了一个原生的DI机制:@EnvironmentObject。你可以把一个对象注入到视图环境中,任何子视图都可以从中读取。

// 在根视图注入
let userSettings = UserSettings()
ContentView().environmentObject(userSettings)

// 在子视图中接收
struct SettingsView: View {
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        Text("Username: \(settings.username)")
    }
}

优点:

  • 非常简单,苹果原生支持。

缺点:

  • 类型不安全: 如果你忘记在父视图注入environmentObject,App会在运行时直接崩溃。
  • 全局污染: 它更像一个“全局变量”,适用于传递真正全局的、与UI显示相关的状态(如主题、用户设置),但如果用它来注入大量的服务和ViewModel,会使依赖关系变得混乱和隐式。
  • 不适合注入服务: 它的设计初衷是传递ObservableObject,用于驱动UI刷新,而不是注入无状态的服务。

结论:@EnvironmentObject可用,但要谨慎。它适合做简单的UI状态分发,而非完整的DI解决方案。

b. 主流方案:“DI容器 + ViewModel”模式

社区目前公认的最佳实践,是结合我们前几篇学到的知识,将传统DI容器(如Swinject)与SwiftUI的@StateObject / @ObservedObject结合起来。

这个模式的思路是:DI容器负责幕后的对象创建,SwiftUI负责前台的状态观测和UI刷新,二者各司其职。

实践步骤:

  1. 在组合根中设置DI容器: 和以前一样,在App的入口处(@mainApp结构体或SceneDelegate)创建和配置我们的AssemblerContainer

    // App.swift
    @main
    struct MyApp: App {
        let assembler: Assembler
        
        init() {
            assembler = Assembler([
                NetworkAssembly(),
                ViewModelAssembly()
            ])
        }
    
        var body: some Scene {
            WindowGroup {
                // 从容器中解析出根视图的ViewModel
                // 注意这里,我们只解析一次,然后交给SwiftUI管理
                let rootViewModel = assembler.resolver.resolve(RootViewModel.self)!
                RootView(viewModel: rootViewModel)
            }
        }
    }
    
  2. 为View注入其专属的ViewModel: View不应该直接接触DI容器。View的唯一依赖就是它的ViewModel。ViewModel通过构造函数注入它所需要的所有服务。

    // ProductDetailView.swift
    struct ProductDetailView: View {
        // 使用@StateObject确保ViewModel的生命周期与View绑定
        // viewModel由父视图(或Coordinator)创建并传入
        @StateObject var viewModel: ProductDetailViewModel
    
        var body: some View {
            VStack {
                Text(viewModel.productName)
                if viewModel.isLoading {
                    ProgressView()
                }
                Button("Add to cart") {
                    viewModel.addToCart()
                }
            }
            .onAppear(perform: viewModel.fetchProduct)
        }
    }
    
  3. ViewModel从DI容器中创建,并注入依赖: 这一步发生在ViewModelAssembly中,我们之前已经很熟悉了。

    // ViewModelAssembly.swift
    class ViewModelAssembly: Assembly {
        func assemble(container: Container) {
            // ViewModel必须是 .transient
            container.register(ProductDetailViewModel.self) { (r, productId: String) in
                ProductDetailViewModel(
                    productId: productId,
                    apiService: r.resolve(APIService.self)!, // 自动解析服务
                    cartService: r.resolve(CartService.self)!
                )
            }.inObjectScope(.transient)
        }
    }
    

这个模式的巨大优势:

  • 职责清晰: Swinject管创建,ViewModel管业务逻辑和状态,View管渲染。
  • 强类型安全: 所有依赖都通过构造函数注入,编译时就能发现错误。
  • 可测试性极高: 你可以轻松地为ProductDetailViewModel创建mock的APIServiceCartService来进行单元测试,完全不用依赖任何UI。
  • 与SwiftUI和谐共存: 它没有破坏SwiftUI的声明式和响应式特性,而是为其提供了一个坚实的、可预测的数据和逻辑后端。

总结与思考

通过对比Riverpod,我们发现,虽然它在与UI的结合度上做得更“原生”,但其核心思想——将依赖(服务)和状态(ViewModel)统一通过某种机制(Provider)提供给UI——是相通的。

在SwiftUI中,我们虽然没有一个像Riverpod一样大一统的框架,但通过 “Swinject (DI容器) + @StateObject (状态管理)” 的组合,我们实现了一个逻辑上等价且非常强大的模式。

  • Riverpod的ref.watch(someProvider) ≈ SwiftUI的@StateObject / @ObservedObject 它们都负责监听状态变化并触发UI刷新。
  • Riverpod的Provider定义 ≈ Swinject的container.register 它们都负责定义如何创建依赖和状态对象。

最终,我们为SwiftUI应用构建了一个清晰的分层架构:

View -> ViewModel (状态和业务逻辑) -> Services (无状态的原子能力)

而依赖注入,就是将这些层次优雅地“粘合”在一起的最佳胶水。

在下一篇中,我们将回到更广义的架构讨论,看看DI思想的其他实现方式,比如通过路由(Coordinator)模式,并给出最终的架构选型建议。

敬请期待!

依赖注入(三):Swinject实战,玩转生命周期与循环依赖

作者 tangzzzfan
2025年7月10日 01:09

在前两篇文章中,我们建立了DI的思想共识,并通过手写一个迷你容器揭开了DI框架的神秘面纱。我们知道了,一个好的DI容器能帮我们自动管理对象的创建和生命周期,把我们从繁琐的手动组装中解放出来。

今天,我们将进入实战环节,聚焦于iOS社区中最流行和强大的DI框架之一:Swinject。我们将学习如何优雅地在项目中使用它,并重点攻克两个核心难点:生命周期的正确使用循环依赖的解决方案

1. 为什么选择Swinject?

在众多Swift DI框架中,Swinject脱颖而出,因为它:

  • 功能强大且成熟: 支持多种生命周期、属性注入、循环依赖解决、模块化注册等高级功能。
  • 纯Swift实现: 充分利用了Swift的类型系统,提供了较好的类型安全。
  • 社区活跃: 拥有完善的文档和庞大的用户群体,遇到问题容易找到解决方案。

2. Swinject核心组件:Container, Assembly, Assembler

想象一下,如果把所有服务的注册代码都写在一个地方,那将是新的灾难。Swinject通过三个核心概念帮助我们进行模块化管理:

  • Container: 这就是我们熟悉的DI容器本身,负责注册和解析服务。
  • Assembly: 这是一个协议,我们可以创建遵循此协议的类,将相关联的依赖注册逻辑组织在一起。比如,所有网络相关的依赖可以放在NetworkAssembly里,所有ViewModel相关的可以放在ViewModelAssembly里。这极大地提高了代码的可读性和维护性。
  • Assembler: 这是一个“组装工”,它的作用是把多个Assembly实例“组装”到一个Container中。这是我们将模块化配置应用到主容器的方式。

一个典型的组织结构:

// 1. 定义模块化的 Assembly
class NetworkAssembly: Assembly {
    func assemble(container: Container) {
        container.register(NetworkServicing.self) { _ in
            NetworkService()
        }
        // ... 其他网络相关的注册
    }
}

class ViewModelAssembly: Assembly {
    func assemble(container: Container) {
        container.register(ProductDetailViewModel.self) { r in
            // Swinject自动解析依赖!
            // r 是一个解析器(Resolver),可以用来获取其他依赖
            ProductDetailViewModel(networkService: r.resolve(NetworkServicing.self)!)
        }
        // ... 其他ViewModel相关的注册
    }
}

// 2. 在应用的组合根(如AppDelegate)使用 Assembler
let assembler = Assembler([
    NetworkAssembly(),
    ViewModelAssembly()
])
let container = assembler.resolver // Assembler内部会创建一个Container,并通过resolver属性暴露出来

3. 重点精讲:Swinject的生命周期(Scope)最佳实践

正如我们在第二篇中强调的,错误地使用生命周期是DI中最常见的错误。Swinject提供了比我们手写版本更丰富的Scope选项。

  • .transient (瞬时): 默认作用域。每次resolve都会创建一个新实例。
  • .container (容器/单例): 在容器的生命周期内,只创建一个实例。后续resolve都返回这同一个实例。这等同于我们手写的singleton
  • .graph (对象图): 这是Swinject一个非常强大且独特的作用域。当resolve一个对象A时,如果其依赖链(A -> B -> D, A -> C -> D)中有多处需要同一个依赖D,.graph能保证在这一次resolve调用中,它们获取到的是同一个D的实例。但如果下次你再次resolve A,你会得到一个全新的A、B、C、D对象图。
  • .weak (弱引用单例): 类似.container,但容器只弱引用该实例。当没有其他地方强引用它时,它会被释放。下次resolve时会重新创建。

团队使用的“黄金法则”

为了避免混淆和误用,我们可以建立一个简单的团队规范:

  1. 无状态或需全局共享的服务(APIService, DatabaseService, UserSession)=> 使用 .container

    container.register(NetworkServicing.self) { _ in
        NetworkService()
    }.inObjectScope(.container) // 明确指定为容器作用域
    
  2. ViewModel 或任何与特定UI/场景绑定的对象 => 永远使用 .transient

    container.register(ProductDetailViewModel.self) { r in
        ProductDetailViewModel(networkService: r.resolve(NetworkServicing.self)!)
    }.inObjectScope(.transient) // 或者不写,因为这是默认值
    

    切记: 将ViewModel注册为.container是导致页面状态混乱的罪魁祸首!

  3. 一次性业务流中的共享依赖 => 谨慎评估使用 .graph

    • 场景: 假设我们有一个“创建订单”的流程,这个流程的Coordinatorresolve出来。这个Coordinator创建了Step1ViewModelStep2ViewModel。如果这两者都需要一个临时的OrderDraft对象来共享草稿数据,那么将OrderDraft注册为.graph作用域是最合适的。这样,在整个“创建订单”流程中,数据得以共享,而当流程结束后,下次再发起创建时,会是一个全新的、干净的OrderDraft

4. 重点攻坚:优雅地解决循环依赖

A依赖B,同时B又依赖A时,构造函数注入会失败,因为在创建A时需要一个完整的B,而在创建B时又需要一个完整的A,这会形成一个死循环。

一个真实的业务例子:

  • AuthenticationManager:负责用户的登录、登出流程。
  • APIService:负责所有网络请求。它需要AuthenticationManager来获取token,并添加到请求头中。
  • 循环点: 当登录失败(如token过期)时,APIService需要通知AuthenticationManager执行登出或刷新token的操作。

这样就形成了 AuthenticationManager -> APIService -> AuthenticationManager 的循环。

Swinject 提供了 initCompleted 这个回调来完美解决此问题。它将注入过程分为两步:

  1. 实例化: 先调用init方法创建对象,此时有循环关系的属性暂时为空。
  2. 属性注入: 在对象创建完成后,Swinject调用initCompleted闭包,此时再去解析并设置那个导致循环的属性。

代码实践:

class CircularDependenciesAssembly: Assembly {
    func assemble(container: Container) {
        
        // 注册 APIService
        container.register(APIService.self) { r in
            APIService(authManager: r.resolve(AuthenticationManager.self)!)
        }.inObjectScope(.container)
        
        
        // 注册 AuthenticationManager,并解决循环依赖
        container.register(AuthenticationManager.self) { _ in
            AuthenticationManager()
        }
        .inObjectScope(.container)
        .initCompleted { r, authManager in
            // 在 authManager 初始化完成后,
            // 再将 APIService 实例注入给它。
            // 此时 APIService 已经可以被正常 resolve 出来了。
            authManager.apiService = r.resolve(APIService.self)!
        }
    }
}

// 对应的类定义
class AuthenticationManager {
    // 使用属性注入来打破循环
    var apiService: APIService!
    
    func login() { /* ... */ }
}

class APIService {
    // 使用构造函数注入
    private let authManager: AuthenticationManager
    
    init(authManager: AuthenticationManager) {
        self.authManager = authManager
    }
}

通过这种方式,我们将强依赖关系(构造函数注入)和弱一些的、可延迟设置的依赖关系(属性注入)结合起来,优雅地打破了初始化时的死循环。

总结与避坑

今天我们掌握了Swinject的核心用法。请记住:

  • 使用Assembly来模块化你的依赖注册。
  • 严格遵守生命周期使用法则,特别是ViewModel必须是.transient
  • 利用initCompleted来解决循环依赖问题。

最后,一个重要的避坑指南: 不要把DI容器本身当作依赖注入到你的业务类中! 如果你发现自己正在写 MyViewModel(container: container),然后在其内部调用container.resolve(XXX.self),那么你又回到了我们第一篇中批评的“服务发现”模式的老路上了。这会再次隐藏依赖,让代码变得难以理解和测试。DI容器应该只存在于应用的“组合根”和少数负责对象创建的“工厂”或“Coordinator”中。

在下一篇中,我们将拓宽视野,看看在声明式的UI世界里,特别是对比Flutter的Riverpod,SwiftUI的依赖注入又有哪些新的玩法和思考。

敬请期待!

依赖注入(二):返璞归真,亲手打造一个迷你“依赖注入容器”

作者 tangzzzfan
2025年7月10日 01:00

在上一篇文章中,我们明确了依赖注入(DI)的核心思想,并达成了共识:构造函数注入是我们的首选,它让依赖关系变得清晰可见。

但随之而来的一个问题是:如果A依赖BB依赖CC又依赖DE... 那么在应用程序的启动点(我们称之为组合根, Composition Root),手动创建和组装这个复杂的对象图谱,将会是一场噩梦。

// 在 AppDelegate 或 SceneDelegate 中...
let database = DatabaseService()
let userCache = UserCache(database: database)
let apiService = APIService(cache: userCache)
let userManager = UserManager(apiService: apiService)
let profileViewModel = ProfileViewModel(userManager: userManager)
let profileVC = ProfileViewController(viewModel: profileViewModel)
// ... 这还只是一个页面的依赖链,想象一下整个App...

这段代码不仅冗长,而且脆弱。任何依赖关系的变化都可能导致这里的大规模改动。

为了优雅地解决这个问题,依赖注入容器 (DI Container) 应运而生。它就像一个智能的“对象工厂”,我们只需要告诉它“配方”(如何创建各种对象),之后每当我们需要一个对象时,直接向容器索取即可,容器会自动处理所有复杂的依赖关系。

今天,我们不直接使用任何第三方框架。我们将亲手打造一个最简化的DI容器,来揭开它神秘的面纱。目的不是重复造轮子,而是为了彻底理解轮子的构造

1. DI容器要解决的核心三要素

一个DI容器,无论多复杂,其核心职责都可以归结为三点:

  1. 注册 (Register): 向容器中“登记”一个服务(通常是一个协议)和它的具体实现。这就像告诉工厂:“以后谁要Engine(引擎),你就给他一个V8Engine的实例。”
  2. 解析 (Resolve): 当需要一个服务的实例时,向容器“索取”。容器会根据之前注册的“配方”,创建并返回一个实例。
  3. 生命周期管理 (Lifecycle Management): 这是DI容器的灵魂。它决定了解析出来的实例是每次都全新的,还是一个共享的单例。

2. Let's Code: 打造我们的迷你容器MiniContainer

我们来创建一个名为MiniContainer的类。

// MiniContainer.swift

final class MiniContainer {
    // 一个字典,用于存储我们的“配方”
    // Key: 服务的类型名称 (String)
    // Value: 一个闭包,这个闭包知道如何创建服务实例
    private var registrations = [String: () -> Any]()

    /// 注册一个服务
    func register<Service>(_ type: Service.Type, factory: @escaping () -> Service) {
        let key = String(describing: type)
        registrations[key] = factory
    }

    /// 解析一个服务
    func resolve<Service>(_ type: Service.Type) -> Service? {
        let key = String(describing: type)
        // 找到配方,执行它,然后尝试转换成我们需要的类型
        return registrations[key]?() as? Service
    }
}

就这么简单!我们已经有了一个具备基本注册和解析功能的容器了。我们用类型名称的字符串作为Key,用一个返回Any的闭包作为Value。

使用它:

// 定义协议和实现
protocol NetworkServicing { func fetchData() }
class NetworkService: NetworkServicing { 
    func fetchData() { 
        print("Fetching data...") 
    } 
}

// 1. 创建容器实例
let container = MiniContainer()

// 2. 注册服务
container.register(NetworkServicing.self) {
    // 这个闭包就是创建NetworkService的“配方”
    NetworkService()
}

// 3. 解析服务
if let networkService = container.resolve(NetworkServicing.self) {
    networkService.fetchData() // 输出: "Fetching data..."
}

3. 升级!引入灵魂要素:生命周期(Scope)

我们目前的实现,每次调用resolve,工厂闭包都会被执行一次,这意味着我们每次得到的都是一个全新的实例。这在DI术语中称为 瞬时 (Transient) 生命周期。

但很多时候,我们希望某些服务在整个App生命周期中只有一个实例,比如数据库连接、网络请求服务等。这就是 单例 (Singleton) 生命周期。

让我们来为容器增加这个至关重要的功能。

首先,定义一个Scope枚举:

enum Scope {
    case transient // 瞬时:每次都创建新实例
    case singleton // 单例:在容器生命周期内共享同一实例
}

然后,我们需要改造MiniContainer来支持它。

// MiniContainer.swift (升级版)

final class MiniContainer {
    // 用于保存单例实例的缓存
    private var singletons = [String: Any]()

    // “配方”现在需要包含生命周期信息
    private typealias Factory = (scope: Scope, factory: () -> Any)
    private var registrations = [String: Factory]()

    /// 注册服务,并指定生命周期
    func register<Service>(_ type: Service.Type, scope: Scope = .transient, factory: @escaping () -> Service) {
        let key = String(describing: type)
        registrations[key] = (scope: scope, factory: factory)
    }

    /// 解析服务
    func resolve<Service>(_ type: Service.Type) -> Service? {
        let key = String(describing: type)
        
        guard let registration = registrations[key] else {
            // 没有注册过这个服务
            return nil
        }

        switch registration.scope {
        case .transient:
            // 瞬时作用域:直接执行工厂闭包,返回新实例
            return registration.factory() as? Service
            
        case .singleton:
            // 单例作用域:
            // 1. 先检查缓存里有没有
            if let instance = singletons[key] as? Service {
                return instance // 有缓存,直接返回
            }
            // 2. 缓存里没有,就创建一个新的
            let newInstance = registration.factory()
            singletons[key] = newInstance // 存入缓存
            return newInstance as? Service
        }
    }
}

4. 场景辨析与常见混淆

现在我们的容器强大多了。但什么时候该用.singleton,什么时候该用.transient呢?这是一个极易混淆且至关重要的点。用错了会导致App行为异常甚至崩溃。

  • 什么时候用 .singleton (单例) ?

    • 无状态服务:NetworkServiceAPIServiceDatabaseManager这类对象,它们本身不存储可变的状态,只是提供方法。让它们成为单例可以节省内存,避免重复创建。
    • 需要全局共享状态的对象: 比如一个UserSessionShoppingCart,你希望在App的任何地方访问到的都是同一个用户会话或购物车。
  • 什么时候用 .transient (瞬时) ?

    • 持有页面或场景相关状态的对象。 最重要的例子就是 ViewModel!
    • 关键案例分析: 想象一个ProductDetailViewModel。我们有两个不同的商品详情页,如果ProductDetailViewModel被注册为.singleton,那么两个页面将共享同一个ViewModel实例!当你在一个页面上选择商品数量时,另一个页面的数量也会跟着变。这绝对是灾难。因此,ViewModel几乎总是应该是.transient的。
  • 警惕“伪单例”陷阱: 我们实现的.singleton,其生命周期是与container实例绑定的。如果container被销毁,那么它缓存的所有单例实例也会随之被销毁。这被称为容器作用域 (Container Scope)。这与我们过去常用的static let shared这种全局静态单例不同,后者会一直存活到App进程结束。容器作用域的单例给了我们更灵活的控制,比如在用户登出后,我们可以销毁旧的容器(以及里面的用户相关单例),再创建一个新的干净容器。

5. 反思与展望:我们手搓容器的局限性

我们亲手打造的MiniContainer已经能很好地说明DI容器的原理了,但它离一个生产级的框架还差得很远:

  • 类型安全问题: 我们用了大量的StringAny类型转换,这在编译时是不安全的。
  • 依赖的依赖: 我们的resolve方法无法自动解析依赖的依赖。例如,注册A时,如果A需要B,我们必须手动写成container.register(A.self) { A(b: container.resolve(B.self)!) }。专业框架能自动完成这个过程。
  • 循环依赖: 如果A依赖BB又依赖A,我们的容器在解析时会陷入无限递归,导致栈溢出崩溃。
  • 线程安全: 我们的registrationssingletons字典在多线程环境下读写是不安全的。
  • 更复杂的生命周期: 比如前面提到的.graph(对象图作用域)。

正是为了解决这些复杂且棘手的问题,我们才需要在真实项目中引入像Swinject这样成熟、稳定、功能强大的开源框架。

通过今天的实践,我们已经完全理解了DI容器的内核。在下一篇文章中,我们将带着这些底层原理的知识,毫无压力地去学习和使用Swinject,并探讨它在项目中的最佳实践。

敬请期待!

依赖注入(一):告别“意大利面条”,从源头理清依赖

作者 tangzzzfan
2025年7月10日 00:51

从今天起,我们将开启一个全新的技术分享系列——深入浅出依赖注入(Dependency Injection, DI)。我希望通过这个系列,能和大家一起探讨如何编写出更健壮、更灵活、更易于测试的代码,最终提升我们整个团队的工程质量和开发效率。

我们都可能遇到过这样的场景:接手一个“祖传”模块,想修改一个小小的功能,却发现牵一发而动全身。代码像一碗缠绕不清的意大利面条,让你无从下手。这种高耦合、职责不清的代码,正是我们要通过引入DI等优秀设计思想来解决的头号敌人。

今天,作为开篇,我们先不谈高深的框架和理论,就从一个我们最熟悉的痛点开始。

1. 我们的痛点:一个典型的OrderViewController

想象一下,我们有一个负责展示订单详情的页面 OrderViewController。它的代码可能是这样的:

// A "traditional" view controller
class OrderViewController: UIViewController {

    private let networkService: NetworkService
    private let databaseManager: DatabaseManager
    private let analyticsTracker: AnalyticsTracker

    // ... 其他属性

    init(orderId: String) {
        // 问题在这里!
        // ViewController 亲自创建了自己的依赖
        self.networkService = NetworkService() // 依赖1:网络服务
        self.databaseManager = DatabaseManager.shared // 依赖2:数据库管理器(单例)
        self.analyticsTracker = AnalyticsTracker(trackingId: "XYZ-123") // 依赖3:分析追踪器

        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        fetchOrderDetails()
    }

    private func fetchOrderDetails() {
        networkService.fetchOrder { [weak self] order in
            self?.databaseManager.save(order)
            self?.analyticsTracker.trackEvent("order_displayed")
            // ... 更新UI
        }
    }
    // ... 其他代码
}

这段代码看起来很“正常”,但它隐藏着几个严重的问题:

  • 极难测试: 你想为 OrderViewController 写个单元测试,测试获取订单后的UI展现逻辑。但只要你创建它,它就会创建一个真实的 NetworkService 并可能发出真实的网络请求。你想用一个模拟的MockNetworkService来返回假数据吗?对不起,做不到,因为NetworkService的创建被写死在了init方法内部。
  • 缺乏灵活性: 某天,产品经理说:“对于VIP用户的订单,我们要用一个新的VipNetworkService来请求,它有不同的缓存策略”。怎么办?你只能去修改OrderViewController的内部代码,增加if/else逻辑。如果未来还有SVIPNetworkService呢?这个类会变得越来越臃肿。
  • 依赖关系模糊: 如果不读init的实现,单从类的定义看,你根本不知道OrderViewController到底需要哪些“帮手”(依赖)才能正常工作。这些依赖关系像“幽灵”一样隐藏在实现细节里。

2. 核心思想:从“我主动去拿”到“你从外部给我”

以上问题的根源在于:OrderViewController 主动承担了创建其依赖(NetworkService等)的责任。

这就像一个厨师,不仅要负责炒菜,还得自己去种菜、养猪、打酱油。这显然不合理。专业的厨师只需要告诉采购员:“我需要顶级的牛肉和新鲜的番茄”,他拿到食材后直接开始烹饪即可。

控制反转 (Inversion of Control, IoC) 就是这个思想。它将“创建依赖”这个控制权,从类的内部“反转”到了类的外部。

依赖注入 (Dependency Injection, DI) 则是实现IoC最常见、最直接的方式。它的口号就是:“别来问我需要什么,直接把你给我的东西拿来用”。

3. DI的三种基本姿势(Swift版)

那么,外部如何把依赖“喂”给我们的类呢?通常有三种方式:

a. 构造函数注入 (Initializer Injection) - 我们的首选!

这是最推荐、最纯粹的DI方式。我们将所有必需的依赖都通过init方法传入。

class OrderViewController: UIViewController {

    private let networkService: NetworkServiceProtocol
    private let databaseManager: DatabaseManagerProtocol
    private let analyticsTracker: AnalyticsTrackerProtocol

    // 依赖通过构造函数“注入”
    init(orderId: String, 
         networkService: NetworkServiceProtocol,
         databaseManager: DatabaseManagerProtocol, 
         analyticsTracker: AnalyticsTrackerProtocol) {
        self.networkService = networkService
        self.databaseManager = databaseManager
        self.analyticsTracker = analyticsTracker
        super.init(nibName: nil, bundle: nil)
    }
    // ...
}

// 在创建它的时候,由外部决定给它什么样的实例
let realService = NetworkService()
let mockService = MockNetworkService() // 用于测试

let vcForProd = OrderViewController(orderId: "123", networkService: realService, ...)
let vcForTest = OrderViewController(orderId: "123", networkService: mockService, ...)

优点:

  • 依赖明确: 一眼就能看出这个类需要哪些依赖才能工作。
  • 保证可用: 实例在创建完成后,所有依赖都已就绪,且不可更改(因为我们用了let)。
  • 非常利于测试: 像上面例子一样,轻松传入Mock对象。

b. 属性注入 (Property Injection)

通过一个可变的属性来注入依赖。

class OrderViewController: UIViewController {
    var networkService: NetworkServiceProtocol! // 注意这里的 '!'
    // ...
}

let vc = OrderViewController(orderId: "123")
vc.networkService = NetworkService() // 在创建后,通过属性设置依赖

适用场景:

  • 当框架负责创建对象时,比如从Storyboard或XIB初始化的ViewController,我们无法干预其init方法。
  • 用于解决循环依赖(我们将在后续文章中深入探讨)。

缺点: 依赖是可变的,且在对象生命周期的某个时刻可能是nil,不够安全。

c. 方法注入 (Method Injection)

只在调用某个特定方法时,才把依赖传进去。

class OrderLogger {
    func logOrder(_ order: Order, using persister: PersistenceProtocol) {
        let logData = format(order)
        persister.save(data: logData)
    }
}

适用场景: 当这个依赖(persister)与类的核心职责关系不大,仅仅是某一个操作需要时,使用方法注入可以避免让整个类都持有它。

4. 重点辨析:依赖注入 vs. 服务发现 (Service Locator)

这是新手中最常见的误区。很多人会把“服务发现”模式误当成DI,因为它看起来也能解决“硬编码创建实例”的问题。

服务发现模式通常长这样:

// 一个全局的服务定位器
class ServiceLocator {
    static let shared = ServiceLocator()
    private lazy var networkService: NetworkServiceProtocol = NetworkService()
    
    func getService<T>() -> T? {
        if T.self == NetworkServiceProtocol.self {
            return networkService as? T
        }
        return nil
    }
}

// 在ViewController中使用
class OrderViewController: UIViewController {
    private let networkService: NetworkServiceProtocol

    init(orderId: String) {
        // 主动去全局的“电话簿”查询服务
        guard let service = ServiceLocator.shared.getService() else {
             fatalError("NetworkService not found!")
        }
        self.networkService = service
        // ...
    }
}

看起来不错,但它其实是“披着羊皮的狼”,会带来新的问题:

  1. 隐藏的依赖(幽灵依赖): OrderViewController的依赖关系又被隐藏起来了。它的init签名没有告诉我们它需要NetworkService,我们必须深入其代码,去寻找ServiceLocator.shared的调用。这让代码的可读性和可维护性急剧下降。
  2. 全局状态与耦合: ServiceLocator是一个全局单例。任何地方都可以访问它,任何地方也可能修改它。你的类不再是独立的,而是和一个看不见的全局状态紧紧地绑在了一起。
  3. 测试的痛苦: 当你测试OrderViewController时,你不能简单地传入一个mock对象了。你必须去处理那个全局的ServiceLocator,比如在测试开始前注册一个mock服务,在测试结束后再清理掉。这非常麻烦,且容易导致测试用例之间互相干扰。

一句话总结:

  • 依赖注入(DI): 依赖关系是明确的(Explicit)。类在“被动”地接收依赖。
  • 服务发现(Service Locator): 依赖关系是模糊的(Implicit)。类在“主动”地查询依赖。

作为团队,我们应该始终追求明确的、可预测的代码,因此,依赖注入是远优于服务发现的选择。

总结与展望

今天我们理清了依赖注入的核心思想:通过将依赖的创建权交由外部,实现类与类之间的解耦,从而极大地提升代码的可测试性、灵活性和可维护性。 我们学习了三种注入方式,并重点辨析了DI与服务发现的本质区别。

现在,你可能会想:“如果我的应用有几十上百个依赖,难道都要在AppDelegate里手动创建和注入吗?那也太复杂了!”

你问到了点子上!这正是DI容器(DI Container)要解决的问题。

在下一篇文章中,我们将亲自动手,从零到一写一个我们自己的迷你DI容器。通过这个过程,你将彻底理解那些流行的DI框架(如Swinject)背后的魔法究竟是什么。

敬请期待!

昨天以前iOS

iOS26适配指南之UINavigationController

作者 YungFan
2025年7月9日 17:06

UINavigationItem

  • 增加了类型为UIString?subtitle,用于设置标准模式下的导航栏的副标题。
  • 增加了类型为UIString?largeTitlelargeSubtitle属性,用于设置 prefersLargeTitles 模式下导航栏的标题与副标题。
  • 增加了类型为UIView?subtitleViewlargeSubtitleView属性,用于设置标准与 prefersLargeTitles 模式下导航栏的副标题视图。

案例

import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        navigationController?.navigationBar.prefersLargeTitles = true
        // iOS26新增
        navigationItem.largeTitle = "导航"
        navigationItem.largeSubtitle = "子标题"
    }
}

效果

UINavigationItem.png

UIBarButtonItem

  • 增加了badge属性,用于设置角标。
  • 增加了prominent样式,用于凸显。
  • 增加了fixedSpace()flexibleSpace()方法,用于调整彼此之间的间距。

案例

import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemGreen

        let barButtonItemOne = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(handleEvent))
        let barButtonItemTwo = UIBarButtonItem(barButtonSystemItem: .bookmarks, target: self, action: #selector(handleEvent))
        let barButtonItemThree = UIBarButtonItem(barButtonSystemItem: .camera, target: self, action: #selector(handleEvent))
        let barButtonItemFour = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(handleEvent))
        // iOS26新增
        barButtonItemOne.badge = .count(10)
        // iOS26新增
        barButtonItemFour.style = .prominent
        // iOS26新增
        let fixedSpace = UIBarButtonItem.fixedSpace(20)
        let flexibleSpace = UIBarButtonItem.flexibleSpace()
        navigationItem.rightBarButtonItems = [barButtonItemOne]
        navigationController?.isToolbarHidden = false
        toolbarItems = [barButtonItemTwo, fixedSpace, barButtonItemThree, flexibleSpace, barButtonItemFour]
    }

    @objc func handleEvent(_ sender: UIBarButtonItem) {
        view.backgroundColor = .init(red: .random(in: 0 ... 1), green: .random(in: 0 ... 1), blue: .random(in: 0 ... 1), alpha: 1.0)
    }
}

效果

UIBarButtonItem.gif

在 SwiftUI 中,如何判断 Text 是否被截断?

作者 Fatbobman
2025年7月9日 08:12

Text 在 SwiftUI 中大量被使用,与 UIKit/AppKit 中对应的组件相比,Text 无需配置,开箱即用,但这也意味着开发者丧失了更多对其的控制能力。在本文中,我们将通过一个实际案例来展示,如何用 SwiftUI 的方式来完成一些看似"不可能"的任务:在一堆给定的视图中,找出第一个文本未被截断的,并以此作为需求尺寸。

❌
❌