虽然 SwiftUI 的慵懒容器以及 Core Data 都有各自的内存占用优化机制,但跟着运用视图内容的杂乱( 图文混排 ),越来越多的开发者遇到了内存占用巨大乃至由此导致 App 崩溃的状况。本文将经过对一个演示 App 进行逐渐内存优化的方式( 由原先显现 100 条数据要占用 1.6 GB 内存,优化至显现数百条数据仅需 200 多 MB 内存 ),让读者对 SwiftUI 视图的存续期、慵懒视图中子视图的生命周期、保管目标的惰值特性以及耐久化存储和谐器的行缓存等内容有更多的了解。

可在 此处 下载本文所需的代码

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

一个内存占用量巨大的 App

本节中,咱们将创立一个在 List 中对 Core Data 数据进行阅读的演示 App。

本例中,Core Data 的数据模型十分简单,只要两个 Entity :Item 和 Picture。Item 与 Picture 之间是1对1的联系。为了尽量不影响 SQLite 数据库的操作功用,咱们为 Picture 的 data 特色启用了 Allows External Storage 选项。

SwiftUI + Core Data App 的内存占用优化之旅

SwiftUI + Core Data App 的内存占用优化之旅

敞开 Allows External Storage 后,SQLite 会主动将尺度大于必定要求( 100KB )的 Binary 数据以文件的方式保存在与数据库文件同级目录的躲藏子目录中。数据库字段中仅保存与该文件对应的文件 ID ( 50 个字节 )。一般为了确保数据库的功用,开发者会为尺度较大的 Binary 特色敞开这一选项。

列表视图适当简单:

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(items) { item in
                        ItemCell(item: item)
                    }
                }
            }
        }
    }
}

单元格视图也是采用了常见的方式:

struct ItemCell: View {
    @ObservedObject var item: Item
    let imageSize: CGSize = .init(width: 120, height: 160)
    var body: some View {
        HStack {
            Text(self.item.timestamp?.timeIntervalSince1970 ?? 0, format: .number)
            if let data = item.picture?.data, let uiImage = UIImage(data: data), let image = Image(uiImage: uiImage) {
                image
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: self.imageSize.width, height: self.imageSize.height)
            }
        }
        .frame(minWidth: .zero, maxWidth: .infinity)
    }
}

生成数据后,运转后显现的状况如下:

SwiftUI + Core Data App 的内存占用优化之旅

Add 100 按钮将创立 100 条记载, 记载数 为当时的数据条数,内存占用 为当时 App 的内存占用状况。详细完成可查看本文演示代码。

在咱们创立完 100 条数据后,重启运用( 重启能够更精准地丈量内存占用状况 )并翻滚列表至底部。此刻该运用的内存占用为 1.6 GB 左右。此刻请不要惊奇,你能够测验点击添加数据按钮继续添加数据,再次翻滚到底部,你将看到愈加令人震惊的内存占用数值,不过有极大的可能会看不到( 运用现已崩溃了 )。

SwiftUI + Core Data App 的内存占用优化之旅

从 Instruments 的剖析来看,跟着列表的翻滚,内存占用继续添加中。

SwiftUI + Core Data App 的内存占用优化之旅

相信任何开发者都无法忍受这种内存占用的状况出现。下文中,咱们将对这段代码进行逐渐优化,以达到终究可用的程度。

第一轮优化:对视图 body 值进行优化

在第一轮优化中,咱们会首要测验从 SwiftUI 的视点下手。

SwiftUI 的慵懒视图容器具有对契合 DynamicViewContent 协议的内容( 经过 ForEach 生成的内容 )进行优化的才能。在正常的状况下( 慵懒容器中仅包括一个 ForEach ,且子视图没有运用 id 添加显式标识 ),慵懒容器仅会创立当时可见范围内的子视图实例,并对其 body 进行求值( 烘托 )。

当子视图进入慵懒容器的可视区域时,SwiftUI 会调用它的 onAppear 闭包,子视图退出可视区域时,会调用 onDisappear 闭包。开发者一般会利用这两个调用时机来完成数据预备和善后工作。

虽然从表面上来看,慵懒容器仅会在视图进入可视区域时才会对其进行操作,但一旦该视图被显现过( body 被求过值 ),即便该视图脱离可视区域,SwiftUI 仍会保存视图的 body 值。这意味着,在慵懒容器中,视图一经创立,其存续期将与该容器共同( 容器不毁掉,则视图将一直存续 )。

在本例中,子视图的 body 值中必定会包括用于显现的图片数据,因而,即便该视图现已被显现过( 翻滚出显现区域 ),该视图的 body 值仍将占用不小的内存。

咱们能够经过在 onAppear 以及 onDisappear 中对图片的显现与否( 变量 show )进行控制( 迫使 SwiftUI 对视图的 body 从头求值 ),然后削减因上述原因所添加的内存占用。

对 Cell 视图代码( ItemCell.swift )进行如下调整:

struct ItemCell: View {
    @ObservedObject var item: Item
    @Environment(\.managedObjectContext) var viewContext
    let imageSize: CGSize = .init(width: 120, height: 160)
    @State var show = true
    var body: some View {
        HStack {
            if show { // 仅当处于慵懒容器可视区域时采显现内容
                Text(self.item.timestamp?.timeIntervalSince1970 ?? 0, format: .number)
                if let data = item.picture?.data, let uiImage = UIImage(data: data), let image = Image(uiImage: uiImage) {
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: self.imageSize.width, height: self.imageSize.height)
                }
            }
        }
        .frame(minWidth: .zero, maxWidth: .infinity)
        .onAppear {
            show = true // 进入可视区域时显现
        }
        .onDisappear {
            show = false // 退出可视区域时不显现
        }
    }
}

经过上面简单的改动,当时 App 的内存占用状况便有了显著的改进。翻滚到底部后( 100 条数据 ),内存的占用将在 500 MB 左右。

SwiftUI + Core Data App 的内存占用优化之旅

Instruments 会导致优化后的结果显现不精确,内存占用数据将以 App 中的显现以及 Xcode Navigator 的 Debug 栏内容为准。假如翻滚过快,可能会导致内存占用增大。估量与体系无暇进行整理操作有关。

SwiftUI + Core Data App 的内存占用优化之旅

虽然上述优化技巧可能会对翻滚的流畅度产生必定的影响( 视觉上不明显 ),不过考虑到它所带来的巨大收益,在本例中应该是一个适当不错的挑选。

同未优化过的代码相同,跟着数据量的增大,内存的占用也将随之进步。在 400 条记载的状况下,翻滚到底部,内存占用值差不多是 1.75 GB。虽然咱们节约了差不多 70% 的内存占用,但仍无法彻底满足需求。

第二轮优化:让保管目标回归慵懒状况

在第二轮优化中,咱们将测验从 Core Data 中找寻处理之道。

首要,咱们需要对保管目标的惰值特性以及和谐器的“行缓存”概念有所了解。

存储和谐器的行缓存( Row cache in coordinator )

在 Core Data Stack 的多层结构中,存储和谐器( NSPersistentStoreCoordinator )正好处于耐久化存储与保管上下文之间。其向保管上下文以及耐久化存储供给了单个的共同接口,一个和谐器便能够应对多个上下文以及多个耐久化存储。

SwiftUI + Core Data App 的内存占用优化之旅

在和谐器具备的很多功用中,“行缓存”是其间很有特色的一个。所谓行缓存,便是指当 Core Data 从 SQLite 中获取数据时,首要将数据以接近原始存储格局的方式保存内行缓存( 内存 )中。并根据上下文的需要,用对应的数据向特定的保管目标进行填充( 实例化 )。行缓存的真实含义在于,在有多个保管上下文( NSMangedObjectContext )与和谐器相关时,关于同一条记载( NSManagedObjectID 共同 )的内容,无需进行多次 IO 操作,能够直接从行缓存中获取( 假如能够命中的话 )。

从当今移动开发的视点来说,行缓存如同存在的含义不大,但考虑到 Core Data 的前身主要用来处理金融类数据业务,在此种场景中,行缓存能够带来适当可观的收益。

因为行缓存机制的存在,当咱们经过 Core Data 从数据库中获取某个数据时( 例如图片 ),行缓存中会有一份副本。

保管目标的惰值特性

保管目标( NSManagedObject )除了只能在创立其的保管上下文中进行操作外,按需填充也是保管目标的重要特性之一。

在开发者经过创立一个 Request ( NSFetchRequest )从数据库中获取查询结果时,除非特别将 Request 的 returnsObjectsAsFaults 特色设置为 false ,不然保管上下文并不会给保管目标的保管特色( @NSManaged )回来真实的数据。只要在访问这些保管特色时,Core Data 才会为保管目标进行数据填充( 假如行缓存中有,从缓存中取;假如没有则将数据从数据库中搬运到行缓存后再从缓存中取 )。

惰值特性是 Core Data 的重要特性之一。它确保了,只在真实对数据有需求时,才对数据进行获取( 实例化 )。在进步了功用的一起,也尽量削减了对内存的占用。

在本例中,只要视图首次出现在 List 的可视区域时,Item 才会被填充数据。

在保管目标从惰值状况( Fault )脱离后,只要在几种特定的条件下,才会从头转换为惰值。例如经过调用 refresh 或 refreshAllObjects 办法。

除非特别设置 relationshipKeyPathsForPrefetching 特色,不然除了实体( Entity )本身的特色( Attribute )外,Core Data 对与 Entity 有相关的联系( Relationship )也采用了默许的慵懒填充规矩( 即便 returnsObjectsAsFaults 为 false )。

数据的多份拷贝

当图片数据从 SQLite 经 Core Data 终究经过 SwiftUI 显现时,实际上在内存中至少保存了三份拷贝:

  • 行缓存
  • 保管目标上下文( 保管目标被填充后 )
  • 显现该图片的 SwiftUI 视图( body 的值中 )

在第一轮优化中,咱们经过显现控制,修改了脱离可视区域的视图 body 值( 删去了一份 Copy )。假如咱们能够在视图脱离可视区域时,能让保管目标从头进入惰值状况,或许又能节约一部分内存。

因为一个和谐器能够对应多个上下文,假如在另一个上下文中,指向同一个图片的另一个保管目标也进行了填充,那么就又会多出一个 Copy

不成功的优化

在首轮优化后的代码基础上,做如下添加:

       .onDisappear {
            show = false
            // 在视图脱离可视区域时,测验让 Item 以及对应的 Picture 目标回来惰值状况
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                viewContext.refresh(item, mergeChanges: false)
                if let picture = item.picture {
                    viewContext.refresh(picture, mergeChanges: false)
                }
            }
        }

修改后运转程序,咱们会惊异地发现 —— 简直没有变化

原因何在???

经过代码查看保管目标会发现,虽然保管目标现已转为慵懒状况,但实际上并没有节约多少内存。这是因为,咱们在定义 Picture 的 data 特色时,设置了 Allows External Storage 选项。这意味着,在保管目标上下文中,data 特色即便在填充后也仅有 50 个字节( 文件 ID )。

现在无法找到 Core Data 内行缓存以及上下文中处理这些外置二进制数据的任何材料。不过经过试验中剖析,这些数据肯定是被缓存的,且在被加载后,并不会因为回来惰值而主动从内存中铲除

因而,即便咱们将保管目标回来成惰值状况,也仅能节约很少的内存占用( 在本例中简直能够忽略不计 )。

效果有限但潜力不小的优化

为了能对图片数据在上下文中的体现有愈加精准的控制,我修改了 data 特色的设置,取消了 Allows External Storage 选项。

为了确保程序顺利运转,需要从模拟器( 或真机 )上首要删去 App,然后再从头安装

相较于第一轮的优化,本次优化后内存占用有了必定的改进( 幅度不到 100 MB )。

SwiftUI + Core Data App 的内存占用优化之旅

虽然本轮优化的效果一般( 且数据添加后,内存占用仍呈线性增长 ),但至少标明是有时机从 Core Data 中找到可优化的视点。

终极优化:私有上下文 + 不持有保管目标

思路

在第二轮优化中,虽然经过将保管目标转换为惰值处理了一部分内存占用问题,但存在于行缓存中的数据一直仍是无法得到有效铲除。是否有可能将上下文以及行缓存中数据所占空间一起优化掉?

为了削减内存占用,Core Data 关于不需要的数据空间采用活跃的开释策略。假如一个保管目标失去了强引证,那么 Core Data 将很快便开释掉它所占用的上下文中的内存空间。假如一条记载( 数据库中的数据 ),无论哪个上下文中都没有与其对应的保管目标,那么也将快速地整理其所占用的行缓存空间。

也就是说,假如咱们能让数据仅在视图出现在慵懒容器可见范围内,才创立一个指向该数据的保管目标,并且在视图脱离可视区域时,删去该目标( 放弃引证 ),那么就能够经过 Core Data 本身的内存开释机制来完成本轮优化

根据上述原理,咱们将测验如下过程:

  • 在 onAppear 的闭包中,经过私有上下文创立一个 Picture 目标
  • 将 data 特色的数据转换成 Image,并保存在视图中的一个 Source of truth 中
  • 在视图显现该 Image
  • onAppear 闭包运转结束时,Picture 目标将主动被开释
  • 在 onDisapper 中铲除 Source of truth 中的内容( 设置为 nil )

依照预想,因为该 Picture 保管目标仅存活于视图的 onAppear block 中,闭包执行完毕后,Core Data 会主动开释上下文以及行缓存中对应的数据。

代码如下:

struct ItemCell: View {
    @ObservedObject var item: Item
    @State var image: Image?
    @Environment(\.managedObjectContext) var viewContext
    let imageSize: CGSize = .init(width: 120, height: 160)
    @State var show = true
    var body: some View {
        HStack {
            if show {
                Text(self.item.timestamp?.timeIntervalSince1970 ?? 0, format: .number)
                if let image = image {
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: self.imageSize.width, height: self.imageSize.height)
                } else {
                    Rectangle()
                        .frame(width: self.imageSize.width, height: self.imageSize.height)
                }
            }
        }
        .frame(minWidth: .zero, maxWidth: .infinity)
        .onAppear {
            show = true
            Task {
                if let objectID = item.picture?.objectID { // 获取 ObjectID 并不会触发慵懒填充
                    let imageData: Data? = await PersistenceController.shared.container.performBackgroundTask { context in
                        if let picture = try? context.existingObject(with: objectID) as? Picture, let data = picture.data {
                            return data
                        } else { return nil }
                    }
                    if let imageData {
                        image = Image(uiImage: UIImage(data: imageData)!)
                    }
                }
            }
        }
        .onDisappear {
            show = false
            image = nil
        }
    }
}

抱负很饱满,实际很骨感,执行上述代码后,内存并不会有很大的改进。问题又出现在什么地方呢?

开释不活跃的 @State

上面代码的问题,是因为咱们运用了声明为 @State 的变量来暂存 Image。在慵懒容器中,与活跃开释 body 所占内存容量的策略不同,@State 对应值的开释并不活跃。即便咱们在 onDisappear 中将该变量设置为 nil,但 SwiftUI 并没有开释之前它所占用的空间。

以下面的代码举例:

struct MemeoryReleaseDemoByState: View {
    @State var data: Data?
    @State var memory: Float = 0
    var body: some View {
        VStack {
            Text("memory :\(memory)")
            Button("Generate Data") {
                data = Data(repeating: 0, count: 10000000)
                memory = reportMemory()
            }
            Button("Release Memory") {
                data = nil
                memory = reportMemory()
            }
        }
        .onAppear{
            memory = reportMemory() // reportMemory 将陈述当时 app 的内存占用,完成请查看本文典范代码
        }
    }
}

首要点击 “Generate Data”,然后点击 “Release Memory”,你会发现虽然 data 设置为 nil,但 app 所占据的内存空间并没有削减

在这种状况下,咱们能够经过引证类型来创立一个 Holder,经过该持有器,处理开释不活跃的问题。

struct MemeoryReleaseDemoByStateObject: View {
    @StateObject var holder = Holder()
    @State var memory: Float = 0
    var body: some View {
        VStack {
            Text("memory :\(memory)")
            Button("Generate Data") {
                holder.data = Data(repeating: 0, count: 10000000)
                memory = reportMemory()
            }
            Button("ReleaseMemory") {
                holder.data = nil
                memory = reportMemory()
            }
        }
        .onAppear{
            memory = reportMemory()
        }
    }
    class Holder:ObservableObject {
        @Published var data:Data?
    }
}

SwiftUI 只会持有 @StateObject 所对应实例的引证,实例中特色数据的开释仍遵从标准的 Swift 言语逻辑。因而,经过 Holder,咱们能够依照自己的想法开释不需要的内存

修改后的代码:

struct ItemCell: View {
    @ObservedObject var item: Item
    @StateObject var imageHolder = ImageHolder()
    @Environment(\.managedObjectContext) var viewContext
    let imageSize: CGSize = .init(width: 120, height: 160)
    @State var show = true
    var body: some View {
        HStack {
            if show {
                Text(self.item.timestamp?.timeIntervalSince1970 ?? 0, format: .number)
                if let image = imageHolder.image {
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: self.imageSize.width, height: self.imageSize.height)
                } else {
                    Rectangle()
                        .frame(width: self.imageSize.width, height: self.imageSize.height)
                }
            }
        }
        .frame(minWidth: .zero, maxWidth: .infinity)
        .onAppear {
            show = true
            Task {
                if let objectID = item.picture?.objectID {
                    let imageData: Data? = await PersistenceController.shared.container.performBackgroundTask { context in
                        if let picture = try? context.existingObject(with: objectID) as? Picture, let data = picture.data {
                            return data
                        } else { return nil }
                    }
                    if let imageData {
                        imageHolder.image = Image(uiImage: UIImage(data: imageData)!)
                    }
                }
            }
        }
        .onDisappear {
            show = false
            self.imageHolder.image = nil
        }
    }
}
class ImageHolder: ObservableObject {
    @Published var image: Image?
}

在终究的代码中,咱们对图片数据在内存中的三个备份完成了有效的控制。在同一时间( 抱负状况下 ),只要出现在可视区域的图片数据才会保存在内存中。

SwiftUI + Core Data App 的内存占用优化之旅

能够加大检测力度,即便在生成了 400 条记载的状况下,内存占用也依然被控制在一个适当抱负的状况( 下图为 400 条数据翻滚到底部的内存占用状况 )。

SwiftUI + Core Data App 的内存占用优化之旅

至此,咱们终于完成了对该段代码的优化,无需再担心其可能因占用内存过大而导致的崩溃。

总结

SwiftUI 的慵懒容器运用起来很便利,并且经过 @FetchRequest 与 Core Data 合作也很便利,这在必定程度上导致开发者有了轻视的心思,以为 SwiftUI + Core Data 会为咱们处理一切。但在有些状况下,咱们依然需要经过自己对两者的深化了解对代码进行高度优化才能取得预期的效果。

期望本文能够对你有所协助。一起也欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行交流。

订阅下方的 邮件列表,能够及时取得每周的 Tips 汇总。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】