iOS Widget实践

2,181 阅读6分钟

一、什么是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
    }
}

这里值得一提的是:

  1. 由于这个OpenDataManger需要在主项目和Widget中同时使用,所以在Target Membership需要同时勾选两个target。
  2. 这里的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
    }

六、可能遇到的问题

  1. 数据无法同步
    出现这个问题,第一步可以看看工程的Capabilities是否开启,然后检查一下groupKey是否有错误,接下来检查对应的UserDefaults的key是否有错误。

  2. 展开高度计算不对
    由于我们是一个动态的tableView,所以展开高度是根据数据源来的,之前的做法是根据接口返回的数量计算,但是问题是请求是异步返回的,切换过来会先调用高度代理函数,导致高度闪烁和展开高度不低的问题。

    解决方式就是:本地存一个数记录有多少条数据,使用本地的数据就算高度

  3. 网络请求失败
    这个问题是集成测试时发现的,抓包发现根本没调用接口,widget也没有数据,一番排查发现私有仓库版本不对,导致数据同步失败。

  4. widget展示”无法加载“
    这个问题我是没有遇到的,不过很多朋友说的是widget发生了崩溃或者内存使用过大导致。

  5. 苹果在iOS13系统推出了深色模式;设置深色模式之后会整体调暗系统颜色,导致小组件展示不清晰。
    这里需要在展示之前获取系统当前的展示模式,在刷新UI时根据不同的系统设置不同的字体颜色