I want to accomplish recursive rendering in my SwiftUI app. This is desirable when rendering a very large array of data, and only a small portion of it needs to be updated during each View update. I asked a similar question here earlier, and @Rob Mayoff pointed me towards SwiftUI's ImageRenderer
API. But his code used the MVC design pattern, which I want to avoid. I have implemented a "minimal reproducible example" app that works (although it has two problems which are the focus of this question). For simplicity the code below is macOS only, but only a few small changes are required to make it iOS or multi-platform.
The DataGenerator
class (with protocol ObservableObject
) updates a variable called myVariable
every tenth of a second, and publishes it to all Views. This app contains only one View called MyView
which observes the instance of DataGenerator
, and hence gets redrawn every time myVariable
changes. MyView
declares a static variable called myImage
using SwiftUI's Image()
command. I then create a Canvas and initialize it by drawing the current myImage
into it. I then add a horizontal line (with vertical height determined by myVariable
). I then use the ImageRenderer()
command to turn the Canvas into a bitmap image data called renderer
, and I render this renderer
using SwiftUI's Image()
command.
The final statement updates the original myImage
with the renderer
bitmap image data to form the recursive loop.
MyView.myImage = Image(nsImage: renderer .nsImage!)
Unfortunately, this is non-View code and Xcode complains if I try to put it in my MyView
struct. So I have had to resort the the ExecuteCode
struct trick suggested here by @Andrew_STOP_RU_WAR_IN_UA .
The code below is the complete Swift app. It is also on GitHub here. You can copy it into Xcode and run it. But it has two problems:
1.) The image rendered onscreen get progressively fuzzy with each iteration. The new horizontal lines are sharp and distinct when they first appear, but you can watch them become more fuzzy as they drift into the background with each iteration. In the SO Q&A here, @iOSDevil encountered a related problem which suggests that my problem is the updating of myImage
using the Image representation of the NSImage, which is a view representation and thus using the “screen sized image” rather than updating with the full-resolution of the underlying NSImage. I'm not sure that this is the cause of my fuzziness problem, but I don't know what to do about it.
2.) The app has a memory leak. As the app continues to run (and the window gets progressively filled with horizontal lines), the RAM usage indicator goes continuosly upward until the app freezes when it runs out of system memory. I am speculating that the renderer
bitmap is not being properly disposed of after it has been used to update myImage
. I want to make sure that this memory leak is not due to poor programming on my part, before I bother Apple with a bug report.
I would appreciate any suggestions on how to implement recursive rendering simply and efficiently. I am not married to SwiftUI, so, if there is a simpler approach, please tell me about it.
import SwiftUI
@main
struct RecursiveRenderingApp: App {
var body: some Scene {
WindowGroup {
MyView()
.environmentObject(DataGenerator.generator)
}
}
}
class DataGenerator: ObservableObject {
static let generator = DataGenerator()
@Published var myVariable: Double = 0.0
init() {
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
self.myVariable = Double.random(in: 0.0 ... 1.0)
}
}
}
@MainActor
struct MyView: View {
@EnvironmentObject var generator: DataGenerator
static let mySize = CGSize(width: 1_000, height: 1_000)
static var myImage = Image(size: mySize, opaque: true) { gc in
gc.fill(Path(CGRect(origin: .zero, size: mySize)), with: .color(.white))
}
var body: some View {
let canvas = Canvas { context, size in
context.draw(MyView.myImage, in: CGRect(origin: .zero, size: MyView.mySize))
var path = Path()
path.move( to: CGPoint( x: 0.0, y: generator.myVariable * size.height ) )
path.addLine(to: CGPoint( x: size.width, y: generator.myVariable * size.height ) )
context.stroke( path, with: .color( Color.black ), lineWidth: 1.0 )
}.frame(width: MyView.mySize.width, height: MyView.mySize.height)
let renderer = ImageRenderer(content: canvas)
Image(nsImage: renderer.nsImage!)
.resizable()
.aspectRatio(contentMode: .fill)
ExecuteCode {
MyView.myImage = Image(nsImage: renderer.nsImage!)
}
}
}
// Use this struct to insert non-View code into a View. (Use with caution.):
struct ExecuteCode : View {
init( _ codeToExec: () -> () ) {
codeToExec()
}
var body: some View {
EmptyView()
}
}
I submitted the above code to Apple Developer Technical Support seeking a solution to it's problems. An Apple engineer responded that my approach inevitably leads to memory spikes and lossy compression of the image data each time it is exported and re-rendered. It inevitably yields fuzzy lines and memory-leaks. He provided the much-improved code reprinted below.
The good news is that his code is very clean well-written multi-platform Swift code. It produces sharp (non-fuzzy) lines, and does not have memory leaks.
The bad news is that it is not ''recursive rendering" as I have defined it. It draws all paths for each myVariable update - including redrawing the ones from previous updates that have not changed.
In my mind, this is still extremely inefficient. Nevertheless, I am printing his solution below to help other SO users who might benefit from it in their particular application. I am still seeking an efficient solution which requires me to render paths only once and have them persist in my onscreen window.
import SwiftUI
@main
struct RecursiveRenderingApp: App {
@StateObject var generator = DataGenerator()
var body: some Scene {
WindowGroup {
MyView()
.environmentObject(generator)
}
}
}
class DataGenerator: ObservableObject {
@Published var myVariable: Double = 0.0
init() {
Timer.publish(every: 0.1, on: .main, in: .common)
.autoconnect()
.map { _ in Double.random(in: 0.0...1.0) }
.assign(to: &$myVariable)
}
}
struct MyView: View {
@EnvironmentObject var generator: DataGenerator
@StateObject private var renderer = ImageRenderer(content: LineCanvas(values: []))
var body: some View {
LineCanvasImage(renderer: renderer)
.onReceive(generator.$myVariable) { value in
renderer.content.values.append(value)
}
}
}
struct LineCanvasImage: View {
@ObservedObject var renderer: ImageRenderer<LineCanvas>
var body: some View {
if let cgImage = renderer.cgImage {
Image(decorative: cgImage, scale: renderer.scale)
.frame(width: LineCanvas.size.width, height: LineCanvas.size.height)
}
}
}
@MainActor
struct LineCanvas: View {
static let size = CGSize(width: 1_000, height: 1_000)
var values: [Double]
var body: some View {
Canvas { context, size in
for value in values {
var path = Path()
path.move(to: CGPoint(x: 0, y: value * size.height))
path.addLine(to: CGPoint(x: size.width, y: value * size.height))
context.stroke(path, with: .color(.black), lineWidth: 1)
}
}
.frame(width: Self.size.width, height: Self.size.height)
}
}