Apr 24

大字号自适应的UIKit界面开发

Lrdcq , 2021/04/24 21:51 , 程序 , 閱讀(1503) , Via 本站原創
在客户端开发的过程中,我们总是会考虑下系统大字号的支持。之前的产品App统计的数据上,实际上有25%的用户调整到了比系统默认字号更大的字体,甚至在iOS上有10%的用户调整到归为Accessibility的特大号字体了(如图)。而事实上,iOS应用几乎没有应用去做大字号适配,目前的主流App相对来说腾讯的软件做得最好,QQ会根据系统设置自动适配,而微信则单独提供了内部字号设置界面。
點擊在新視窗中瀏覽此圖片

这一点Android应用基本上都做得好多了,显然这是因为:

1. Android的TextView默认就有根据系统设置放大缩小的行为,并且可以很方便的通过控制文字单位dp or sp来决定是否在TextView中使用大字体功能。

2. Android开发下习惯性使用可以挣开的布局,而iOS中相对繁琐一些(或者是开发人员没有这个习惯),导致大字号的布局适配成本相当高。

另外还有技术之外的一点是,早期的国内iOS应用确实没人做大字体,客观上诉求不强没有客户反馈,早期客户端应用目标用户以年轻人为主并且iPhone用户整体偏年轻。而这几年随着互联网应用下沉化多端化,有大字体需求的中老年用户多了起来,甚至会又些应用就是以中老年人为主要客户群体,这时大字体的必要性就来了。

本博客讨论的是,如何对现有iOS应用,在尽量低成本/影响范围的情况下进行大字号适配。

现有方案

其实Apple给我们提供了官方的方案的,即通过[UIFont preferredFontForTextStyle:UIFontTextStyleXXX]产生的标准语义font,并且UILabel设置了adjustsFontForContentSizeCategory = YES,即可在系统的设置字体大小发生变化的时候,UILabel自动刷新了(https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)。但是大家还是觉得使用系统方案有很多局限性:

1. preferredFontForTextStyle本质上是预设字体,灵活性上确实没法满足业务开发的诉求。
2. adjustsFontForContentSizeCategory只是解决了UILabel本身的问题,但是布局问题还是需要开发者自己解决。

因此几乎没有人用苹果的方案。

执行设计整个方案的话,其实更像是主题系统的一环,也可以单独拎出来处理,不过基本思路是一致的:a. 组件在渲染时会读取主题参数,这里是全局设定中的label文本缩放系数,然后进行渲染;b. 监听系统大字体改变的情况,并且刷新全局设定中的对应系数;c. 触发所有使用相关参数的控件和布局刷新。

1. 第一步是解决数据源是获取系统设置的值和改变情况。并得到缩放比例。

本身的获取方法是[UIApplication sharedApplication].preferredContentSizeCategory,得到的是一个声明为UIContentSizeCategory类型的字符串常量,枚举对应的就是系统设置界面的那些设置。而发生变化的时候,系统会给予UIContentSizeCategoryDidChangeNotification通知。然而实际测试这个通知并不靠谱,具体说来,在快速在设置应用与我们的应用切换的过程中会有丢通知的情况,因此另一个可行的方法是每次UIApplicationWillEnterForegroundNotification进行手动检查。因此有如下代码:
static CGFloat kTLableScale = 1.0;
static NSDictionary *kTLableScaleMap;

+ (void)load {
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(onSizeChange:)
                                                 name:UIApplicationWillEnterForegroundNotification
                                               object:nil];
    
    kTLableScaleMap = @{
        UIContentSizeCategoryExtraSmall: @0.7,
        UIContentSizeCategorySmall: @0.8,
        UIContentSizeCategoryMedium: @0.9,
        UIContentSizeCategoryLarge: @1.0,
        UIContentSizeCategoryExtraLarge: @1.1,
        UIContentSizeCategoryExtraExtraLarge: @1.2,
        UIContentSizeCategoryExtraExtraExtraLarge: @1.3,
        UIContentSizeCategoryAccessibilityMedium: @1.4,
        UIContentSizeCategoryAccessibilityLarge: @1.5,
        UIContentSizeCategoryAccessibilityExtraLarge: @1.6,
        UIContentSizeCategoryAccessibilityExtraExtraLarge: @1.7,
        UIContentSizeCategoryAccessibilityExtraExtraExtraLarge: @1.8
    };
}

+ (void)onSizeChange:(id)sender {
    UIContentSizeCategory category = [UIApplication sharedApplication].preferredContentSizeCategory;
    
    if (!category) {
        return;
    }
    
    CGFloat newScale = [kTLableScaleMap[category] floatValue];
    CGFloat oldScale = kTLableScale;
    kTLableScale = newScale;
    NSLog(@"oldScale = %lf newScale = %lf", oldScale, newScale);
    if (oldScale != kTLableScale) {
        [[NSNotificationCenter defaultCenter] postNotificationName:@"TLableSholdUpdateFont" object:nil];
    }
}

a. 注意我们声明了一个kTLableScaleMap来维护preferredContentSizeCategory枚举和缩放比例的关系。因为苹果系统实际上却是没有给出具体的缩放比例,或者苹果系统下对于不同style的font缩放比例是不同的才会有这个问题。不过我们作为主题系统只能拉出统一的值,所以才有这个设定。这个做法带来的附带好处是,每个应用的缩放映射map变成业务可控的了,因此我们可以做更灵活的操作比如只会放大不会缩小(UIContentSizeCategoryLarge以下全是1.0)来只支持大字号不支持小字号,或者UIContentSizeCategoryAccessibilityLarge以上全是1.5来拒绝过于大的字号,来保障实际应用UI效果不会炸得太厉害。

b. 这里使用的是UIApplicationWillEnterForegroundNotification通知,因此需要手动做dif。如果使用的UIContentSizeCategoryDidChangeNotification的话,还需要补充一段didFinishLaunch作为数据初始化([UIApplication sharedApplication]在didFinishLaunch之前为空,所以无法在+load中进行初始化)。

2. 第二方面是组件的改造,最基础的是对UILabel的改造。考虑到和安卓对标,能让用户选择Label是否启用自动缩放功能,所以一定需要新增一个属性,同时基于里氏替换原则,虽然iOS的字号是通过font表示的,但是我们不应该在子类或者拓展里变更font的含义,因此最合理的属性添加方式是添加一个表示是否进行resize的BOOL值。

- 拓展+切面or继承+子类:每次对UIKit进行功能拓展的时候都会有这个问题的讨论。切面的好处是,无缝入侵既有ui代码,只需要批量添加resizeEnable就可以。但是坏处是可能对其他有类似行为的UILabel子类产生影响,可能对系统GUI控件产生影响(特别是UILabel这种使用广泛的控件)。而使用继承相对保险很多,主要问题是改动量较大,针对大型App跨部门协作时间成本非常高,可能需要半年以上才能完成,当然有一个利好是如果和推广主题系统一起执行(目前主流主题系统开发方式都是继承控件组),执行效果会好很多。考虑到实际在团队中的可执行性,还是以继承为主。

因此尝试编写继承UILabel的代码:
@interface TLable : UILabel
@property(assign, nonatomic) BOOL enableFontResize;
@end

@implementation TLable {
    BOOL _sholdUpdateFont;
    UIFont *_sourceFont;
}

- (instancetype)init {
    if (self = [super init]) {
        _sholdUpdateFont = NO;
        _enableFontResize = YES;
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(setSholdUpdateFont) name:@"TLableSholdUpdateFont" object:nil];
    }
    return self;
}

- (void)setFont:(UIFont *)font {
    _sourceFont = font;
    [self setSholdUpdateFont];
}

- (UIFont *)font {
    return _sourceFont;
}

- (void)setSholdUpdateFont {
    _sholdUpdateFont = YES;
    [self setNeedsLayout];
}

- (void)layoutSubviews {
    if (_sholdUpdateFont) {
        [super setFont:[_sourceFont fontWithSize:_sourceFont.pointSize * enableFontResize ? kTLableScale : 1.0]];
        _sholdUpdateFont = NO;
    }
    [super layoutSubviews];
}

@end

a. 新增setSholdUpdateFont机制来保证最低限度的进行font更新,触发点包括初始化,font发生改变,上文的系统字号广播通知发出。

b. 需要对用户设置的font进行拦截转储,实际设置为UIKit的font为缩放过的font。

c. font刷新时机设定在layoutSubviews的super前,相对来说较为保险并且很多机制都能够触发到。

d. 设计子类的时候,可以定义为UIResizableLabel通过类名来表示可以resize的含义,并提供disableResize的BOOL属性来关闭行为,实际使用起来也同样具有抽象合理性。

布局模式

在iOS中由于布局习惯问题,如传统的frame+测量布局,如果字体会在运行时缩放,很容易产生预期之外的效果如:
點擊在新視窗中瀏覽此圖片

即文字被裁减。当然如果观察下Android上布局写得不够严谨也容易出现这个问题,因此如果要做低成本的大字号布局适配,至少的前提条件有:

a. 所有的UILabel都不使用Frame布局,而是通过AutoLayout上下左右撑开(父容器无所谓)。或者各个label本身在自己layoutsubview前具有自动按内容调整大小的能力。

b. 布局设计在文字变大后还是能一定程度容纳,比如文本区域原本的设计就是非常紧张的,那不打破设计就肯定无法容下大字号,也无法做自适应。

在这两条前提下,实际布局分为两种情况:

1. 布局可以被UILabel给撑开。也就是说整个页面从上到下从根view到目标label均是通过AutoLayout堆叠起来的,这种情况下,ILabel字号直接放大就好。
點擊在新視窗中瀏覽此圖片

需要注意点是:一般的页面都是从上到下的,因此一般是左右固定约束,上下撑开,但是对应的如果字体变大就会出现原本是1-2行的文字变成1-3行甚至1-4行的情况,高度也会大大的增加,因此有两种选择:容忍极大文字的情况下存在文字折叠“...”的情况,因此每个label需要指定numberOfLines到预期数字;或者接受label可以变得极高,需要进行极端UI验收(最多可能文字,最大字号)。

相反如果文字是单行向右边延伸,需要注意的是是否原本屏幕宽度本来可以容纳max长度的文本现在不行了,如果不行就得改为多行并且用上面那段的策略。对于左右两个Label拼装的情况,约束平衡可能会很难处理,建议添加上maxWidth之内的约束以保证大字号的情况下不会存在label被压到极限;或者策略改为不做挤压。

2. 布局相对较死,不存在布局撑开或者挤压,即父布局是死的或者计算后的固定frame。这种情况下在iOS既有业务代码中应该更常见(毕竟这样的布局更容易做出高性能布局),在列表,动态布局,等比布局中非常常见,即子view基本上是固定在父布局的某给lefttop,righttop的位置的,它的大小不会改变父布局,如下图的情况:
點擊在新視窗中瀏覽此圖片

父布局的两个label分别固定在左上与左下,字体放大之后就会出现内容重叠的情况。这也是绝大部分App支持大字号后一定会出现的问题。

客观来说,从降低成本的角度上图的效果其实是可以接受的。用户体验的角度,从优到劣分别是:好看(赏心悦目)=>能看(能直观获取到信息)=>查看困难(比较困难的获取到信息)=>信息看不到(获取不到信息)。其中我们支持大字号的初衷即字号太小属于“查看困难”,放大后如果出现文字重叠,也属于“查看困难”,但是主观判断,普遍还是认为一定不太过分的文字重叠的用户体验还是优于不放大,因此还是可以接受。

当然,任由文字重叠也是不够的,至少需要:a. 检查极限情况下是否会完全重叠,如果是建议针对性修改布局(不过一般不会)。b. 检查极限情况下是否会超出屏幕or父布局,特别是单行的情况下,可能需要改成左右约束的多行文本。c. 检查文字和其他元素的前后顺序,保证就算重叠文字也在最前面,不被图片等其他元素遮挡。

当然,如果针对这种情况还要提升用户体验,还可以尝试的做法包括:a. 尝试调整布局方式,比如从lefttop改为某条线上下居中,这样在字号放大后会更均匀有效的占用周围空间,从而有更小的重叠。b. 调整布局比重,在文字区域变大后能挤压或者完全隐藏非重要的装饰/图标等界面元素,保证信息获取向“能看”靠拢。

————

当然,除了以上两种布局的处理方式之外,还有成本最高的——即针对大字号设计,重新布局。QQ的部分关键界面有采用这种方法,不过还是视成本考量。总的来说,大字号适配的目标还是为了提升字号敏感用户的用户体验,结合以上做法与目标项目的实际用户情况,不追求完美,而是量力而行。
logo