cover_image

iOS14新特性-WidgetKit开发与实践

张凯凯@贝壳找房 贝壳产品技术
2020年10月29日 06:12
图片


iOS14新特性-WidgetKit小部件 通过桌面编辑添加,将小部件放在iOS主屏幕或macOS通知中心上,使用户可以随时访问应用中的内容。同时小部件也可以保持更新,因此用户始终可以一目了然地获得最新信息,同时点击区域可以Deep Link跳转到主APP任意界面中。

图片


在2020年苹果发布会推出Widget之后,贝壳就第一时间做出了尝试, 期间苹果中国提供了很多支持与帮助,目前已在贝壳和链家APP上线。


图片


小部件具有三种不同的大小(小,中和大),可以显示各种信息。用户可以个性化小部件以查看特定于其需求的详细信息,并以最适合他们的方式安排其小部件。


图片


不同分辨率机型,三种卡片的尺寸也不同:

图片


1 如何开发WidgetKit?


前期准备:

Xcode 12 及Bate版, iOS 14 及Bate版, 了解SwiftUI控件


1.1 创建Widget


首先,File->New->Target:

图片


有两种配置可供选择:


  • StaticConfiguration: 对于一个没有用户可配置属性的Widget。


    例如,显示一般市场信息的股票市场Widget,或显示趋势标题的新闻Widget。


  • IntentConfiguration: 对于一个具有用户可配置属性的Widget来说,你可以使用SiriKit自定义意图来定义属性。您使用 SiriKit 自定义意图来定义属性。


    例如,一个天气Widget需要一个城市的邮政编码或邮政编码,或者一个包裹跟踪Widget需要一个跟踪号码。


下图中「Include Configuration Intent」复选框决定了Xcode使用哪种配置。选择Include Configuration Intent 表示支持用户配置;不需要,则不勾选。

图片


1.2  Widget初始化配置


对象解析


  • kind

    识别Widget的字符串。

    如果包含多个widget后可作为唯一的标识符。

  • Provider

    符合TimelineProvider的对象。

    一个符合TimelineProvider的对象,它能产生一个时间线,告诉WidgetKit何时渲染Widget。

    时间线包含一个你定义的自定义TimelineEntry类型。

    时间线条目标识了你希望WidgetKit更新Widget内容的日期。

    在自定义类型中包含你的Widget的视图需要渲染的属性。

  • Placeholder

    一个 SwiftUI 视图,WidgetKit 用来在第一次渲染Widget。

    占位符是您的Widget的通用表示,没有特定的配置或数据。

  • Content Closure(内容闭合)

    一个包含SwiftUI视图的封闭。

    WidgetKit调用它来渲染Widget的内容,从提供者那里传递一个TimelineEntry参数。


函数解析


1)placeholder


占位视图,在数据加载前展示,在xcode12 bate3和bate4中有所有所变化:


  func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), model: LJWidgetModel.preview_widget)
    }


2)getSnapshot


快照,在添加组件库中展示


func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), model: LJWidgetModel.preview_widget)
        completion(entry)
}


3)getTimeline


时间轴,控制刷新时机


 func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
        let date = Calendar.current.date(byAdding: .hour, value:12toDate()) ?? Date()
        LJWidgetAPI.loadData{ (model, errorin
            guard let model = model else {
                let timeline = Timeline(entries: [SimpleEntry(datedate, model: LJWidgetModel.preview_widget)], policy: .after(date))
                completion(timeline)
                return
            }
            let timeline = Timeline(entries: [SimpleEntry(datedate, model: model)], policy: .after(date))
            completion(timeline)
        }
    }


4)SimpleEntry


数据模型,类似model


struct SimpleEntry: TimelineEntry {
    let dateDate
    let model: LJWidgetModel
}


5)WidgetEntryView


主内容,展示区分小中大卡片 ,可根据family来区分


struct LJWidgetEntryView : View {
    var entry: Provider.Entry
    @Environment(\.widgetFamily) var family


    @ViewBuilder
    var body: some View {


        switch family {
        case .systemSmall:
            let small = entry.model.small
            LJWidgetSmall(small)
                .previewLayout(.sizeThatFits)
        case .systemMedium:
            let medium 
= entry.model.medium
           LJWidgetMedium(medium)
            .previewLayout(.sizeThatFits)


        case .systemLarge:
            let large 
= entry.model.large
            LJWidgetLarge(large)
                .previewLayout(.sizeThatFits)


        @unknown default:
            Text("unknown")
        }
    }
}


6)Widget


主界面控制器。kind为标识符


struct LJWidget: Widget {
    let kind: String = "LJWidget"
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            LJWidgetEntryView(entry: entry)
        }
      .configurationDisplayName("My Widget")
        .description("This is an example widget.")
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
    }
}
Widget_Previews  struct LJWidgetLarge_Previews: PreviewProvider 
{
    static var previews: some View {
        Group {
            LJWidgetLarge(LJWidgetLargeModel.preview)
                .frame(width: 329.0, height: 345.0)
                .previewLayout(.sizeThatFits)
                .colorScheme(.light)
            LJWidgetLarge(LJWidgetLargeModel.preview)
                .frame(width: 329.0, height: 345.0)
                .previewLayout(.sizeThatFits)
                .colorScheme(.dark)
        }
    }


Preview 预览

图片


界面开发是SwiftUI (Apple要求),可参考:https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension


1.3 Widget刷新机制


  • 自动刷新


如下图,本身维护一个时间轴,在创建时填充不同时间节点,当到达时间节点位置时,触发刷新。after会在消耗完时间点后,再次填充,保持循环运行。


图片


  • 手动刷新


一种是push notification推送来更新widget,另一种则是客户端内通过调用接口主动 reload。


2 OC项目和Swift混编


如果原有老项目为OC项目,想要实现和Swift的相互调用,需用到桥接文件,另外针对引入Swift依赖库,会引起包体积增加,大小大概7~8M左右,可以在ipa包内查看:

图片


2.1  Swift引用OC代码


创建xxx(工程名)-Bridging-Header头文件,  并在Build Setting -> Objective-C Bridging Header 设置其路径

图片


这样我们在桥接文件内,通过 import “xxx.h”引用OC的组件库,就可以在Swift使用了。


2.2  OC引用Swift代码


桥接文件不需要手动创建,系统帮我创建好了,可以查看xxx-swift,在文件夹查找不到,但是引用头文件后,可以点击进入查看:

图片


3 主APP和Widget间通信


因为Widget为新的Target项目,和之前主项目是两个进程的关系,所以如果想要资源共享,比如登录状态值token,  网络配置等,就需要用到共享区域来实现通信。苹果提供的共享方式有:


方式一:APPGroup 方式


1) 配置好证书,是Gourp功能正常使用中,主APP和Widget保持统一key

图片

当我们配置完以后,会在文件目录下多出来一个.entitlements的文件。


2)主APP写下数据


//Main App 通过TextField来向共享文件appGroup.txt中写入数据
- (void)textFieldDidEndEditing:(UITextField *)textField {
   //获取App Group的共享目录    
NSURL *groupURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.simon.app.test"];   
 NSURL *fileURL = [groupURL URLByAppendingPathComponent:@"appGroup.txt"];  //写入文件   
 [textField.text writeToURL:fileURL atomically:YES encoding:NSUTF8StringEncoding error:nil];}


3)Widget 内读取App Group的共享目录  


NSURL *groupURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.simon.app.test"];   
NSURL *fileURL = [groupURL URLByAppendingPathComponent:@"appGroup.txt"];    //读取文件     
NSString *str = [NSString stringWithContentsOfURL:fileURL encoding:NSUTF8StringEncoding error:nil];   self.shareLabel.text = str;


方式二:KeyChain Sharing


KeyChain可将用户信息加密存储在钥匙串中,保证用户信息的安全性;另外多个应用可通过keyChain共享用户信息。


1)同样我们需要配置keychan的key, 保持和主APP一致,省事的是少了证书的配置


图片


2)通过对钥匙串的读写来操作 (一般由SAKeychain库管理)比较方便,注意一点:实现共享的只是在钥匙串的缓存数据,如果一旦加载到内存中,它的修改不受主APP的影响了。


4 常见问题


问题1. 选择Intent (widget 配置),会出现configtion查找不到


解决:

方法一: 若不使用Inetent, 在生成widget时不勾选即可,避免这类问题;

方法二:见https://developer.apple.com/forums/thread/653910 (在官网提出后,目前没有官方人员回答,不过有其他答案,未验证)


问题2. Xcode 12 bate3 运行贝壳的widget未显示preview


解决:Build systems: 选择新的编译方式


问题3. 由于WidgetKit使用一些Swift的新特性,所以版本需要修改成Swift5.0

图片


问题4. Swift 桥接OC组件库


解决:

1) 生成LJShell-Bridging-Header.h桥接文件,
2)在 Build setting中找到Objective-C Bridging Header  设置对应路径 $(SRCROOT)/LianJiaShell/LJShell-Bridging-Header.h

3)将 Build Settings 中的 Defines Module 选项设置为 YES


问题5. 引入OC组件库报查找不到


解决:在podfile中在LJWidget的target引入对应 pod xxx


问题6. 使用Widget Preview功能


bate版本目前发现在OC工程下混编情况,prview无法使用,可以尝试新建个swift工程来编写swiftUI,使用preview功能


问题7.  Error: Multiple commands produce


解决:
方法一:

不使用New Build System,在File > Project/Workspace Settings中的Share Project/Workspace Settings 里build system 将New Build System(Default)切换成Legacy build system。

方法二:在 target -> Build phase > Copy Bundle Resource 中找到info.plist,移除


问题8. dyld: Library not loaded:

dyld: Library not loaded: /System/Library/Frameworks/WidgetKit.framework/WidgetKit

  Referenced from: /var/containers/Bundle/Application/EA42E025-6CFA-4C90-950E-50D28255B4DA/LJShell.app/LJShell

  Reason: image not found


解决:https://developer.apple.com/forums/thread/126506


5 参考文献


  • WidgetKit:

    https://developer.apple.com/documentation/widgetkit

  • SwiftUI:
    https://developer.apple.com/tutorials/swiftui/creating-and-combining-views


图片
继续滑动看下一个
贝壳产品技术
向上滑动看下一个