如何 HotReload Objective-C 代码——用 SwiftUI

SwiftUI 和 Xcode 11

自从 SwiftUI 推出之后,它带来左边代码右边实时预览的特性,让一直苦于 Objective-C 耗时、低效、繁琐的开发流程的我很羡慕。但是 SwiftUI 需要 Swift 工程运行在 iOS 13 的 target 上才能使用。目前国内大部分的 App 基本都是 Objective-C,最低支持 iOS 9 版本的现状,阻止进一步将 SwiftUI 引入到实际工程里。

直到最近重温 WWDC2019 Mastering Xcode Previews,其中有一句

Finally, you have seen how to use previews not just with SwiftUI and not just with Swift, but with all of the source files that Xcode understands written against UIKit, AppKit, and WatchKit.

让我有了个想法——既然 Xcode 认识 Objective-c,那是不是也支持 Objective-c 的预览?接下来,我在使用Xcode Preview for Objective-C等关键字 Google 了一番,居然没找到类似的方案,有点怀疑是不是搜索姿势不对?

在经过一些尝试,有一些初步的成果,B 站视频演示 使用 Xcode Preview 实现普通的 UIView 的 HotReload。
下面以复盘的方式来介绍下实现的过程。

Xcode 的 Canvas 可以实现预览的原理

Xcode 会自动监听当前 editor 的文件内容变化(注意不是文件在文件系统的变化,而是编辑器缓存区的内容变化),也就是说不需要触发自动保存或者手动保存,自动刷新。当前编辑的文件需要满足条件:

  1. 文件名以 swift 结尾的文件
  2. 至少包含一个继承自 PreviewProvider的 struct 对象

此时,Swift5.1 新加入的特性 _dynamicReplacement 发挥作用;假如你修改了下面函数的内容

struct UIView_Preview: PreviewProvider {
  static var previews: some View {
        UIViewPreview {
            let name = NameFlag()
          // 修改前
            name.configure(withImageName: "duck", name: "小鸭子", count: 2)
          //修改为
           name.configure(withImageName: "chick", name: "小鸡", count: 1)
            return name
        }.padding()
    }
}

则 Xcode会创建一个新的文件,如 UIView_Preview.3.preview-thunk.swift

extension UIView_Preview {
    @_dynamicReplacement(for: previews) private static var __preview__previews: some View {
    AnyView(...)
    }
}

编译之后以 UIView_Preview.3.preview-thunk.dylib 载入运行时,并重新生成 PreviewProvider 对象,此时当前程序里运行的 (for: previews) 即为更改过后的代码,改动后的结果在 Canvas 里显示。

关于Canvas

Canvas 在 Xcode11 beta 阶段曾经叫 Preview,正式版改为 Canvas,Canvas 的本质是是一个模拟器,当前选中是 iPhone 8,则 Canvas 是运行当前 Xcode 最新的版本如 iOS 13.3 的 iPhone 8 模拟器,并且运行在 Release 模式。

模拟器有两种状态,一种是普通模式,正如其名 Canvas,此时只显示 UI,不接受事件,也不发网络请求;第二种模式是 Live Mode,可以理解为把独立模拟器的核心界面嵌入到 Xcode 里面。运行当前工程代码,运行至 Appdelegate 完毕,把当前 View 或者 ViewController 作为类似 RootViewController 的角色展示。类似:

self.window.rootViewController.navigationController.viewControllers = @[ViewController]

关于Canvas 的日志:

Canvas 是个隐藏的模拟器,xcrun simctl list 里看不到,所以你在预览代码时的日志、崩溃在 Xcode 里都看不到,只能去看 Console 或者 crashReports。这真是个缺憾,不知道后期会不会有新的入口能够看到。

如何让Objective-C 代码 Previewable

在我实际尝试过程中,其实为两步,
第一步:如何让 PreviewProvider 返回 SwiftUI 里的自定义视图。出于以下考虑:

  1. 需要把自定义 UI 作为最外层视图,这个它的尺寸取决于它的容器,这个容器包括 UIView 里父节点,也包括 Canvas 以及 previewLayout等语法的约束
  2. 保留预览普通 ViewController 和把 ViewController 嵌入到 NavigationController 的能力;
  3. 不要引入过多的文件封装,可以牺牲单个文件的职责单一性;

我们以 UIView_Preview 的封装为例,代码较少完整代码如下:

import Foundation
import UIKit
#if canImport(SwiftUI)

import SwiftUI
@available(iOS 13.0, *)
struct UIView_Preview: PreviewProvider {
    static var previews: some View {
        Text("我是自定义 SwiftUI视图")
    }
}
#endif

这是最基本的预览任意自定义 View 的代码,但是上述代码成功运行是当前旧工程是否能够使用 Preview 功能的关键,不同的项目要让上述代码能够 Preview 成功需要不同程度的调整。如果是全新的 Objective-c 工程则没什么问题,我们会在下面详细看到要让上述代码工作需要做的努力。

第二步,如何让 PreviewProvider 返回 Objective-C 编写的的自定义视图?
答案很简单:Swift 和 Objective-c 混编,让 UIViewPreview对象返回一个 Objective-c 封装的 UIView 即可。原理很简单:引入 bridging-header.h( bridging-header.h 是你工程里混编使用的头文件,如果没有则新建一个)。从普通开发的角度,从工程上考虑,避免大家去修改 bridging-header.h 这个公共头文件而导致冲突的问题,我将要在PreviewProvider 里预览的 Objective-C 的类头文件,放到另外一个独立的头文件 Preview-header.h,而将其在 bridging-header.h 引入。

//
//  Use this file to import your target's public headers that you would like to expose to Swift.
//

#import "Preview-Header.h"

这样每个开发频繁修改的文件是 Preview-header.h且这个文件在被第一个 commit 之后,就需要加入到 .gitignore 文件里,避免每个人修改导致的冲突。

首先,创建一个普通的 Objective-C 的类。

@interface NameFlag: UIView

- (void)configureWithImageName:(NSString *)imageName name:(NSString *)name count:(NSInteger)count;

@end

然后在 Preview-header.h 引入 NameFlag 类供 Swift 使用,如下;

//
// 这里是  Preview-Header.h 的内容
//
#import "MyViewController.h"
#import "NameFlag.h"

这样在 UIView_Preview.swift 里就可以使用 NameFlag 类,注意接口函数在转为为 Swift 对象时发生了变化:

import Foundation
import UIKit
#if canImport(SwiftUI)

import SwiftUI
@available(iOS 13.0, *)
struct UIView_Preview: PreviewProvider {
    static var previews: some View {
        UIViewPreview {
            let name = NameFlag()
            name.configure(withImageName: "duck", name: "小鸭子", count: 2)
            return name
        }.padding()
    }
}

#endif

UIViewPreview是自定义 SwiftUI 的封装,返回 some View 对象。类似的对 UIViewController 有类似的处理方式,不过因为 UIViewController 在 UIKit 的界面里算是顶级元素,可以独占整个屏幕所以不需要再封装,详细代码见:

@available(iOS 13.0, *)
struct UIViewController_Preview: PreviewProvider, UIViewControllerRepresentable {
    
    typealias UIViewControllerType = UIViewController

    static var previews: some View {
        UIViewController_Preview()
    }
    
    func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIViewController {
        let vc = MyViewController()
        vc.title = "下面是小鸡或者小鸭子的图形"
        let nav = UINavigationController(rootViewController: vc)
        return nav
    }
    
    func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext) {
        
    }
}

上面的MyViewController是 MyViewController.h 文件被引入到 Swift 里,可以直接使用。

以上即是所有技术细节,并不难,我也很奇怪为啥没人分享是因为太简单了吗?后来当我把上面的验证性代码引入到实际工程里才发现,并不简单。

引入到严选 App

严选 App 源代码创建于 2015 年,是个典型的 CocoaPod 工程,创立之初就有很多功能被抽离为 Pod 集成到主 App,同时也引入了很多业界优秀的 Pod,它是基于 CocoaPod 静态库构建,工程已经比较大了,直接写的代码行数 15 万行,不包括第三方引用。因为它是我现在使用最多的工程,先拿它来试试,在尝试过程中遇到了很多问题:

  1. 缺少 Swfit_Version 指定,相信这是早期 Objective-c 工程的通病。解决方法,手动指定为 Swift5
  2. SWIFT_OPTIMIZATION_LEVEL 必须是 -Onone,也就是不能优化,个中原因没深究,可能是被优化后执行 replacement 会出错吧。
  3. 启动时奔溃,查看日志发现是 willPresentNotification 时失败导致奔溃,删除所有 Push 通知相关代码,再试
  4. 接着出现了网易支付 sdk 没有支持 x86-64 架构 .a文件的错误,继续删除尝试。

还是启动时崩溃,下面几个错误交替出现

CrashOnLaunchError: yanxuan app crashed on launch
The app "yanxuan" crashed while updating the preview. Look for crash logs in ~/Library/Logs/DiagnosticReports for more details.

| Error Domain=com.apple.dt.ultraviolet.service Code=12 "Rendering service was interrupted" UserInfo={NSLocalizedDescription=Rendering service was interrupted}

[FBSSystemService][0x1125] Error handling open request for yanxuan: {
userInfo = {
FBSOpenApplicationRequestID = 0x1125;
}
underlyingError = ;
}

限于水平有限,一直没有解决,按照错误提示,在网上找了一圈,初步认为是 Xcode Preview 自身的问题,另外找到两个关于在 CocoaPod 里使用 Xcode Preview 崩溃的 issue(见参考链接),要么就是加载 Classes 时对 category 没加载?暂时放下,再试试其它项目。

网易有钱的集成遇到的问题

网易有钱脱胎自青柠,代码架构也是基于 CocoaPod,也有很多自定义的 Pod 继承,引入了一些优秀的第三方库,不过因为引入 Pod 机制较早当时还不存在动态 Pod 能力,所以全部基于静态库构建,直接写的代码行数 40 万行,不包括第三方引用。在尝试把 PreviewSupport 集成到有钱时还是遇到了很多的问题;

  1. 必须指定 Swift_Version
  2. debug 模式下,SWIFT_OPTIMIZATION_LEVEL 必须是 -Onone
  3. package 的配置里,需要 release 下的名字,如果不对无法预览成功(不解原因)。
    Release 模式
  4. 自定义字体导致运行是可以的,但是 Preview 时失败,*** CFRelease() called with NULL ***
  5. 监控 sdk hook sauronEye_InitWithTarget 出现死循环,同样也是 Run 成功,Preview 出现 crash
  6. fatal error: file '.../PreviewSupport/Preview-Header.h' has been modified since the precompiled header '.../Build/Intermediates.noindex/PrecompiledHeaders/MoneyKeeper-Bridging-Header-swift_2T5I95P4IJAHN-clang_1N1GJRDEZ3R5Z.pch' was built,解决方式删掉 PrecompiledHeaders 目录
  7. 有钱的启动页有两个前置界面:1. 一个优先级非常高的“隐私弹窗”(最近工信部的要求);2.一个是新手引导和 splash。这两个会导致预览 UIView 的时候,其实已经渲染出来了,但是被上面两个界面遮住了,导致 Canvas 里一片空白。
  8. 七鱼 SDK 引入后,SDK 没有 x86-64 架构导致的符号找不到的问题,但是实际上 Run 是可以的。简单解决,删掉所有七鱼的引用

进过一天的折腾,删代码、rebuild 反复操作,终于成功运行了,结果如下;


基金搜索界面

网易推手的集成

推手是个比较新的项目,基于 CocoaPod 构建,代码量不大,基于静态库构建,直接写的代码行数 1 万行,不包括第三方引用。把 PreviewSupport 集成到推手还算顺利;

  1. 指定 Swift_Version
  2. 修改 debug 模式下,SWIFT_OPTIMIZATION_LEVEL 为 -Onone

接下来就可以使用 Canvas 开发了,以未读信息提醒组件为例;


修改小红点组件

小小·装修宝的集成

小小·装修宝是我第一个练手的个人项目项目,基于 Carthage 构建,代码量不大,大概有 4 万行代码,基于 framework 构建,截图里全是动态库。


第三方 framework

直接写的代码行数 1 万行,不包括第三方引用。把 PreviewSupport 集成到装修宝遇到了很多问题;

  1. 原本 target里有个 UITests 存在,但是代码被删了,Run 能成功,Preview 失败。解决方法,删掉 UITests 这个 target
  2. Framework search path 那里配置的多个路径,其中有个在前面的路径是错误的, Run 能成功,但是 Preview 提示 ld 阶段出错,解决:删掉错误的路径
  3. 指定 Swift_Version
  4. 修改 debug 模式下,SWIFT_OPTIMIZATION_LEVEL 为 -Onone
  5. 链接失败
BuildError: Failed to build UIView_Preview.swift

Compiling failed: linker command failed with exit code 1 (use -v to see invocation)

failedToBuildDylib: ld: warning: directory not found for option '-F/Applications/Xcode.app/Contents/SharedFrameworks-iphonesimulator'
Undefined symbols for architecture x86_64:
  "___cxa_demangle", referenced from:
      +[CLSDemangleOperation demangleCppSymbol:] in Crashlytics(CLSDemangleOperation.o)
  "___gxx_personality_v0", referenced from:
      +[CLSDemangleOperation demangleBlockInvokeCppSymbol:] in Crashlytics(CLSDemangleOperation.o)

看起来是 Crashlytics 出问题,只是不知道为何出错,Run 是成功的。最粗暴的方式删掉 Fabric 和 Crashlytics 之后 Preview 成功!
我仔细审视了这两个库,

% file Fabric.framework/Fabric                                                                                     
Fabric.framework/Fabric: Mach-O universal binary with 5 architectures: [arm_v7:current ar archive] [arm_v7s] [i386] [x86_64] [arm64]
Fabric.framework/Fabric (for architecture armv7):   current ar archive
Fabric.framework/Fabric (for architecture armv7s):  current ar archive
Fabric.framework/Fabric (for architecture i386):    current ar archive
Fabric.framework/Fabric (for architecture x86_64):  current ar archive
Fabric.framework/Fabric (for architecture arm64):   current ar archive
 % file Crashlytics.framework/Crashlytics                                                                            
Crashlytics.framework/Crashlytics: Mach-O universal binary with 5 architectures: [arm_v7:current ar archive] [arm_v7s] [i386] [x86_64] [arm64]
Crashlytics.framework/Crashlytics (for architecture armv7): current ar archive
Crashlytics.framework/Crashlytics (for architecture armv7s):    current ar archive
Crashlytics.framework/Crashlytics (for architecture i386):  current ar archive
Crashlytics.framework/Crashlytics (for architecture x86_64):    current ar archive
Crashlytics.framework/Crashlytics (for architecture arm64): current ar archive
// 作为对比下面是用 carthage 引入的 ZipArchive.frame
 % file ZipArchive.framework/ZipArchive                                                                                       
ZipArchive.framework/ZipArchive: Mach-O universal binary with 4 architectures: [i386:Mach-O dynamically linked shared library i386] [x86_64:Mach-O 64-bit dynamically linked shared library x86_64] [arm_v7:Mach-O dynamically linked shared library arm_v7] [arm64:Mach-O 64-bit dynamically linked shared library arm64]
ZipArchive.framework/ZipArchive (for architecture i386):    Mach-O dynamically linked shared library i386
ZipArchive.framework/ZipArchive (for architecture x86_64):  Mach-O 64-bit dynamically linked shared library x86_64
ZipArchive.framework/ZipArchive (for architecture armv7):   Mach-O dynamically linked shared library arm_v7
ZipArchive.framework/ZipArchive (for architecture arm64):   Mach-O 64-bit dynamically linked shared library arm64

Fabric 和 Crashlytics 是异类,只有他们两作为静态库引入工程,其他全是动态库 framework。
举例,这次以 MIFLiveController 为例;


首页的调试

至此,可以认为 SwiftUI 的 Preview 可以在大部分旧工程里使用了,不过需要些改动,改动范围视旧工程的情况而定。

我们已经解决了,开发时如何 Preview 的问题,但是作为代码的生命周期还需要考虑 Run 和 release 下,如何隔离 iOS 13+ 的 SwiftUI 源码的问题。

预览、开发运行、发布环境的代码隔离

Xcode 在 iOS13 SDK 上编译,我们的 App 还是可能运行在 iOS 11 的设备,这时候需要在 iOS 13 上这些函数不可见,这一点很容易做到;


#if canImport(SwiftUI)
import SwiftUI

@available(iOS 13.0, *)
struct UIView_Preview: PreviewProvider {
    static var previews: some View {
         Text("check me")
    }
}

#endif

@available(iOS 13.0, *) 即可实现。最大的问题是如何做到在 iOS11 不执行 import SwiftUI 导入?

我尝试 Swift 里的 Compilation Check #if (可用命令见参考链接)指令,其中最接近的是#if canImport(SwiftUI),实际在 iPhone 8 (iOS 11) 上运行,会报错,找不到 image,应该是 canImport 是编译期间检查因为用 Xcode 11 编译通过,但运行时崩溃。

dyld: Library not loaded: /System/Library/Frameworks/SwiftUI.framework/SwiftUI
  Referenced from: /Users/hite/Library/Developer/CoreSimulator/Devices/52DDD947-9105-4DD8-9C98-72EB4FD2B2BA/data/Containers/Bundle/Application/932D68B9-B32E-412B-9129-906DCEFDD788/OC_Previewable.app/OC_Previewable
  Reason: no suitable image found.  Did find:
    /System/Library/Frameworks/SwiftUI.framework/SwiftUI: mach-o, but not built for iOS simulator

最后我想到的方案是——将 SwiftUI.framework 设置为 optional


设置为 optional

虽然不够优雅,但解决问题。

第二个问题,release 时这些 Preview 用的文件资源不打包。这个很简单,因为 Xcode 提供了 Development Assets配置项,把 PreviewSupport 整个都加入 Development Assets。

Development Assets

以上即是所以针对Objective-c 旧工程解决方案,还有些问题没解决。如果是你的旧工程是 Swift 项目情况更乐观。例如,我还有一个个人项目是9星浏览器,是用 Swift 编写的,毫无疑问,完全可以借助 SwiftUI 来开发,甚至全部用 SwiftUI 重写原来的 UIKit 组件。所以如果你的 Swift 项目,因为不能做到 iOS 13+,而不能使用 SwiftUI 来编写界面,那在开发阶段完全可以用 SwiftUI 的预览功能来加快界面编写速度,提高生产力。

使用 Canvas 调试 Objective-C 的遗留问题

  1. 修改 Objective-C 后保存 resume 之后速度比较慢才能看到效果。
    预览速度等同你的工程启动的速度。建议开发阶段将一些逻辑省略掉;多用动态库,减少 build 时候 link 的耗时等
  2. ~/Library/Logs/DiagnosticReports 下面的 log 出现有很大的延迟。
    在我的试验中,Canvas 里提示崩溃了 N 次了,点击 Show Crash logs 按钮,打开的文件里还是空空如野。你可以尝试用 terminal 打开这个目录看看,有意外收获。
  3. 有时候,你在 bridging-header.h 文件里新增了新的 Objective-c 的类,此时 Preview 会出错,需要清理了 PrecompiledHeader 文件夹等。
  4. Objective-c 的预览的速度不够理想,有时候和旧流程,保存 —> Ctrl+R —> 点点点、跳转跳转 速度差不多。它最大的长处在于省去你从首页点击跳转到你目标页面的冗余操作;还有就是 mock\ 单元测试能力,这个另文详解。
  5. Preview 运行的条件比较严格,我还没找到是哪些配置会影响,导致 Run 能成功但是 Preview 失败
  6. 在 Swift 文件里的修改不需要手动 save 以及手动触发 resume,即时编辑即时生效,原因是 Xcode 监听 .swift 文件的内容改变而不是文件在文件系统的变化。而且其中正在修改的内容,包括字符串这种对象和数字 2 这种值类型,都被封装参数,成了函数的下标,所以不需要重新编译,直接修改参数内容,立即可以生效看到效果,速度非常快。
  7. 接上条,能不能做到:监视 Objective-c 源文件的变化,即时响应。
  8. 动态库和静态库混合使用会失败的原因不明,也许是 Xcode 本身的问题

抛砖引玉

如果你要引入到自己的工程,只需要把 PreviewSupport目录复制到当前工程里,如果是新建 bridging-header.h 文件则需要做些调整。另外需要修改 Preview-Header.h 文件,引入需要预览的视图类。

可以看到,整个过程不够解耦,改动也比较多,这套预览 Objective-c 代码的解决方案,还有很多问题,还处于"实验室阶段"。没有解决严选无法使用的问题;解决 iOS 13以下不导入 SwiftUI 的方式不够优雅;没有搞清楚为何能够在模拟器是 Run 成功的,在 Preview 时会各种报错,导致这些差异的配置在哪儿?

但我还是发出来了,限于个人水平,希望有大牛能够帮我解决上面的问题,让我能够在实际项目里简单的用起来,不需要那么多的修改,提前给大牛们说声:

tql,感谢~

参考:

  1. Mastering Xcode Previews
  2. Compilation Check #if
  3. SwiftUI preview is not working when using the CocoaPods plugin
  4. Unable to see XCode/SwiftUI Previews within CocoaPods frameworks
  5. https://nshipster.com/swiftui-previews/
  6. https://github.com/hite/OC_Previewable

你可能感兴趣的:(如何 HotReload Objective-C 代码——用 SwiftUI)