iOS 屏幕适配浅谈

3,871 阅读10分钟
原文链接: mp.weixin.qq.com

作者 | 钱凯

杏仁移动开发工程师,前嵌入式工程师,关注大前端技术新潮流。

前端开发的屏幕适配其实算是基本功,每个码农在长期实践中都有自己的总结。

在 iOS 平台上,苹果爸爸对适配的支持个人感觉很不人性化,提供了 AutoLayout、sizeClass 等技术,感觉没有前端类似 flexBox 这样的技术来得灵活。像是点歪了技能树,过于重视使用 xib 配置 UI,但很多码农还是习惯纯代码编程。Cocoa 没有 css 这样的纯布局文件,导致很多时候我们将布局、UI 和逻辑写在一起,十分混乱、冗长。

下面简单介绍下在实践中适配屏幕的方向思路,抛砖引玉。

从设计到代码:沟通与标准

App 的 UI 界面是由设计人员(产品,UI)绘制的,然后由开发实现,双方要有良好的沟通,并且把设计内容标准化、文档化。

对设计方来说,适配的规则总是在设计师心中的,是按比例的缩放,还是固定的间距,是公用一套规则,还是在大屏下有特殊的布局,都需要有明确方式传达给耿直的码农们。

一般常见的布局方式有:

  • 固定间距:在不同尺寸下,间距总是固定。

  • 流式布局:文字,图片等在不同屏幕下流式排布,比如大屏下一行显示四张图片,小屏一行三张,图片尺寸固定。

  • 比例放大:间距,文字大小,图片大小等比例放大。

  • 保持比值:两个UI元素或者图片的长宽等属性保持一定的比值。

  • 对齐:元素间按某个方向对齐。

设计师需要将这些布局规则标注清楚,有利沟通,也方便日后追溯。

对于一些通用 UI 组件,要进行标准化,设计上有利于 App 风格统一,实现上也方便开发进行封装。

UI 的搭建:xib VS 纯代码

苹果一直用 xib 来标榜他们家 App 开发简单易上手:将各种你需要的东西往屏幕上一拖一放,一个 UI 界面就搞定了,这很 cool 不是嘛!

Xib 的优点显而易见:

  • 易上手、可视化,所见即所得

  • 减少代码量

  • 快,适合小 App 快速开发

但是在我们的实际项目中,是不推荐使用 xib 的。

首先,xib 本身过于笨拙,只能搭建一些简单的 UI,动态性很差,难以满足 App 复杂的 UI 交互需求。

其次,做过性能优化的同学都知道,xib(or StoryBoard)的性能是很差的,相对于用纯代码 alloc 的组件来说,xib 加载慢,而且会占用 App 包的体积。不仅仅是 App 的性能,使用老 mac 打开较大的 xib 文件,有时候会卡的你怀疑人生,严重影响开发效率(心情)。

除此以外,对于团队协作来说,xib 也不是一个好选项:阅读困难,无法在 git 上查看历史改动,容易造成冲突,造成冲突后难以解决,元素通过 outlets 与代码的链接难以维护,容易在改动中造成错漏等等。

另外,对于我这种中途转到前端的工程师来说,对一切在 IDE 界面上配置的东西都有种迷之不信任,感觉不如一行行黑底白字的代码来的靠谱。

当然我们不是完全禁用了 xib,用代码码 UI 的缺点也很明显:繁琐,代码量大。因此对一些元素较多,又比较固定的 UI 组件,我们可以用 xib 来减少代码量:

针对UI代码繁琐,重复编码多的情况,我们可以通过适当封装(UI 工厂类),组织结构(MVC,分离 UI 代码)等手段,清晰逻辑。

// label 工厂方法
+ (UILabel *)labelWithFont:(UIFont *)font
                     color:(UIColor *)
                      text:(NSString *)text
             attributeText:(NSAttributeString *)attributeText
                 alignment:(NSTextAlignment)alignment;

布局:返璞归真

从 iOS7 开始苹果在 Cocoa 平台引入 AutoLayout 进行 UI 的基本布局。但是 AutoLayout 非常反人类,不仅代码繁琐而且使用不灵活限制很多。

比如我想要把三个元素等间距地展示在屏幕上,用 AutoLayout 写完基本蛋都碎了,更别说动态地在两套布局间切换这种高级需求。

后来苹果推出 sizeClass,试图解决多套布局的问题,但是仍然没有触及到码农的痛点,而且依赖 xib 使它泛用性不好。

一段典型的 AutoLayout 代码如下所示:

    _topViewTopPositionConstraint = [NSLayoutConstraint
                                     constraintWithItem:_topInfoView
                                     attribute:NSLayoutAttributeTop
                                     relatedBy:NSLayoutRelationEqual
                                     toItem:self.view
                                     attribute:NSLayoutAttributeTop
                                     multiplier:1.0
                                     constant:self.navigationController.navigationBar.frame.size.height + self.navigationController.navigationBar.frame.origin.y];
    
    [self.view addConstraint:topViewLeftPositionConstraint];
    
    (这里省略上述类似结构*4)

上面省略了很多代码,实际上一页都放不下。它干了什么呢,只是将一个元素紧贴屏幕上边缘放置。项目中我们会使用三方 AutoLayout 的封装:PureLayout ,简化代码,也有其它实用功能。

AutoLayout 比较适合:

  • 基本的对齐(上下左右对齐,居中对齐等)

  • 固定的布局,固定的间距,动态性不高的页面

  • 简单且数量较少的 UI 元素

不擅长:

  • 比例布局

  • 动态性较强的页面局部

  • 不同屏幕大小比例的适配

  • 复杂的 UI

另外有一点,AutoLayout 对性能是有损耗的,所以对性能有要求的场景,比如列表中的 cell,我们会用代码计算 frame,提高滑动帧率。

所以在实际工程中,需要来选择布局方式。

下面是 App 中首页新闻 Feeds 的布局代码片段:

- (void)layoutSubviews {
    
    [super layoutSubviews];
    
    CGFloat cellWidth = CGRectGetWidth(self.bounds);
    CGFloat currentY = 0.f;
    
    // 0.content
    CGFloat cellHeight = CGRectGetHeight(self.bounds);
    CGFloat contentHeigth = cellHeight - kCellPaddingHeight;
    _mainContentView.frame = CGRectMake(0, 0, cellWidth, contentHeigth);
    
    // 1. topic
    CGFloat topicLabelWidth = [_topicLabel.text boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:_topicLabel.font} context:nil].size.width;
    
    CGFloat topicLabelHeight = [@"测高度" boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:_topicLabel.font} context:nil].size.height;
    
    CGFloat topicLogoLeftPadding = 3.f;
    CGFloat topicLogoWidth = 10.f;
    CGFloat topicLeftPadding = 13.f;
    
    _topicView.frame = CGRectMake(topicLeftPadding, currentY + kTopicUpPadding, topicLogoWidth + topicLogoLeftPadding + topicLabelWidth, topicLabelHeight);
    _topicLogo.frame = CGRectMake(topicLabelWidth + topicLogoLeftPadding, CGRectGetHeight(_topicView.frame) / 2.0 - topicLogoWidth / 2.0, topicLogoWidth, topicLogoWidth);
    _topicLabel.frame = CGRectMake(0, 0, topicLabelWidth, topicLabelHeight);
    
    (省略大量代码……)
    
    // 10._sourceLabel
    CGSize sourceSize = [_sourceLabel.text boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:_sourceLabel.font} context:nil].size;
    
    _sourceLabel.frame = CGRectMake(kEdgeHorizontalPadding, currentY + kLeadingUpPading, sourceSize.width, sourceSize.height);
}

可以看到,为了确定每个元素的位置,我们需要进行大量的计算,代码可读性也不好,繁琐难读。如果引入动态性,比如不同屏幕字体大小改变,元素大小按比例扩大等,则计算量又要上一个数量级。

动态布局:清晰独立

UI 界面是动态的,在不同状态,不同尺寸或者手机的横竖屏情况下,我们往往需要在多套布局方案中切换,或者对布局进行微调。如果使用 xib 布局的话,可以使用 SizeClass + AutoLayout 的方案;如果是代码实现的页面,则没有官方提供的工具,只能用逻辑去判断。

一般来说,我们写复杂的 UI 页面,需要遵循两个原则:

  1. UI 布局代码要清晰:这是最重要的,要一眼就知道在调整那一块,怎么调整,如果不能,适当拆分,优化命名。

  2. 布局代码要和业务逻辑独立:在一些常用设计模式下,我们会将 UI 和数据模型解耦,在 UI 内部,同样要将交互,配置这些逻辑和布局解耦,独立出类似前端 css 这样的纯布局文件。

将布局代码提炼出来,在不同尺寸下调用不同的实现:

if (IS_IPHONE_6){  
    self.layout = [MyLayout iPhone6Layout];
}else if (IS_IPHONE_6_PLUS){  
    self.layout = [MyLayout iPhone6PlusLayout]; 
}

// 实现小屏幕布局
+ (MyLayout *)iPhone6Layout {...}
// 实现大屏幕布局
+ (MyLayout *)iPhone6PlusLayout {...}

字体适配:字体集

在开发中我们经常会遇到需要动态设置字体的情况:

  • 不同屏幕尺寸,或者横竖屏,需要展示不同的字体大小。

  • 为用户提供了文章调节字体选项。

  • App 的不同语言版本,需要显示的字体不一样。

较为简单的做法是用宏或者枚举定义字体参数,针对不同尺寸的屏幕,我们拿到不同的值:

#ifdef IPHONE6
#define kChatFontSize 16.f
#else IPHONE6Plus
#define kChatFontSize 18.f
#endif

在对一些旧代码做字体适配扩展的时候,直接修改源码改动太多,容易混乱,可以采用 runTime 方法 hack Label 等控件的展示,替换原有的 setFont 方法:

+ (void)load{  
    
    Method newMethod = class_getClassMethod([self class], @selector(mySystemFontOfSize:));  
    Method method = class_getClassMethod([self class], @selector(systemFontOfSize:));  
    method_exchangeImplementations(newMethod, method);  
}  
  
+ (UIFont *)mySystemFontOfSize:(CGFloat)fontSize{  
    UIFont *newFont=nil;  
    if (IS_IPHONE_6){  
        newFont = [UIFont adjustFont:fontSize * IPHONE6_INCREMENT];  
    }else if (IS_IPHONE_6_PLUS){  
        newFont = [UIFont adjustFont:fontSize * IPHONE6PLUS_INCREMENT];  
    }else{  
        newFont = [UIFont adjustFont:fontSize];  
    }  
    return newFont;  
}  

以上套路缺点显而易见:不够灵活,将逻辑分散,不便于维护,扩展性也不好。

一种比较好的实践是引入字体集(Font Collection)的概念,什么是字体集呢,我们在用 Keynote 或者 Office 的时候,软件会提供一些段落样式,定义了段落、标题、说明等文字的字体,我们可以在不同的段落样式中切换,来直接改变整个文章的字体风格。

听上去和我们的需求是不是很像呢,我们在代码中也是做类似的事情,将不同场景下的字体定义到一个 Font Collection 中:

@protocol XRFontCollectionProtocol <NSObject>

- (UIFont *)bodyFont; // 文章
- (UIFont *)chatFont; // 聊天
- (UIFont *)titleFont; // 标题
- (UIFont *)noteFont; // 说明
......
@end

不同的场景,灵活选择不同的字体集:

+ (id<XRFontCollectionProtocol>)currentFontCollection {

#ifdef IS_IPhone6
    return [self collectionForIPhone6];
#elif IS_IPhone6p
    return [self collectionForIPhone6Plus];
#endif
    return nil;
}

// set font
titleLabel.font = [[XRFontManager currentFontCollection] titleFont];

适配新的屏幕或者场景,我们只需要简单地增加一套字体集就好了,可以很方便的管理 App 中的字体样式,做动态切换也很简单。


总结来说,用代码在一个尺寸实现设计稿是比较简单的,但是要在各种尺寸下忠实反应设计的想法需要合理的代码设计以及一定的代码量。

UI 的还原其实也是大前端开发非常重要的部分,作为程序员,往往重视代码的稳定,业务的正常使用而忽略软件界面这个同样重要的用户体验因素。设身处地地想,如果设计看到自己精心调配的比例、字体、色号在不同尺寸手机上显示得歪七倒八,一定会气的要死吧。

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

杏仁技术站

长按左侧二维码关注我们,这里有一群热血青年期待着与您相会。