自从iOS 10苹果给widget做了一次大改版后,很多人都开发了自己的widget。网上也有很多教程,来告诉你怎么快速开发一个widget。我的这篇文章也不会重复那些简单的创建extension添加证书之类的东西。我们要深入地看一下widget到底应该做成什么样子。

你真的了解widget的尺寸吗

首先widget由两种状态

typedef NS_ENUM(NSInteger, NCWidgetDisplayMode) {
NCWidgetDisplayModeCompact, // Fixed height
NCWidgetDisplayModeExpanded, // Variable height
}

大部分网上的教程都会告诉你,如果你想改widget的高度,都是在下面这个方法中这么写

- (void)widgetActiveDisplayModeDidChange:(NCWidgetDisplayMode)activeDisplayMode withMaximumSize:(CGSize)maxSize {
if (activeDisplayMode == NCWidgetDisplayModeCompact) {
self.preferredContentSize = CGSizeMake(maxSize.width, 110);
} else {
self.preferredContentSize = CGSizeMake(maxSize.width, 300);
}
}

这个意思就算折叠状态110,展开状态300。

因为如果你折叠状态就算写120,也一样是110的高度,这个高度不会变化。展开状态下,当然要取比maxSize.height小的一个值。那么maxSize这个值是多少?

然而我要告诉你,高度根本就不是一个固定值!并且可以认为是无规律的!!!

因为,整个widget的maxSize限制的第一规则是根据系统字体大小变化

无论是折叠状态还是展开状态。也就是说,直接写死110是错误的。因为默认系统字体下,的确折叠高度是110。但是一旦系统字体改为最小,widget折叠状态的高度仅为95,而在系统字体最大的情况下,widget折叠状态的高度是140。而系统字体大小一共有7档。也就是说,折叠高度和字体大小相关,但不是线性相关

可以验证,折叠的高度是95-100-105-110-120-135-140这七档。且不可修改。

光折叠高度也就算了。展开最大高度也是一个非线性相关的高度(并且是在折叠高度统一的情况下)

以下对于展开高度的讨论,都固定系统字体大小为默认大小,控制变量(最终得出的尺寸结果,理论上乘以7就是所有可能的高度)。

首先就是机型的差异,当然手机屏幕越小,展开最大高度也就越小,这个其实尚可以接受。大不了我们按照最小屏幕的情况开发呗。然而,我要告诉你,widget最大高度还是会变!

这个是我们最常见的widget入口,就是屏幕左滑的Today页

然而其实还有另外一个入口,就是下拉通知页的左滑,也会有入口

这两个入口进来,widget展开状态的最大高度,后者会比前者小很多

打断点看maxSize很容易就可以验证,iPhone7默认字体大小,展开状态下。第一个入口的maxSize.height是616,而另外一个情况下,这个数值变成了528

此时真想问一声苹果爸爸,这到底是想搞啥?

其实还有第三个入口,就是3D touch app图标,也会出现widget,但是那个只有折叠状态

也就是说,目前来看,折叠状态是7种尺寸,而每种屏幕大小的展开状态下就是7*2种,也就是说,4吋,4.7吋,5.5吋这三种主流屏幕尺寸都要适配的话,展开状态是7*2*3=42种尺寸

看到这你可以说,没关系,我就取4吋设备最小的高度。这个就要看你的设计师能不能同意了。

你以为完了吗?别说iPad呢,那个咱们就不考虑了,iPhone能放下,iPad当然也放得下。

但是你真的想不到,5.5吋也就是Plus机型的横屏状态,也是不同尺寸的。Plus横屏下的展开模式,第一个入口最大高度仅有352,第二入口的最大高度仅264……

意味着什么,最大字体情况下的折叠状态都有140高度,展开还不到折叠高度的两倍。

如果你对widget的尺寸适配感兴趣,并且有解决方案,请联系我,必有重谢。

有没有感觉被闪瞎了

你如果添加了很多个widget就会发现,就单单在列表里上下滑动都能把眼睛闪瞎。

Widget 自身的更新机制,是进入到 Widget 后,先执行 viewDidLoad 方法,然后是 viewWillAppear 方法。

但是经测验,每当某一个Widget在上下滑动,滑出屏幕后,再把这个widget划回来,就走上面那一套刷新机制。

由于以上特性,更新代码最好写在 viewWillAppear 方法里面,对于更新时效性特别强的,比如天气类 app,这种最好就是在该方法里面添加一个 NSTimer 定时进行刷新,在 viewWillDisAppear 方法中 进行 取消NSTimer invalidate定时更新即可。

或者,你自己实现缓存,一样可以优化。判断如果请求来的数据和当前数据内容一致,那么就不进行刷新列表操作。

知乎、得到 等等好多app的 Widget,只要走 viewDidLoad 方法就会闪一下,因为每次Widget加载请求的数据后会进行替换造成的。

至于为什么只要不再视线范围再回来就刷新,我猜测,是因为内存问题。

widget对内存的要求之高令人发指,你的widget中一旦有gif,基本上就完全没有办法显示,过一会就会显示无法载入。不仅如此,反复来回滚动widget页面,以不断刷新也会导致占用内存升高,不太清楚这个是不是苹果的BUG,但是我自己的测试中,尽量都让单个的widget内存占用小于15M,这样被杀掉内存的机会很小

所以,我在开发的时候,gif图都只取第一帧。并且尽可能不主动刷新UI,保持widget内存处于一个较低的水平。

而且由于extension实际上不能直接使用主target中的那些框架,所以,我也写了一些最基本的功能组件。

首先当然是缓存系统,图片缓存尤其关键,因为widget这种特性,会反复刷新,如果没有缓存系统,是非常大的浪费。首先就是图片缓存:

#import "QDTEImageCache.h"
#import <CommonCrypto/CommonDigest.h>
@implementation QDTEImageCache
+ (instancetype)shareImageCache {
static dispatch_once_t once;
static id instance;
dispatch_once(&once, ^{
instance = [self new];
});
return instance;
}
- (BOOL)isExistCacheForKey:(NSString *)key {
key = [self cachedFileNameForKey:key];
NSString *filePath = [[self getCachePath] stringByAppendingPathComponent:key];
return [[NSFileManager defaultManager] fileExistsAtPath:filePath];
}
- (NSData *)getImageDataForKey:(NSString *)key {
if ([self isExistCacheForKey:key]) {
return [NSData dataWithContentsOfFile:[[self getCachePath] stringByAppendingPathComponent:[self cachedFileNameForKey:key]]];
}
return nil;
}
- (void)saveToCacheWithData:(NSData *)data forKey:(NSString *)key {
key = [self cachedFileNameForKey:key];
NSString *filePath = [[self getCachePath] stringByAppendingPathComponent:key];
dispatch_async(dispatch_get_global_queue(0, 0), ^{
[data writeToFile:filePath atomically:YES];
});
}
- (NSString *)getCachePath {
NSFileManager *fileMgr = [NSFileManager defaultManager];
NSString *containerPath = [[fileMgr containerURLForSecurityApplicationGroupIdentifier:@"group.com.XXXXXXX"] path];
NSString *path = [containerPath stringByAppendingString:@"/Caches/"];
if (![fileMgr fileExistsAtPath:path]) {
BOOL res = [fileMgr createDirectoryAtPath:path
withIntermediateDirectories:YES
attributes:nil
error:nil];
if (!res) {
return nil;
}
}
return path;
}
- (NSString *)cachedFileNameForKey:(NSString *)key {
const char *str = [key UTF8String];
if (str == NULL) {
str = "";
}
unsigned char r[CC_MD5_DIGEST_LENGTH];
CC_MD5(str, (CC_LONG)strlen(str), r);
NSString *filename = [NSString stringWithFormat:@"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%@",
r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10],
r[11], r[12], r[13], r[14], r[15], [[key pathExtension] isEqualToString:@""] ? @"" : [NSString stringWithFormat:@".%@", [key pathExtension]]];
return filename;
}
@end

一个非常基础的图片缓存,同时配合文件管理类, 来管理接口返回的response:

控制器发出的请求,收到response的data时做一次缓存并比对

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
[self.jsonData appendData:data];
NSDictionary *dic = [[NSJSONSerialization JSONObjectWithData:self.jsonData options:NSJSONReadingMutableContainers error:nil] copy];
if (dic == nil) return;
self.jsonData = nil;
NSDictionary *metaDic = [dic valueForKey:@"meta"];
if ([[metaDic valueForKey:@"status"] integerValue] == 200) {
NSArray *papers = [[dic valueForKey:@"response"] valueForKey:@"papers"];
NSDictionary *paperDic = [papers firstObject];
[_fileMgr saveToCacheWithRawDic:paperDic];
QDTELabModel *labModle = [self modelFromRawDic:paperDic];
if (labModle.article_id.longValue == self.labModel.article_id.longValue) return;
self.labModel = labModle;
dispatch_async(dispatch_get_main_queue(), ^{
for (UIView *subView in self.view.subviews) {
[subView removeFromSuperview];
}
[self refreshContentView];
});
}
}

文件管理类用来储存

#import "QDTEFileManager.h"
@implementation QDTEFileManager
+ (instancetype)shareManager {
static dispatch_once_t once;
static id instance;
dispatch_once(&once, ^{
instance = [self new];
});
return instance;
}
- (NSDictionary *)getUserinfo {
NSFileManager *fileMgr = [NSFileManager defaultManager];
NSString *containerPath = [[fileMgr containerURLForSecurityApplicationGroupIdentifier:@"group.com.XXXXXX"] path];
NSString *filePath = [containerPath stringByAppendingPathComponent:@"QDUserinfo.json"];
if ([fileMgr fileExistsAtPath:filePath]) {
NSError *error;
return [[NSJSONSerialization JSONObjectWithData:[NSData dataWithContentsOfFile:filePath] options:NSJSONReadingMutableContainers error:&error] copy];
}
return nil;
}
- (NSDictionary *)getRawDicFromCache {
NSFileManager *fileMgr = [NSFileManager defaultManager];
NSString *containerPath = [[fileMgr containerURLForSecurityApplicationGroupIdentifier:@"group.com.XXXXXX"] path];
NSString *path = [containerPath stringByAppendingString:@"/Caches/"];
NSString *filePath = [path stringByAppendingPathComponent:@"QDLabCache.json"];
if ([fileMgr fileExistsAtPath:filePath]) {
NSError *error;
NSDictionary *rawDic = [[NSJSONSerialization JSONObjectWithData:[NSData dataWithContentsOfFile:filePath] options:NSJSONReadingMutableContainers error:&error] copy];
return rawDic;
}
return nil;
}
- (void)saveToCacheWithRawDic:(NSDictionary *)rawDic {
NSFileManager *fileMgr = [NSFileManager defaultManager];
NSString *containerPath = [[fileMgr containerURLForSecurityApplicationGroupIdentifier:@"group.com.XXXXXX"] path];
NSString *path = [containerPath stringByAppendingString:@"/Caches/"];
BOOL res = [fileMgr createDirectoryAtPath:path
withIntermediateDirectories:YES
attributes:nil
error:nil];
if (!res) {
return;
}
NSString *filePath = [path stringByAppendingPathComponent:@"QDLabCache.json"];
if ([NSJSONSerialization isValidJSONObject:rawDic])
{
NSError *error;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:rawDic
options:NSJSONWritingPrettyPrinted
error:&error];
dispatch_async(dispatch_get_global_queue(0, 0), ^{
[jsonData writeToFile:filePath atomically:YES];
});
}
}
- (NSString *)getServerIP
{
if ([self getDEBUG]) {
NSFileManager *fileMgr = [NSFileManager defaultManager];
NSString *containerPath = [[fileMgr containerURLForSecurityApplicationGroupIdentifier:@"group.com.XXXXXX"] path];
NSString *filePath = [containerPath stringByAppendingPathComponent:@"QDServerIP.json"];
if ([fileMgr fileExistsAtPath:filePath]) {
NSError *error;
NSArray *serverIPArr = [[NSJSONSerialization JSONObjectWithData:[NSData dataWithContentsOfFile:filePath] options:NSJSONReadingMutableContainers error:&error] copy];
return serverIPArr.firstObject;
}
}
return @"http://app3.qdaily.com";
}
- (BOOL)getDEBUG {
#ifdef DEBUG
return YES;
#elif BETA
return YES;
#else
return NO;
#endif
}
@end

最后呢,这个是我其中一个widget的文件结构。

widget虽小,但是我当时在开发的时候还是尽量想怎么复杂怎么做,毕竟这种东西,开发一次,几乎以后再也不会去动了。毕竟……针尖上还要带着脚镣跳舞实在太累了😂。