返回

iOS Widget 默认Intent设置:解决初次加载空白问题

IOS

为 iOS Widget 设置新的默认 Intent 值

当开发 iOS 小组件 (Widget) 时,一个常见的需求是:让小组件初次添加时显示有意义的默认值。例如,天气预报类小组件应当默认显示用户上次使用 App 时查询的位置。IntentHandler 中的默认位置功能通常会在 App 首次启动时被调用,但不会在小组件刚被添加时执行。这导致用户首次添加小组件后看到的是一个空白或无效状态。开发者必须解决这个矛盾。

问题分析

小组件的 Intent 对象在其被添加到桌面时就被初始化,其生命周期独立于 App。默认的 Intent 值由 IntentHandler 提供,仅在 Widget 初次初始化,或在 App 运行后、并经用户更新其意图配置的时候会被设定。所以当Widget被用户第一次创建时,如果App还没有运行过,或者用户的location还没有发生过选择变化,系统使用的依然是初始化 Intent时默认设定的 nil 或者其他预设的初始默认值,而并非上一次 App 使用记录的位置。因此,单纯依靠 IntentHandler 中返回的动态默认值无法满足需求,需要额外处理逻辑来保证默认位置的正确性。

解决方案一: 使用 App 存储的配置初始化 Widget Intent

一个解决方案是在 App 中维护一个位置数据的持久化存储(比如 UserDefaults 或者 Core Data),然后在 IntentHandler 初始化时,尝试从持久化存储读取上次 App 使用时的位置,并将该位置信息设置为 Widget Intent 的默认值。这个方法可以让小组件首次加载的时候,显示和上次应用使用时相同的信息。

步骤:

  1. 在 App 中保存位置信息: 当用户选择位置或者在应用程序中使用完位置服务之后,使用 UserDefaults 或者 Core Data 等机制来存储位置信息,或者其他任何形式持久化存储都可以。
  2. IntentHandler 中读取: 修改 IntentHandler,让它从存储读取保存的位置信息,并作为 Intent 初始化的默认值。

代码示例:
假设使用 UserDefaults:

// 在 App 中更新和保存位置的代码 (比如,在选择或接收到用户当前位置更新后执行)
func saveLastLocation(locationName: String) {
    UserDefaults.standard.set(locationName, forKey: "lastLocation")
}

// IntentHandler 中的实现
import WidgetKit
import Intents

class IntentHandler: INIntentHandler {

    override func defaultLocation(for intent: MyWeatherIntent) async -> MyLocation? {
       // 从 UserDefaults 获取上次位置
       let lastLocationName = UserDefaults.standard.string(forKey: "lastLocation")

       // 如果读取到有效的 lastLocationName,就用它创建 MyLocation,
       // 否则返回一个默认位置 (可选)。

      if let lastLocationName = lastLocationName, !lastLocationName.isEmpty {
            let location = MyLocation(identifier: lastLocationName, display: lastLocationName)
           return location
       } else {
           // 这里返回一个备用的默认位置(如果需要),而不是 nil
          return MyLocation(identifier: "defaultLocation", display: "Default Location") // 默认返回其他值以确保 UI 正常渲染
       }
    }
    
    
    
    
    override func resolveLocation(for intent: MyWeatherIntent, with completion: @escaping (INObjectResolutionResult<MyLocation>) -> Void) {
      if let location = intent.location {
         if !location.identifier.isEmpty && !location.display.isEmpty {
             completion(.success(with:location))
             return
         }
     }

     completion(.needsValue())

   }


  override func confirm(intent: MyWeatherIntent, completion: @escaping (MyWeatherIntentResponse) -> Void) {
      
      //处理意图,这里无需处理, 直接回调成功
      
     
     
        
         completion(MyWeatherIntentResponse(code: .ready, userActivity: nil))
      
  }
   
}

此方法避免了第一次显示无信息的问题,同时也确保小组件的信息会跟随 App 更新,是一个不错的选择。但这种方法依赖于 App 启动并完成用户定位之后才会有可用的默认位置,在一些 App 并未记录用户位置时可能需要提供其他的备用默认值。

解决方案二: App Shared Data 以及时间戳判断

除了存储和获取位置信息外,另一个值得考虑的方法是让小组件在启动时,检查共享数据是否过旧(可以通过添加一个存储的时间戳来实现)。如果数据为空或者过旧,则直接显示一个初始值,并在下次 widget 调用 render 函数或者App运行的时候,使用实际的数据来覆盖旧的数据。这种方法减少了Widget的复杂逻辑,并让默认数据能够及时得到更新,即使app未运行。

步骤:

  1. App 将位置和时间戳存入 shared data : 当位置更新后,将位置信息和当前时间戳一起存储到 App Group 的 UserDefaults 中,或者使用Core Data,确保 Widget Extension 可以访问这个数据。
  2. Widget 加载数据 : 在 widget Provider的timelinesnapshot方法中,先读取App shared的 location 和 时间戳。
  3. 比较时间戳: 检查时间戳是否过时(或者第一次加载没有值)。如果是,使用fallback值;如果不是,正常渲染数据。

代码示例:
在 App 代码中,在合适的地方保存位置信息:

import Foundation
func saveSharedLocation(location: String) {

        let sharedDefaults = UserDefaults(suiteName: "group.com.example.weatherapp") // "group.com.example.weatherapp" 更换成你的App Group
        sharedDefaults?.set(location, forKey: "sharedLocation")
        sharedDefaults?.set(Date().timeIntervalSince1970, forKey: "lastUpdateTimestamp")
 }


在 Widget 的 timelinesnapshot 方法中:

 import SwiftUI
 import WidgetKit
 
struct Provider: IntentTimelineProvider {
  func placeholder(in context: Context) -> SimpleEntry {
     SimpleEntry(date: Date(), location: "Loading...", configuration: MyWeatherIntent())
  }
 
  func getSnapshot(for configuration: MyWeatherIntent, in context: Context, completion: @escaping (SimpleEntry) -> Void) {
         let sharedDefaults = UserDefaults(suiteName: "group.com.example.weatherapp") // "group.com.example.weatherapp"
         if let lastLocation = sharedDefaults?.string(forKey: "sharedLocation")  {
         let entry = SimpleEntry(date: Date(), location: lastLocation, configuration: configuration)
               completion(entry)
         } else {
                let entry = SimpleEntry(date: Date(), location: "Initial Value", configuration: configuration)
               completion(entry)
          }

  }
  
 func getTimeline(for configuration: MyWeatherIntent, in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {

      let sharedDefaults = UserDefaults(suiteName: "group.com.example.weatherapp") // "group.com.example.weatherapp"
      var location : String
      if let lastLocation = sharedDefaults?.string(forKey: "sharedLocation"),
       let timeStamp = sharedDefaults?.double(forKey: "lastUpdateTimestamp")
      {
      //判断更新时间是否超时
            let timeIntervalSinceLastUpdate = Date().timeIntervalSince1970 - timeStamp
            let  timeOutInterval =  180 // 超时时间,秒

        if timeIntervalSinceLastUpdate > timeOutInterval  {
        // 超时,则使用初始值或者其他 fallback 值,比如 "Initial Location",同时可能需要去 App 进行 fetch 新值
            location =  "Initial Location"
         }
         else
         {
            location = lastLocation
         }

         let entry = SimpleEntry(date: Date(), location: location, configuration: configuration)
       let timeline = Timeline(entries: [entry], policy: .atEnd)
      completion(timeline)
     }
      else
        {
        //默认值,首次添加或读取到过期或为空的数据时显示
        location = "Initial Value"
       let entry = SimpleEntry(date: Date(), location: location, configuration: configuration)
        let timeline = Timeline(entries: [entry], policy: .atEnd)
         completion(timeline)
     }
    
 }

 }

此方法相对第一种方法,对初始数据处理更加细致。当 Widget 初始化且时间戳过时的时候可以显示一个默认信息。由于timelinesnapshot会定时执行,也能实现定期检测更新的效果。

安全提示

无论选用哪种方法,都要注意在处理用户位置数据时的安全性和隐私性:

  • 明确声明小组件使用位置数据权限的目的。
  • 只存储用户授权允许的数据,并保证安全存储。
  • 使用 App Groups 共享数据的时候,要防止数据被意外覆盖。

这些解决方案应有助于解决小组件初始化时的 Intent 默认值问题,确保用户体验更加顺畅和一致。每个方法各有侧重,开发者可依据自身的需求选择最适合的方式。