基于SwiftUI的组件化构建实践

阅读本文需要具备一定的iOS开发经验。

SwiftUI是Apple于2019年WWDC上发布的基于Swift语言构建的全新声明式UI框架,抛弃了之前Autolayout,Storyboard的布局方式,提供了Canvas实时预览功能,以提升UI开发的体验。

背景

某迭代了10年+的大型单体iOS项目,之前完成了部分功能从Objective-CSwift语言迁移的工作。加上近两年该司推行整个大前端(web + mobile)向MFE(Micro-Front-End)迁移的策略,由此加入了一个MFE项目,工作内容是将主页功能剥离成独立的组件。

正篇

作为一个上层业务组件,我们会依赖一些已有基础组件框架。受限于一些历史遗留原因,无法直接使用SPM(Swift Package Manager)作为项目的依赖管理工具。还是使用了Cocoapods作为项目的依赖管理工具。

项目结构

作为一个组件化项目,主要的业务代码会以Framework的方式提供给主工程。同时在开发的过程中会建立一个ExampleApp方便开发者调试。

这个场景就有两种方案可以选择:

  1. ExampleAppFramework建立两个不同项目,ExampleApp通过Cocoapods以本地源码的方式引入Framework
  2. FrameworkExampleApp放在同一个工程文件中,以不同Target的形式引入。

两种方案各有利弊

  • 方案一

    • 优势:ExampleAppFramework较为分离,更符合真实的使用场景(接入主App),也是业界的主流选择。
    • 劣势:每次新加文件和资源都需要pod install一次,经常需要开两个项目工程来调试。
  • 方案二

    • 优势:调试方便,新加文件只需CMD + B编译一次,ExampleApp就能引用到。
    • 劣势:创建文件的时候需要小心关注Target归属,与真实使用场景有差异。在接入主工程的时候,可能会暴露问题。

根据团队成员技能水平,使用习惯,综合讨论采用了第二个方案去构建项目。建立出的项目结构如下图:
Screen Shot 2021-09-02 at 5.22.14 PM

通过Embed的方式将Framework引入ExampleApp:
-w907

开发准备

上一步已经把项目基本框架搭建起来了,现在进入工程准备开发。打开工程,Target选择ExampleApp
-w176

打开Canvas预览默认的Hello world界面,all good!

Preview_success

毕竟ExampleApp只是用来调试的,不接入主工程没那么重要。

接下来将Target切换成我们的MFE
-w163
满心欢喜打开预览,由此掉入第一个坑。

Canvas报错,预览无法正常工作

-w1116

如果Canvas不能用,那就又回到了以前使用UIKit盲写UI的时代了。

Check报错信息:
WeChat18b28ab0c2b8aeebd84d25c23cfbf7a9

项目MFE依赖了一个基础的组件,log的最后一行输出了关键信息,MFE依赖的基础组件缺失。

遇到依赖组件没有找到的问题,首先想到的是去查DerivedData中的Build结果。

通过/Users/{MAC_Account_Name}/Library/Developer/Xcode/DerivedData/TestMFE-etvmtlvxanvppnhldjgozcqkeqdq路径,打开MFE的DerivedDataBuild文件,发现SwiftUI项目多了一个Previews文件夹,在这个文件夹下找到MFE在模拟器中的编译生成文件。

最终路径为:/Users/{MAC_Account_Name}/Library/Developer/Xcode/DerivedData/TestMFE-etvmtlvxanvppnhldjgozcqkeqdq/Build/Intermediates.noindex/Previews/TestMFE/Intermediates.noindex/Debug-iphonesimulator

在这个路径下我们看到的文件结构是这样的:
-w1003

可以看到MFE依赖的SwifterSwift库,并没有在TestMFE.framework的文件目录下。我们尝试手动将SwifterSwift放到TestMFE.framework文件下。

WX20210902-205226@2x

然后重新启动Canvas, 预览成功!

Screen Shot 2021-09-02 at 20.50.18

依据这个思路得出

  • 解决方案一:
    在Xcode中的Build phase中,添加一个脚本,编译完成将依赖库复制到MFEBuild文件中去。

    1
    cp -R "$BUILT_PRODUCTS_DIR/dependency/dependency.framework" "$BUILT_PRODUCTS_DIR/$FRAMEWORKS_FOLDER_PATH"

    但该解决方案有个弊端,如果MFE只依赖很少的基础组件还好。若需要依赖很多基础组件的话,维护这个脚本就需要很大的成本。那有没有更优的解法?

  • 解决方案二
    既然我们使用Cocoapods进行依赖管理,那通过pod指定Previewsearch path应该也可以link到相关的依赖库。

通过搜索CocoapodsGit issue找到一种重写podAggregateTargetSettings, 为Preview link更多search path的方式。我们需要在Podfile中添加如下描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// In Podfile

class Pod::Target::BuildSettings::AggregateTargetSettings
alias_method :ld_runpath_search_paths_original, :ld_runpath_search_paths

def ld_runpath_search_paths
return ld_runpath_search_paths_original unless configuration_name == "Debug"
return (ld_runpath_search_paths_original || []) + (framework_search_paths || [])
end
end

class Pod::Target::BuildSettings::PodTargetSettings
alias_method :ld_runpath_search_paths_original, :ld_runpath_search_paths

def ld_runpath_search_paths
return (ld_runpath_search_paths_original || []) + (framework_search_paths || [])
end
end

Canvas成功预览!

小结:
SwiftUI是一套全新的UI框架,目前对其CanvasPreview还不具有很深刻的认识。在解决上述预览问题的过程中,猜想Preview是用Xcode工程项目中对开发者不可见的AggregateTarget来构建的,随着相关文档逐渐完善,希望以后有机会更深入地了解其实现机制。

解决了上述预览问题,打开工程准备开始写demo code, 随即出现了另一个影响开发的问题。

Auto Preview Failure

在理想状态下,我们使用Auto Preview的状态应该是这样的:
Kapture 2021-09-03 at 10.20.12

然而在项目中我们的实际使用状态,每输入一个字符,就会遇到这个场景:
WX20210903-104039

Auto preview updating paused, 每次更新完UI部分的代码,都需要手动点击resume来刷新,丢失了此前丝滑的开发感受。

对于这个问题刚开始没有任何思路,却在点击右侧resume按钮的时候,发现其会重新触发一次Build流程。

从这个角度入手,尝试查找MFEBuild的过程中是否有额外的操作。在Scheme-Build-Post-actions, 发现了一个之前给Xcode文件排序用的脚本。

WX20210903-142957@2x

尝试移除这个文件排序脚本,Auto preview可以正常工作了。那么文件排序的工作该如何处理?

  • 方案一: 放在Run-Post-actions中,这是一个非常直接的解法。然而项目的MFE是一个Framework工程,没有Main函数入口,也就不存在Run这个流程。因此,这个方案并不合适于此场景。

  • 方案二:已经了解了Auto preview被pause是由于在Build前后插入了其他操作。那只需找到一个合适的位置插入对应的操作。在Xcode工程里面,哪里最适合做这些额外的脚本操作? –Build Phases

    按照这个思路将文件排序的脚本放到Compile之后,Link Binary之前:
    WX20210903-151013
    结果失败。

    尝试将脚本移动到Link Binary之后,依然不work。行进至此,感觉进入了死胡同。

    绝望之中寄希望于stack overflow,找到了关于脚本执行的解决方法

    通过环境变量,判断Preview的状态,在Preview结束之后再去执行脚本中的操作。改动后的脚本如下:

    问题解决! Auto preview works well!!

小结:
在项目工程搭建的过程中,遇到的两个问题都是和SwiftUIPreview有关。对Preview目前的了解尚浅,经历了这个过程至少知道了Preview其实也隐式的执行了Build的流程。

后续

解决了影响开发效率的问题,项目搭建基本完成。然而功能的开发才刚刚开始……