SwiftUI Widget每秒刷新内存占用过高 如何仅更新文本不重载图片
Widget秒级刷新仅更新文本优化方案
问题背景
首次开发倒计时Widget,已完成全部设计,需求为每秒更新倒计时文本,但刷新过程中每秒都会重新生成图片,导致内存持续上涨、磁盘写入速度达20-30MB/s、CPU占用维持在90-95%,需要实现刷新时跳过图片处理逻辑,仅更新文本内容。
现有问题代码
核心Widget逻辑
struct Provider: IntentTimelineProvider { func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date(), configuration: ConfigurationIntent()) } func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) { let entry = SimpleEntry(date: Date(), configuration: configuration) completion(entry) } func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { var entries: [SimpleEntry] = [] let currentDate = Date() let entryDate = Calendar.current.date(byAdding: .second, value: 1, to: currentDate)! let entry = SimpleEntry(date: entryDate, configuration: configuration) entries.append(entry) let timeline = Timeline(entries: entries, policy: .atEnd) let _ = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timerSec) in WidgetCenter.shared.reloadAllTimelines() } completion(timeline) } } struct SimpleEntry: TimelineEntry { let date: Date let configuration: ConfigurationIntent } struct Widget_Practice_WidgetEntryView : View { @Environment(\.widgetFamily) var family var entry: Provider.Entry var body: some View { switch family { case .systemSmall: CreateSmallDesign() case .systemMedium: CreateMediumDesign() case .systemLarge: CreateLargeDesign() case .systemExtraLarge: CreateExLargeDesign() @unknown default: CreateSmallDesign() } } } @main struct Widget_Practice_Widget: Widget { let kind: String = "Widget_Practice_Widget" var body: some WidgetConfiguration { IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in Widget_Practice_WidgetEntryView(entry: entry) } .configurationDisplayName("Edit Widget") .description("This is an example widget.") } } struct Widget_Practice_Widget_Previews: PreviewProvider { static var previews: some View { Widget_Practice_WidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent())) .previewContext(WidgetPreviewContext(family: .systemSmall)) } }
中等尺寸视图代码
struct CreateMediumDesign : View { var body: some View { ZStack { Image(uiImage: resizeImage(image: UIImage(named: demoCountdownDatabase[1].imageName), targetSize: CGSize(width: 250, height: 250))!) .resizable() .scaledToFill() .blur(radius: 30) VStack { HStack(alignment: .center, spacing: 15) { Text("") Image(uiImage: resizeImage(image: UIImage(named: demoCountdownDatabase[1].imageName), targetSize: CGSize(width: 250, height: 250))!) .resizable() .scaledToFill() .frame(width: 80, height: 80, alignment: .center) .clipShape(RoundedRectangle(cornerRadius: 15)) .overlay(RoundedRectangle(cornerRadius: 15).stroke(Color.white, lineWidth: 2)) .shadow(radius: 10) VStack(alignment: .leading) { Text(demoCountdownDatabase[1].title) .font(.largeTitle) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .scaledToFill() Text(demoCountdownDatabase[1].subtitle) .font(.body) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .scaledToFill() } Spacer() } GeometryReader { geometry in HStack(alignment: .center, spacing: 0) { Text(fullDateUntilString(date: demoCountdownDatabase[1].toDate, title: "Year")) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(fullDateUntilString(date: demoCountdownDatabase[1].toDate, title: "Month")) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(fullDateUntilString(date: demoCountdownDatabase[1].toDate, title: "Day")) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(fullDateUntilString(date: demoCountdownDatabase[1].toDate, title: "Hour")) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(fullDateUntilString(date: demoCountdownDatabase[1].toDate, title: "Minute")) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(fullDateUntilString(date: demoCountdownDatabase[1].toDate, title: "Second")) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) } } .frame(height: 15) } } } }
图片缩放工具代码
func resizeImage(image: UIImage?, targetSize: CGSize) -> UIImage? { if image != nil { let size = image!.size let widthRatio = targetSize.width / size.width let heightRatio = targetSize.height / size.height var newSize: CGSize if(widthRatio > heightRatio) { newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) } else { newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) } let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) image!.draw(in: rect) let newImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return newImage ?? nil } else { return nil } }
具体优化步骤
- 预生成并缓存缩放后的图片
图片不需要随倒计时刷新,提前在初始化阶段一次性完成缩放,存入全局缓存,后续渲染直接复用。新增全局缓存代码:
// 全局缓存,Widget首次加载时仅执行一次图片缩放 enum CachedWidgetAsset { static let countdownBgImage: UIImage? = resizeImage( image: UIImage(named: demoCountdownDatabase[1].imageName), targetSize: CGSize(width: 250, height: 250) ) static let countdownThumbImage: UIImage? = resizeImage( image: UIImage(named: demoCountdownDatabase[1].imageName), targetSize: CGSize(width: 80, height: 80) ) }
修改CreateMediumDesign中的图片引用,直接使用缓存:
// 背景模糊图直接用缓存,无需每次缩放 Image(uiImage: CachedWidgetAsset.countdownBgImage!) // 缩略图直接用缓存,无需每次缩放 Image(uiImage: CachedWidgetAsset.countdownThumbImage!)
- 移除无效的主动刷新Timer
原代码中getTimeline每次生成时间线都创建重复的Timer调用reloadAllTimelines,属于多余逻辑,还会导致重复刷新占用资源。直接使用Widget原生的Timeline机制实现秒级更新,一次性生成未来一段时间的倒计时条目即可:
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { var entries: [SimpleEntry] = [] let currentDate = Date() let targetDate = demoCountdownDatabase[1].toDate // 一次性生成未来5分钟的300个条目,系统会自动按时间触发刷新 for secondOffset in 0..<300 { let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset, to: currentDate)! // 提前计算倒计时文本存入Entry let yearText = fullDateUntilString(date: targetDate, title: "Year") let monthText = fullDateUntilString(date: targetDate, title: "Month") let dayText = fullDateUntilString(date: targetDate, title: "Day") let hourText = fullDateUntilString(date: targetDate, title: "Hour") let minuteText = fullDateUntilString(date: targetDate, title: "Minute") let secondText = fullDateUntilString(date: targetDate, title: "Second") let entry = SimpleEntry( date: entryDate, configuration: configuration, yearText: yearText, monthText: monthText, dayText: dayText, hourText: hourText, minuteText: minuteText, secondText: secondText ) entries.append(entry) } let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) }
- 修改Entry结构存储预计算文本
将需要渲染的倒计时文本提前在生成Entry的时候计算完成,不需要在View渲染时每次调用fullDateUntilString,进一步降低渲染开销。修改SimpleEntry结构:
struct SimpleEntry: TimelineEntry { let date: Date let configuration: ConfigurationIntent // 新增预计算的倒计时文本 let yearText: String let monthText: String let dayText: String let hourText: String let minuteText: String let secondText: String }
- 修改视图直接使用Entry文本
渲染时直接使用Entry中的预计算文本,配合SwiftUI的差异渲染机制,只会更新文本部分,不会重绘不变的图片:
struct CreateMediumDesign : View { var entry: SimpleEntry // 传入Entry获取预计算的文本 var body: some View { ZStack { // 不可变的图片部分,刷新时不会重绘 Image(uiImage: CachedWidgetAsset.countdownBgImage!) .resizable() .scaledToFill() .blur(radius: 30) // 可变的文本部分,仅当entry变化时重绘 VStack { HStack(alignment: .center, spacing: 15) { Text("") Image(uiImage: CachedWidgetAsset.countdownThumbImage!) .resizable() .scaledToFill() .frame(width: 80, height: 80, alignment: .center) .clipShape(RoundedRectangle(cornerRadius: 15)) .overlay(RoundedRectangle(cornerRadius: 15).stroke(Color.white, lineWidth: 2)) .shadow(radius: 10) VStack(alignment: .leading) { Text(demoCountdownDatabase[1].title) .font(.largeTitle) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .scaledToFill() Text(demoCountdownDatabase[1].subtitle) .font(.body) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .scaledToFill() } Spacer() } GeometryReader { geometry in HStack(alignment: .center, spacing: 0) { Text(entry.yearText) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(entry.monthText) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(entry.dayText) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(entry.hourText) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(entry.minuteText) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) Text(entry.secondText) .font(.footnote) .frame(width: geometry.size.width / 6.1, height: 35) .foregroundColor(.white) .shadow(color: .black, radius: 2, x: 0, y: 0) .multilineTextAlignment(.center) } } .frame(height: 15) } } } }
注意:其他尺寸的视图(小尺寸、大尺寸等)也按照相同逻辑修改图片引用即可。
内容的提问来源于stack exchange,提问作者Travgalax
相关产品推荐
相关产品推荐

