iosswiftswiftuiwidgetkit

iOS 14 widget works locally, but fails via TestFlight


I have a SwiftUI app with a widget. When I run the app via Xcode (either straight to my device or on the simulator), the widget works exactly as expected.

However, when I run the app through TestFlight, the widget does appear, but it does not show any data -- it's just the empty placeholder. The widget is supposed to show an image and some text, but it shows neither.

I've seen some posts on Apple Developer forums about similar problems. One accepted answer says the following:

  1. Make sure that you use Xcode 12 beta 4 and iOS 14 beta 4 on your devices. Make sure that you have placeholder(in:) implemented. Make sure that you don't have placeholder(with:) because that's what the previous beta of Xcode was suggesting with autocompletion and without that you won't get your placeholder working. I think this whole problem is caused by the WidgetKit methods getting renamed but that's another story.
  2. As per the release notes, you need to set "Dead Code Stripping" to NO in your extension target's build settings. This is only necessary for the extension's target.
  3. When uploading your archive to the App Store Connect, uncheck "Include bitcode for iOS content".
  4. Delete your old build from a device when installing a new beta.

I've implemented these suggestions, to no avail.

Here's my code for the widget. It first fetches game data via CloudKit, then creates a timeline:

import WidgetKit
import SwiftUI
import CloudKit

struct WidgetCloudKit {
    static var gameLevel: Int = 0
    static var gameScore: String = ""
}


struct Provider: TimelineProvider {
    private var container = CKContainer(identifier: "MyIdentifier")
    static var hasFetchedGameStatus: Bool = false
    

    func placeholder(in context: Context) -> SimpleEntry {
        return SimpleEntry(date: Date(), gameLevel: 0, gameScore: "0")
    }

    
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry: SimpleEntry

        if context.isPreview && !Provider.hasFetchedGameStatus {
            entry = SimpleEntry(date: Date(), gameLevel: 0, gameScore: "0")
        } else {
            entry = SimpleEntry(date: Date(), gameLevel: WidgetCloudKit.gameLevel, gameScore: WidgetCloudKit.gameScore)
        }
        completion(entry)
    }


    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
            let pred = NSPredicate(value: true)
            let sort = NSSortDescriptor(key: "creationDate", ascending: false)
            let q = CKQuery(recordType: "gameData", predicate: pred)
            q.sortDescriptors = [sort]

            let operation = CKQueryOperation(query: q)
            operation.desiredKeys = ["level", "score"]
            operation.resultsLimit = 1

            operation.recordFetchedBlock = { record in
                DispatchQueue.main.async {
                    WidgetCloudKit.gameLevel = record.value(forKey: "level") as? Int ?? 0
                    WidgetCloudKit.gameScore = String(record.value(forKey: "score") as? Int ?? 0)
                    Provider.hasFetchedGameStatus = true

                    var entries: [SimpleEntry] = []
                    let date = Date()

                    let entry = SimpleEntry(date: date, gameLevel: WidgetCloudKit.gameLevel, gameScore: WidgetCloudKit.gameScore)
                    entries.append(entry)

                    // Create a date that's 15 minutes in the future.
                    let nextUpdateDate = Calendar.current.date(byAdding: .minute, value: 15, to: date)!
                    let timeline = Timeline(entries: entries, policy: .after(nextUpdateDate))
                    completion(timeline)
                }
            }

            operation.queryCompletionBlock = { (cursor, error) in
                DispatchQueue.main.async {
                    if let error = error {
                        print("queryCompletion error: \(error)")
                    } else {
                        if let cursor = cursor {
                            print("cursor: \(cursor)")
                        }
                    }
                }
            }
                    
            self.container.publicCloudDatabase.add(operation)
    }
    
}

struct SimpleEntry: TimelineEntry {
    var date: Date
    var gameLevel: Int
    var gameScore: String
}

struct WidgetEntryView : View {
    var entry: Provider.Entry
    
    var body: some View {
        GeometryReader { geo in
            VStack {
                Image("widgetImage")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: geo.size.width)
                HStack {
                    VStack {
                        Text("LEVEL")
                        Text(entry.gameLevel == 0 ? "-" : "\(entry.gameLevel)")
                    }
                    VStack {
                        Text("SCORE")
                        Text(entry.gameScore == "0" ? "-" : entry.gameScore)
                    }
                }
            }
        }
    }
}

@main
struct Widget: SwiftUI.Widget { 
    let kind: String = "MyWidget"
    
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            WidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Game Status")
        .description("Shows an overview of your game status")
        .supportedFamilies([.systemSmall])
    }
}

Question: Why isn't my widget working when distributed through TestFlight? What are my options, here?

Thank you!

Update: If I use the unredacted() view modifier, the widget shows the image and the "LEVEL" and "SCORE" text, but still does not show any actual data. So, my SwiftUI view now looks like this:

struct WidgetEntryView : View {
    var entry: Provider.Entry
    
    var body: some View {
        GeometryReader { geo in
            VStack {
                Image("widgetImage")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: geo.size.width)
                HStack {
                    VStack {
                        Text("LEVEL")
                        Text(entry.gameLevel == 0 ? "-" : "\(entry.gameLevel)")
                    }
                    VStack {
                        Text("SCORE")
                        Text(entry.gameScore == "0" ? "-" : entry.gameScore)
                    }
                }
            }
                .unredacted() // <-- I added this
        }
    }
}

Update #2: In the article Keeping A Widget Up To Date, there's a section that talks about background network requests:

When your widget extension is active, like when providing a snapshot or timeline, it can initiate background network requests. For example, a game widget that fetches your teammate’s current status, or a news widget that fetches headlines with image thumbnails. Making asynchronous background network requests let you return control to the system quickly, reducing the risk of being terminated for taking too long to respond.

Do I need to set up this (complicated) background request paradigm in order to make CloudKit work for my widget? Am I on the right track?


Solution

  • Did you try to deploy the cloudkit container to the production? You can find it on the CloudKit Dashboard.