iOS 10 —— widget开发详解

来源:互联网 发布:js qq在线客服插件 编辑:程序博客网 时间:2024/06/07 10:33

前言

    iOS extension的出现,方便了用户查看应用的服务,比如用户可以在Today的widgets中查看应用的简略信息,然后点击进入相关的应用界面。

1.交互

在开始构建Widget之前,如果想对Widget实现技术细节和交互特点有一个完整概览,我觉得没有什么文档比官方App Extension Programming Guide更值得一读了.刚开始接触iOS通知中心,一直很疑惑为何通知中心采用两个不同Tab“今日”和“通知”来对消息进行分离.其实这和Widget工作机制有关.

Widget是放在“今日”Tab之中,而它工作机制是只有用户下拉通知中心时才会去刷新获取最新数据,这种做法和Android不同在于,Android更偏向于把整个Widget一直放在后台实时持续的更新.设想一下,如果我们看同样天气信息,Android会持续消耗资源去做一件用户不会实时预览信息,这也就能解释为何经常看到Android用户抱怨耗电问题.而对于即时消息,iOS做法是直接把这些消息实时归类到”通知“Tab中.其实这种做法很好解决采用消耗最少资源前提下保证其操作的灵活性.

因为现有Widget一般来说是展现在系统级别的 UI上,所以在App Extension Programming Guide中Apple对Widget交互提出如下明确的要求:

扩展应该保持轻巧迅速,并且专注功能单一,在不打扰或者中断用户使用当前应用的前提下完成自己的功能点.

类似一直挚爱Todo应用Clear则交互上堪称上典范:

122936-b627f2f886f650fc.png

Clear's Widget

当然如果动点脑子会发现,Widget开放iOS上实现应用之间Launcher成为了可能,类似早期一直很魔性应用"Launcher":

122936-e5885fa2d24a8576.png

Launcher's Widget

可以让用在 iOS 的通知中心里,以类似应用程序捷径的方式直接快速切换 App 的小工具,其实当初在推出没多久后,便被 Apple 以"误用 / 滥用"Widgets 为理由下架,但有意思的就在几天前3月20日又重新上架.



图1 Today的widget展示----以支付宝为例说明

文章将依次从以下几个问题着手,进行详细说明:
1、如何为现有的工程添加widget;
2、如何绘制UI;
3、如何调起app;
4、如何与host app共享数据。

2.构建

1.创建Widget

Xcode菜单 -> File -> New -> Target.. -> 选择Today Extension

  -->

添加Today Extension


图2 添加today的target

   如图,为现有的工程添加Today Extension,命名这里不赘述了,大家都懂的。


图3 添加today之后的工程目录

    这是添加Today Extension之后的工程目录。
    到这里,为现有的工程添加Today Extension算是完成了,运行程序就可以看到类似图1的简单的效果了,很简单哈。

绘制UI

    与网上发表文章的童鞋们一样,我也是个代码控(装B一下),习惯用纯代码来绘制Today的UI。


图4 删除默认创建的MainInterface并修改Info.plist

    这里,删除默认创建的MainInterface.storyboard,并按图4所示修改Info.plist文件。(当然,习惯使用storyboard的童鞋可以略过,直接操作storyboard即可)


图5 设置widget展示视图的大小

    首先,设置widget展示视图的大小。关于widget的背景色,以及具体展示的内容大家按需绘制,这里暂且不表。


图6 设置widget视图距离左侧边界距离为0[自Xcode8 以后不会出现30px的差距,并且此方法已被弃用]

    运行程序后,会发现一个问题:绘制的内容与左侧边界有一定距离(约30px)。如何解决这个问题呢,如图6所示,TodayViewController遵守的NCWidgetProviding协议给出了解决方案。

这种方式把整个布局填充区域间隔都设置为0,当然更简洁的方式是你可以直接采用“return UIEdgeInsetsZero;”方式.而关于Widget上布局处理则采用Masonry框架做的相对布局,简单快捷推荐.当然关于Masonry框架快速上手则不得不推荐阅读Masonry介绍与使用实践(快速上手Autolayout).

整个点击区域实现

如你所看当用户拉开Widget时,因为Widget是依赖于应用程序在分发时是跟应用程序一块打包的,希望点击Widget布局任何区域都能唤起主应用程序,常用的方式在整个View增加Tap事件订阅处理:

08.png

Tap事件

但这种方式会额外产生一个问题,如果Widget空白区域没有任何UI元素则无法触发该事件,那这里有一个小技巧可以解决改问题,可以整个Widget增加一个透明的ImageView:

09.png

设置透明度

初始化时注意把imageview透明度设置为0.01最小值,那么无论设置其背景色为什么值肉眼都是不可见的.然后使用Masonry框架布局来填充Widget整个背景如下:

10.png

填充整个背景

然后为imageview增加Tap事件订阅即可:

11.png

增加事件订阅

这样就能整个Widget区域可点击效果.另外针对通过Widget中唤起主应用程序方式目前只支持url scheme方式来实现.同时也是Widget向主应用程序反馈数据和交互的渠道之一.

 定时更新机制

Widget自身更新机制当用户下拉通知中心(Notification Center)时立即更新数据,但我们仔细研究Widget用户使用场景时发现,如果用户锁屏时间过长,打开Widget后不做任何操作,这个时候针对一些即时类应用,类似我们天气中可能涉及到灾害预警它要求场景数据一旦产生就要实时展现给用户,这就需要我们基于Widget自身机制外还要处理这个场景下天气数据自动更新的问题.

这个时候我们需要构建一个定时更新的NSTimer:

12.png

初始化NSTimer

非常简单,在NSTimer固定更新间隔执行的方法调用就是更新数据方法,当然重点不在这里,而是触发和关闭这个NSTimer时机.按照Widget生命周期来说,如果用户是第一次下拉查看Widget其实就是执行整个ViewController生命周期调用过程,这个并没有什么问题,但是还是存在一个特殊情况.系统为了保证Widget上数据是及时更新的,默认会截取上次显示成功Widget的快照.这个快照会一直保存到新的数据或UI被更新才回被替换,那这就会带来一个问题,当你拖拽通知中心(Notification Center)下拉过于频繁时,Debug跟踪代码执行路径你会发现整个Widget生命周期执行过程和第一次下拉执行的路径发生了变化.

第一次下拉执行路径是viewDidLoad->viewWillAppear,而如果下拉过于频繁你就会发现代码执行路径直接只会执行viewWillAppear方法,这个就是系统默认保存上次快照而导致的执行路径上变化.这对我们选择NSTimer更新时机以及后面会提到的Widget横竖屏处理都会有影响.

那么很明显,为了保证这个定时更新机制能够无论用户什么情况下操作都能起作用,我们需要把NSTimer fire触发代码调用放到viewWillAppear方法中来.同理当Widget关闭后在viewDidDisappear方法取消NSTimer invalidate定时更新即可.

 Widget横屏支持

关于Widget横屏支持在开发中耽误一点时间来解决这个问题,在iPhone 6 & Plus上已经横竖屏直接切换,Widget默认是竖屏,但如果你需求中横屏UI的布局和竖屏布局完全不同,这个时候你就需要判断当前Widget横竖屏状态来切换对应的布局.

当然一般思路我们都会按照端内处理横竖屏方式来处理Widget,如果你翻过官方的开发文档,你会发现在iOS 6.0版本之前UIViewController之间横竖屏切换,只需要设置shouldAutorotateToInterfaceOrientation函数即可.UIInterfaceOrientation是UIApplication.h头文件中定义的枚举类型,总共有四个方向.在shouldAutorotateToInterfaceOrientation方法中返回相应的结果即可,如果直接返回YES将支持所有方向.而在iOS 6.0版本之后,UIViewController之间横竖屏切换需要多设置一个supportedInterfaceOrientations函数返回UIInterfaceOrientationMask枚举类型.除了设置shouldAutorotateToInterfaceOrientation之外,还要将supportedInterfaceOrientations返回的方向与shouldAutorotateToInterfaceOrientation保持一致,否则会在两个支持不同横竖屏ViewController中切换时,会出现竖屏变横屏,横屏变竖屏的情况.但问题是这种方式是否适用Widget横屏处理呢?

使用UIDeviceOrientationIsPortrait来判断:

13.png

判断横屏方法一

当你执行这段代码调试时你会发现,orientation方向的值始终都会是UIDeviceOrientationUnknown.如果你点开UIDeviceOrientation枚举你会看到.它包含了两个扁平方向UIDeviceOrientationFaceUp和UIDeviceOrientationFaceDown,其实它代表的意思屏幕朝上或朝下平躺两个方向的判断.所以当你设备平躺桌面时.即时你有时已经切换了横屏你会发现它会返回FaceUp或FaceDown,所以你当你调用UIDeviceOrientationIsPortrait方法时它返回值其实是没有意义的,因为设备目前方向在平躺下Faceup和FaceDown既不是横屏也不是竖屏.难道没有更好的方式嘛?

可以采用如下方式能够完美解决Widget横竖屏切换状态判断的问题:

14.png

Widget横竖屏状态判断

其实设置Widget显示高度时就会发现,高度在横竖屏状态切换是不会变化的,但宽度会随着横竖屏状态切换会发生变化,所以判断屏幕宽度这个思路是可取的.因为横竖屏UI布局不同,调用时机则可以选择在viewWillLayoutSubviews或viewDidLayoutSubviews方法中进行.因为这两个方法都是viewWillAppear方法是必然执行的,这也就自然规避Widget自身因为下拉快照保存机制导致代码执行路径变化导致布局更新的问题.

Widget国际化

在来说说这个Widget国际化,因为我们客户端自身已经支持三种不同语言,这就是导致Widget也是需要根据端内语言变化必须有国际化的支持.其实我们端内已经做了一套完整的国际化机制.Widget最好处理方式能够复用端内机制,而不需要单独开发支持.iOS 8 新引入的自制 framework 的方式来组织需要重用的代码,这样在链接 framework 后 app 和Widget就都能使用相同的代码. 包含Widget中数据请求和数据记忆其他能够复用的代码。

这也是我们一开始打算解决方式,但发现剥离这部分代码时间周期明显超过我们预期.所以在国际化处理上我们Widget独立做了一套国际化处理,它和端内在处理机制上并没有多大的不同:

15.png

Widget国际化处理

当然重点不再于它的实现,你可以发现我们Widget中国际化文本文件Locallizable.string命名加了一个"WG",这个问题是刚开始开发之初我们一直认为Widget作为端是独立于主应用程序的.所以当初理解为只有把这个文件命名为的“Locallizable.string”才是正常的能够被识别的,但我们调试时发现,Widget打包时会把这些国际化单独放到PlugIns文件下,这里给出一个简体中文全路径:

/private/var/mobile/Containers/Bundle/Application/61C637FF-B5BC-432A-ADD5-BA64EBFE98E8/MojiWeather.app/PlugIns/MojiWidget.appex/zh-Hans.lproj

根据这个路径你会发现文件时可以找到的,但调试时发现国际化取对应Key的值一直是取不到的,但我们任意非“Locallizable.string”时则是没有问题的,后来我们发现当我们打包在不同机型上测试这个问题时,如果“Locallizable.string”名称命名会导致调试时ok,而最终打包上会出现找不到对应key值得问题.这个原因到我写这篇blog一直没有找到具体的原因.所以我们给出解决方案是一定要和主应用程序“Locallizable.string”保持不同即可解决.

当然关于Widget中闪现的问题,因为我们Widget存在两个不同尺寸切换,导致这个问题很明显,处理方式自然是viewWillLoad方式中做好Widget高度在不同场景高度初始化就可以完美避免.



调起app

    因为extension和containing app是两个完全独立的进程,所以它们之间不能直接通信(不能像应用内部点击按钮,跳转到指定页面)。为了实现widget调起app,这里通过openURL的方式来启动containing app。
- (void)openURLContainingAPP{ //通过extensionContext借助host app调起app [self.extensionContext openURL:[NSURL URLWithString:@"appextension://xxx"] completionHandler:^(BOOL success) { NSLog(@"open url result:%d",success); }];}

//

前置工作:

首先,在containing app的info.plist添加如下代码:

<key>CFBundleURLTypes</key><array>    <dict>        <key>CFBundleURLName</key>        <!--这个一定要唯一-->        <string>com.wildcat.TodayExtensionDemo</string>          <key>CFBundleURLSchemes</key>        <array>            <!--调转URL的host,例如:TodayDemo:// --->            <string>TodayDemo</string>                            </array>    </dict></array>

在today extension中实现:

-(void)openURLContainingAPP{    [self.extensionContext openURL:[NSURL URLWithString:@"TodayDemo://action=GotoHomePage"]                 completionHandler:^(BOOL success) {                     NSLog(@"open url result:%d",success);                 }];}

在 containing app appdelegate中添加如代码,接收跳转:

-(BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<NSString *,id> *)options{    NSString* prefix = @"TodayDemo://";    if ([[url absoluteString] rangeOfString:prefix].location != NSNotFound) {        NSString* action = [[url absoluteString] substringFromIndex:prefix.length];        if ([action isEqualToString:@"GotoHomePage"]) {        }        else if([action isEqualToString:@"GotoOtherPage"]) {         }    }    return YES;}

//

数据共享

设置containingApp 和 widget的boundIdentifier

1、containingApp 和widget boundIdentifier对比

containingApp                        com.zgzd.widget

widget                                     com.zgzd.widget.today

containingApp 的boundIdentifier比widget 多一个单词(应该是默认的命名规则)

开启appGroups




图7 添加App Groups

    通过App Groups提供的同一group内app共同读写区域,可以用NSUserDefaults和NSFileManager两种方式实现extension和containing app之间的数据共享。



开发者账号内会创建具有appGroups功能的appID



如果想要将带有Extension的应用上传到App Store,你需要为extension单独的申请一个AppID(Bundle ID要对应XCode里面extension的Bundle ID,千万别直接复制,会漏掉后面无法复制到呈灰白的字段),同时配备相对应的distribution profile.

七、共享数据

存入

NSUserDefaults*shared = [[NSUserDefaultsalloc]initWithSuiteName:@"group.com.zgzd.widget"];

[sharedsetObject:[NSNumbernumberWithInt:indexPath.row]forKey:@"number"];

[sharedsynchronize];

取出

NSUserDefaults*shared = [[NSUserDefaultsalloc]initWithSuiteName:@"group.com.zgzd.widget"];

NSString*string=[NSStringstringWithFormat:@"%@",[sharedobjectForKey:@"key"]];

NSLog(@"插件获取共享数据==%@",string);


八、包含widget的项目打包注意事项

widget因为有自己的appID,所以也需要一套自己的配置文件




打包出来后,widget的icon一直不显示,找了半天,后来把widget隐藏,在弄出来,妹的,icon又显示了。原来我是通过itools导进手机的,widget默认显示,widget没加载,所以icon就显示不出来了

这个只是尝试设置,和这个没关系




1 通过NSUserDefaults共享数据

保存数据
- (void)saveDataByNSUserDefaults{ NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.xxx"]; [shared setObject:@"asdfasdf" forKey:@"widget"]; [shared synchronize];}
读取数据
- (NSString *)readDataFromNSUserDefaults{ NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.xxx"]; NSString *value = [shared valueForKey:@"widget"]; return value;}

2 通过NSFileManager共享数据

保存数据
- (BOOL)saveDataByNSFileManager{ NSError *err = nil; NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.xxx"]; containerURL = [containerURL URLByAppendingPathComponent:@"Library/Caches/widget"]; NSString *value = @"asdfasdfasf"; BOOL result = [value writeToURL:containerURL atomically:YES encoding:NSUTF8StringEncoding error:&err]; if (!result) { NSLog(@"%@",err); } else { NSLog(@"save value:%@ success.",value); } return result;}
读取数据
- (NSString *)readDataByNSFileManager{ NSError *err = nil; NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.xxx"]; containerURL = [containerURL URLByAppendingPathComponent:@"Library/Caches/widget"]; NSString *value = [NSString stringWithContentsOfURL:containerURL encoding:NSUTF8StringEncoding error:&err]; return value;}
    到这里,在Today中添加应用的widget就完成了,从Today中可以浏览预设的快捷服务,在文章iOS 10 Today Widget中接着说iOS 10的适配问题。

-- 

Today Widget新特性

    安装完iOS 10的beta版本,发现苹果越发重视widget了:快速浏览,及时从喜爱的应用中了解信息,如图1所示。
    现在,从锁屏页面,下拉通知栏的第一页,还有左滑主页面都可以进入widget。通过右上角的“展开”、“折叠”按钮,可以查看widget的全部内容和部分内容。在6s上面,点击应用icon的3D Touch界面中,也会有widget的折叠界面。


iOS10 Today Widget界面.png

适配问题

1、在widget中,展开、折叠具体是怎么实现的呢?

    在NSExtensionContext中,新添了widgetLargestAvailableDisplayMode属性,来确认当前widget是展开还是折叠状态。所以,先在viewWillAppear中设置widget的mode为展开。
代码段1
- (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; self.extensionContext.widgetLargestAvailableDisplayMode = NCWidgetDisplayModeExpanded;}
    然后,就是展开和折叠的处理了。在NCWidgetProviding协议中,新添了这么个方法widgetActiveDisplayModeDidChange,不赘述,直接用代码示例说明它的用途:
代码段2
// If implemented, called when the active display mode changes.// The widget may wish to change its preferredContentSize to better accommodate the new display mode.
- (void)widgetActiveDisplayModeDidChange:(NCWidgetDisplayMode)activeDisplayMode withMaximumSize:(CGSize)maxSize { if (activeDisplayMode == NCWidgetDisplayModeCompact) { self.preferredContentSize = CGSizeMake([UIScreen mainScreen].bounds.size.width, 110); } else { self.preferredContentSize = CGSizeMake([UIScreen mainScreen].bounds.size.width, 300); }}
    到这里,你可以在iOS 10上面看到带有展开、折叠功能的widget了,具体UI上面的微调,这里暂且不表,聪明的你肯定早就知道解决方案了。但是,不要着急,容我再把自己遇到的坑给家接着叨叨。

2、启动app后,第一次显示的是折叠,而不是展开呢?

    这个问题,归咎于preferredContentSize的设置,确认widget的mode之前,不要设置这个值。我的处理方式是:在widgetActiveDisplayModeDidChange中设置展开或折叠状态下widget的高度,就如上面的代码段2一样(当然,我的项目里比这个处理要复杂的多,这里化繁为简只为示例),iOS 10环境下在这里设置高度也就足够了。

3、为什么有时展开或折叠"失灵"了,没有对应的展开或折叠呢?

    这个问题的前提,肯定是你展开、折叠对应的widget高度不一样,只是看到了右上角按钮内容改变,但高度却没有变。
    这个问题的原因在于,点击展开、折叠按钮修改了widget的mode之后,却没有设置对应的高度:preferredContentSize。怎么办呢?再回到代码段2,mode改变后,设置对应状态下的高度即可。

4、如何用XCode 7.3打出能够适配iOS 10的widget呢?

    适配完iOS 10,会发现代码中总不能用XCode 8.0 beta打包代码吧,可是用XCode 7.3打包代码也编译不过啊,widgetLargestAvailableDisplayMode和NCWidgetDisplayMode都是iOS 10的产物。
    用kvc试试看?对,就是kvc。闲言不表,直接看代码吧:
代码段3 相对于 代码段1
- (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self.extensionContext setValue:@"1" forKey:@"widgetLargestAvailableDisplayMode"];}

代码段4相对于 代码段2
- (void)widgetActiveDisplayModeDidChange:(NSInteger)activeDisplayMode withMaximumSize:(CGSize)maxSize { if (activeDisplayMode == 0) { self.preferredContentSize = CGSizeMake([UIScreen mainScreen].bounds.size.width, 110); } else { self.preferredContentSize = CGSizeMake([UIScreen mainScreen].bounds.size.width, 300); }}

5、切记:UI的更新要在主线程操作哈!!!比如酱紫:

//通知主线程刷新dispatch_async(dispatch_get_main_queue(), ^{ //...........;});






原文链接:http://www.jianshu.com/p/ca3e11d7686c
                  http://www.cocoachina.com/ios/20150702/12337.html

0 0