You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.24 19:54:07