一、什么是Widget
根据官方文档介绍Widget就是位于"今日"视图的App扩展,widget使得用户可以快速访问当前重要的信息。用户倾向于经常打开“今日”视图,并且希望他们感兴趣的信息能够立即获得。同时用户能够设置中是否允许"今日"视图在锁屏上出现。
不过值得注意的是官方指出widget提供的应该是快速更新和比较简单的任务,比较多步骤和繁重的任务使用widget并不是一个好的选择。
先看看效果:
如图所示,wiget有两种展示状态——展开和折叠状态,值得一提的是:折叠状态下其高度由系统统一控制,所有的widget高度固定,设置了也不会生效。每个widget顶部的左侧的AppIcon和右侧的展开收起按钮不可定制(右侧按钮不同系统版本有差异,一般的是一个右箭头或者下箭头但在低系统版本上是文字“展开”、“收起”),唯一能做修改的是展示名字,和主App一样,可以在对应在TARGETS下的info.plist里修改“Bundle display name”完成设置,不过一般情况下和主App保持一致。
二、Widget实现
widget的创建很简单,Xcode->File->New->Target->Today Extension如下图所示:
创建成功后会多出现一个文件夹:
默认创建的widget的Deployment版本是比较高的,需要在对应widget的target下修改版本:
好了接下来这个TodayViewController就是我们的主战场,和一般的ViewController生命周期一样,都有 viewDidLoad() 、 viewWillAppear(_ animated: Bool) 等等生命周期函数。默认的widget是收起样式的需要设置 widgetLargestAvailableDisplayMode 及其代理方法才会有展开收起状态,但是不能代码控制展开还是折叠,只能是用户点击按钮来展开和折叠
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.extensionContext?.widgetLargestAvailableDisplayMode = .expanded
}
@available(iOSApplicationExtension 10.0, *)
func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) {
if activeDisplayMode == .compact {
self.preferredContentSize = CGSize.init(width: UIScreen.main.bounds.size.width, height: 140)//这里由系统管控,设置不生效
} else {
self.preferredContentSize = CGSize.init(width: UIScreen.main.bounds.size.width, height: cellHeight * CGFloat(viewModel.entityCount) + 35)
}
}
我们可以在viewDidLoad准备一些初始的必要数据,比如从主App同步必要数据,一般我们切到"今日"视图都希望数据有更新,所以我们在 viewWillAppear加载网络数据,由于我们的应用特性对数据的实时行要求比较高,所以我会在viewWillAppear中开启一个定时器轮询接口刷新刷新数据(轮询时间间隔由用户在主App设置),使得用用一直停留在这个页面也能更新数据。在viewWillDisappear取消定时器,实现如下:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if #available(iOSApplicationExtension 10.0, *) {
self.extensionContext?.widgetLargestAvailableDisplayMode = .expanded
timer = Timer.init(timeInterval: TimeInterval(reloadTime), repeats: true, block: { [weak self] (_) in
self?.lodaData()
})
} else {
lodaData()
}
if let timer = timer {
RunLoop.current.add(timer, forMode: .common)
}
timer?.fire()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
timer?.invalidate()
timer = nil
}
三、三方库使用
在实现widget的时候可能会用到一些三方库(或者自己的私有库),那么我们如何在widget里面导入三方库呢?其实和普通的项目几乎无差别,我们widget是我们创建出来的一个target,所以我们只需要在podFile文件里面声明一下就可以了
这里需要注意的一定是对于主项目和widge同时用到的库,他们的版本号(或者commitID)需要保持一致,否则会报错
四、数据同步
通过上面的了解我们知道Widget是App的扩展,但是它也是一个独立的小应用,所以有如下特性:
-
上架后会和主App分开存储,所以也是需要一套证书的,widget的BundleID在主项目的Bundle ID的基础上加后缀的
-
不能直接和主App数据共享,只能通过App Groups进行,所以需要在Capabilities里面勾上App Groups
数据同步方式有两种一种是NSUserDefaults一种是NSFileManager,我们以第一种为例,由于我同步的数据相对有点多,我创建了一个Manager做数据同步,也尽量减少硬编码key的使用,新建一个个OpenDataManger类:
/// 公开数据枚举Key
enum UesrDefaultKey: String {
case todayWidgetData = "kTodayWidgetData"
case currentLagalKey = "kCurrentLagalKey"
case baseUrl = "kBaseUrl"
case widgetReloadTime = "kWidgetReloadTime"
}
/// 公开数据类
class OpenDataManager {
static let appGroupKey = "group.xxxx.xxxx"
static func setUserDafaultValue(jsonString: String, key: UesrDefaultKey) {
let shareDefault = UserDefaults.init(suiteName: appGroupKey)
shareDefault?.set(jsonString, forKey: key.rawValue)
shareDefault?.synchronize()
}
static func getUserDafaultValue(key: UesrDefaultKey) -> String {
guard let shareDefault = UserDefaults.init(suiteName: appGroupKey) else {
return ""
}
guard let resString = shareDefault.object(forKey: key.rawValue) as? String else {
return ""
}
return resString
}
}
这里值得一提的是:
- 由于这个OpenDataManger需要在主项目和Widget中同时使用,所以在Target Membership需要同时勾选两个target。
- 这里的UserDefaults需要使用指明group key的App Groups的,而不是我们常用的
UserDefaults.standard即:let shareDefault = UserDefaults.init(suiteName: appGroupKey)
使用如下(以获取请求的基础域名为例):
主项目存储:
let baseUrl = NetGuardMannager.shared.hostAPI
OpenDataManager.setUserDafaultValue(jsonString: baseUrl, key: .baseUrl)
widget中使用:
let baseUrl = OpenDataManager.getUserDafaultValue(key: .baseUrl)
五、唤起主App
由于Widget和主App是完全独立,所以它们之间不不能直接通信需要通过openURL的方式来唤起主App,由于我们的App之前和H5交互比较多,我们有专门的路由协议做这个事情,这里直接调用即可:
if let url = URL.init(string: "xxx:///market?params=xxx&fromWidget=true") {
self.extensionContext?.open(url, completionHandler: nil)
}
如果没有这一个Router,一般的做法就是:在主App里配置 Targets->WidgetDemo-> Info->Url Types添加一个,URL Schemes 为 TodayWidget; 然后在AppDelegate里面实现跳转操作:
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
if url.scheme == "TodayWidget" {
//处理跳转事件
retuen true
}
return false
}
六、可能遇到的问题
-
数据无法同步
出现这个问题,第一步可以看看工程的Capabilities是否开启,然后检查一下groupKey是否有错误,接下来检查对应的UserDefaults的key是否有错误。 -
展开高度计算不对
由于我们是一个动态的tableView,所以展开高度是根据数据源来的,之前的做法是根据接口返回的数量计算,但是问题是请求是异步返回的,切换过来会先调用高度代理函数,导致高度闪烁和展开高度不低的问题。解决方式就是:本地存一个数记录有多少条数据,使用本地的数据就算高度
-
网络请求失败
这个问题是集成测试时发现的,抓包发现根本没调用接口,widget也没有数据,一番排查发现私有仓库版本不对,导致数据同步失败。 -
widget展示”无法加载“
这个问题我是没有遇到的,不过很多朋友说的是widget发生了崩溃或者内存使用过大导致。 -
苹果在iOS13系统推出了深色模式;设置深色模式之后会整体调暗系统颜色,导致小组件展示不清晰。
这里需要在展示之前获取系统当前的展示模式,在刷新UI时根据不同的系统设置不同的字体颜色