2

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


Curiosity
437 声望42 粉丝