跳转至

9.Keeping Complications Updated

Written by Scott Grosch

Now that your complications are available to place on the watch face, you have one last consideration. How do you ensure the displayed data is up to date?

Background Download

When your complication’s timeline runs out of data, or even before, you’ll want to initiate a background download. While your first instinct is likely to call one of the dataTask methods from URLSession, that won’t work. watchOS might pause and restart your widget extension multiple times during the download. Instead, you’ll have to create a download task.

Refactor CoOpsApi

Now that both the app and the widget extension need to download data, you’ll need to move some files from TideWatch Watch App into Shared. The main file you need is CoOpsApi, but moving it has a cascading effect. Move the CurrentTideKeys file, as well as the Download and Extensions folders into Shared.

Note

Add the newly moved files to the widget extension’s target membership.

Build your project to ensure the moved files all have the correct target membership.

A few refactorings are required to support downloading data from both projects. The first step you should perform is to declare a property for the root URL in CoOpsApi:

private let rootUrl = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"

Mode the assignment of components.queryItems from url(for:) into a method of its own:

private func queryItems(
  for stationId: MeasurementStation.ID,
  from start: Date, to end: Date
) -> [URLQueryItem] {
  return [
    "product": "predictions",
    "units": "metric",
    "time_zone": "gmt",
    "application": "TideWatch",
    "format": "json",
    "datum": "mllw",
    "interval": "h",
    "station": stationId,
    "begin_date": "\(Formatters.predictionInputFormatter.string(from: start))",
    "end_date": "\(Formatters.predictionInputFormatter.string(from: end))"
  ].map { .init(name: $0.key, value: $0.value) }
}

Then call that method instead in url(for:):

components.queryItems = queryItems(for: stationId, from: start, to: end)

While still in url(for:), use the new property in the guard clause:

var components = URLComponents(string: rootUrl)

The widget extension needs a method to call to initiate a download, so create that next:

public func getWidgetData(
  for stationId: MeasurementStation.ID,
  using session: URLSession
) {
  let end = Calendar.utc.date(byAdding: .hour, value: 1, to: Date.now)!

  var components = URLComponents(string: rootUrl)!
  components.queryItems = queryItems(for: stationId, from: Date.now, to: end)

  let request = URLRequest(url: components.url!)
  session
    .downloadTask(with: request)
    .resume()
}

While the main app can perform a normal data download, as previously mentioned, the widget extension needs to use a downloadTask.

The final change required in the CoOpsApi file is to make the decoding method public, since it now gets called from the widget extension as well. Add one last method to decode the download data:

public func decodeTide(_ data: Data?) -> [Tide] {
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .formatted(Formatters.predictionOutputFormatter)

  guard
    let data,
    let results = try? decoder.decode(TidePredictions.self, from: data),
    let predictions = results.predictions
  else {
    return []
  }

  return predictions.map { predication in
    Tide(on: predication.date, at: predication.height)
  }
}

Then, in getLowWaterHeights(for:), replace the text from the JSONDecoder initializer through the levels assignment with a call to the method you just created:

let levels = decodeTide(data)

That was quite a bit of refactoring, so ensure your project still builds without errors.

Caching Sessions

Apple’s developer documentation specifies, in a complicated manner, that you must reuse sessions when possible. Also, you need to reattach any previously interrupted sessions that have started again.

In the TideWatch Widget project, create a file named SessionData that contains the stored session:

import Foundation

final class SessionData {
  let session: URLSession

  init(session: URLSession) {
    self.session = session
  }
}

Next, create another file name SessionCache to handle the various caching logic:

import Foundation

// 1
final class SessionCache: NSObject {
  // 2
  static let shared = SessionCache()

  // 3
  var sessions: [String: SessionData] = [:]

  // 4
  private override init() {}
}

In the preceding code:

  1. The class will eventually implement session delegates, so ensure it inherits from NSObject.
  2. Creating a static shared property allows you to use SessionCache as a singleton.
  3. Because you might have multiple sessions running at once, you need to be able to find the appropriate URLSession based on a session identifier.
  4. By overriding the init method and making it private, you ensure that the class can only be used as a singleton, via the shared property.

Each time the extension needs to download data, it must either reattach an existing URLSession or create one. Add the following helper method:

// 1
func sessionData(for stationId: MeasurementStation.ID) -> SessionData {
  // 2
  if let data = sessions[stationId] {
    return data
  }

  // 3
  let session = URLSession(
    configuration: .background(withIdentifier: stationId),
    delegate: self,
    delegateQueue: nil
  )

  // 4
  let data = SessionData(session: session)
  sessions[stationId] = data

  return data
}

Here’s what’s happening:

  1. Given a station identifier, you’ll provide the required SessionData, which includes the URLSession.
  2. If cached data for the specified station already exists, simply return that.
  3. If there’s not, you’ll initiate a new background enabled URLSession. By specifying the stationId as the identifier, you ensure that watchOS can reattach an existing download that was previously terminated.
  4. Finally, create a new SessionData, store it in the cache and return the object.

Xcode now shows an error as you specified the SessionCache as the delegate, so add the following to the end of the file to conform to the delegate:

extension SessionCache: URLSessionDownloadDelegate {
  func urlSession(
    _ session: URLSession,
    downloadTask: URLSessionDownloadTask,
    didFinishDownloadingTo location: URL
  ) {
  }

  func urlSession(
    _ session: URLSession,
    task: URLSessionTask,
    didCompleteWithError error: Error?
  ) {
  }
}

Timeline Provider

It’s time to initiate a download. Head back to Provider and replace the entire contents of getTimeLine(for:in:completion:) with the following code:

// 1
let stationId = configuration.station!.identifier!

// 2
let sessionData = SessionCache.shared.sessionData(for: stationId)

// 3
CoOpsApi.shared.getWidgetData(for: stationId, using: sessionData.session)

Here’s why you need that code:

  1. When asked for a timeline, watchOS will provide the configuration data that you created in the previous chapter. Specifically, you’ll be given station that was configured in the TideWatch_Widget intent configuration. It’s safe to force unwrap because this method won’t be called without a station.
  2. You pull the appropriate session to use, possibly having created a new session, via the code you just implemented.
  3. Finally, watchOS will initiate a background download, using the appropriate session.

While the above is relatively straightforward, a gaping hole is in the code, as provided. Do you see the problem?

Getting a timeline requires calling a completion handler so watchOS knows when the data has been updated. Unfortunately, these methods are not yet async aware, so it’s time to make some changes to your session code.

Open SessionData and add a new property that conforms to the completion handler:

var downloadCompletion: (([Tide]) -> Void)? = nil

It’s a pretty ugly signature, but you’ve declared an optional property that expects to be provided with an array of Tide objects and has no return type.

Back in Provider, just before you call the getWidgetData(for:using:) method, assign that property:

// 1
sessionData.downloadCompletion = { tides in
  // 2
  var entries = tides.map { tide in
    SimpleEntry(date: tide.date, configuration: configuration, tide: tide)
  }

  // 3
  if entries.isEmpty {
    entries = [SimpleEntry(date: Date.now, configuration: configuration, tide: nil)]
  }

  // 4
  let oneHour = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)!
  completion(.init(entries: entries, policy: .after(oneHour)))
}

In the preceding code:

  1. Every time watchOS requests a new timeline, the delegate that will be called at completion should be updated.
  2. The decoded Tide data is converted to an array of SimpleEntry objects, which watchOS expects to be passed to the completion handler for presentation at the appropriate time.
  3. It’s important to handle the possibility that no data is available. For instance, the network or the remote server might be down.
  4. Finally, you call the completion handler with the new data, specifying that watchOS should ask for the next update one hour from now.

In the previous chapter, you assumed a Tide object was always available, but now there isn’t, so Xcode is showing compiler errors.

Edit SimpleEntry to make the tide property optional. Doing so means all of your templates must be updated to handle the case of no data. In this chapter’s project materials, I’ve provided updated versions of the templates that you can copy into your project.

You’ll have one last error to fix in EntryView. The new template expects you to pass the station name when using an AccessoryRectangularView:

AccessoryRectangularView(
  tide: entry.tide,
  stationName: entry.configuration.station?.displayString
)

Your project should now compile cleanly.

While you’ve implemented the code for downloadCompletion, which calls the required completion handler, nothing yet calls downloadCompletion.

Completing the Session

In SessionCache, when the delegate methods are complete, you’ll need to call the downloadCompletion delegate, if it’s set. Note that even if the session completed with an error, you still need to call the delegate to tell watchOS to complete the timeline. Add a new method, as shown below:

private func downloadCompleted(for session: URLSession, data: Data? = nil) {
  // 1
  guard let stationId = session.configuration.identifier else {
    return
  }

  // 2
  let tides = data == nil ? [] : CoOpsApi.shared.decodeTide(data)

  // 3
  let sessionData = SessionCache.shared.sessionData(for: stationId)

  // 4
  DispatchQueue.main.async {
    sessionData.downloadCompletion?(tides)
    sessionData.downloadCompletion = nil
  }
}

As you can see, the code is straightforward:

  1. Retrieve the stationId that was assigned during the URLSession creation.
  2. Decode the supplied Data, if it exists. If it’s nil, then an error occurred and you simply default to an empty array.
  3. Find the SessionData so you have access to the delegates.
  4. Call the stored downloadCompletion on the main thread, if it exists, passing in the decoded tides. While not strictly required, clear out the delegate so it can’t be called a second time.

Finally, update the URLSessionDownloadDelegate methods:

func urlSession(
  _ session: URLSession,
  downloadTask: URLSessionDownloadTask,
  didFinishDownloadingTo location: URL
) {
  // 1
  guard
    location.isFileURL,
    let data = try? Data(contentsOf: location)
  else {
    downloadCompleted(for: session)
    return
  }

  // 2
  downloadCompleted(for: session, data: data)
}

func urlSession(
  _ session: URLSession,
  task: URLSessionTask,
  didCompleteWithError error: Error?
) {
  // 3
  downloadCompleted(for: session)
}

In the preceding code:

  1. When a download task finishes you’ll be provided with a file URL pointing to the full contents of the downloaded data. If the provided location isn’t a file or watchOS can’t read the contents, then you call the downloadCompleted(for:) method with data, signifying an error occurred.
  2. Otherwise, you pass the data to the completion method for processing.
  3. In the case of a download error, call the completion method with no data.

You didn’t think you were done, did you?

Background URLSession Events

Background network requests are delivered directly to the widget extension, not the containing app. When a background URL session event occurs, you must tell watchOS whether you’re able to process the event, and if so, what to do.

Add a new method to SessionCache to determine what identifier is valid:

func isValid(for stationId: MeasurementStation.ID) -> Bool {
  return sessions[stationId] != nil
}

Next, in TideWatch_Widget, add a new modifier to the end of the body:

.onBackgroundURLSessionEvents { identifier in
  // 1
  return SessionCache.shared.isValid(for: identifier)
} _: { identifier, completion in
  // 2
  let data = SessionCache.shared.sessionData(for: identifier)

  // 3
  data.sessionCompletion = completion
}

Upon receiving a session event:

  1. Ensure the session’s identifier is valid. If it’s not, processing will stop.
  2. If the identifier was valid, retrieve the SessionCache object.
  3. The final confusing piece of background downloads is that you must tell the watchOS when the session has completed, so save the provided completion handler.

Of course, sessionCompletion doesn’t yet exist, so add a final property to SessionData:

var sessionCompletion: (() -> Void)? = nil

You’ve simply created an optional delegate that takes no parameters and returns nothing. Now, what should you do with that property? That’s right, call it from downloadCompleted(for:data:) in SessionCache, right after calling the other delegate method.

sessionData.sessionCompletion?()
sessionData.sessionCompletion = nil

Nothing to it, right? :]

At this point, after building and running the app, you’ll start to see fresh tide data every hour on your complications.

Key Points

  • Always use a data download, not a data task.
  • Ensure you call both the session event’s completion handler as well as the timeline provider’s completion handler.