如何让 iOS App 开发调试支持实时刷新 (How hard would it be to adjust your iOS app in real-time?)

如何实时修改

How hard would it be to adjust your iOS app in real-time?

在产品迭代的过程中, 随着设计的变化不断修改代码是非常耗时和乏味的.

一般来说, 设计师在图形设计软件上面修改和编辑设计稿, 并交付给工程师. 微调设计时, 也无法避免这样重复乏味的工作.

如果我们的 App 需要支持多主题这种问题就更加困难了. 如果我们正在使用 Interface Builder 该怎么办 ?

让我们来看看, 怎样实现一个简单的库解决上述所有问题.

Features

首先, 我们列出所有想要的 Features :

  • 实时修改反馈 - 我们作出修改, 马上就能看到修改后的效果, 而不用重新编译和运行
  • 热更新 - 棒棒哒
  • 支持代码布局和 Interface Builder 布局
  • 不止于修改 UI 效果, 也可以微调其它的数据
  • 集成简单 - 并且不影响已有的代码运行效果
  • 方便测试
  • 实时写代码来修改 Xcode 8 使用 code injection

库的设计

尽管我喜欢使用纯 Swift, 但 Objective-C 的 runtime 特性更适合来实现一些开发者工具.

我经常嘲笑, 可变性(Mutation) 是一切 Bug 的源泉, 但它确实如此.

如果我们想实现动态修改正在运行的 App, 而又不影响已有代码, 我们必须避免修改不必要的状态.

程序设计中有一个概念 Trait, 允许我们扩展已有的代码, 添加新的功能, 我们来模拟它.

译者注 : Trait 有点像 OC 中的 Category, 或者 React 中的 Mixins

Trait

如果使用我们库的开发者有一些自定义的类, 我们想修改它, 并且支持随时撤销这个修改.

在我们的库中, Trait 包含一个纯函数, 把纯函数传递给一个实体类, 修改它的状态, 并返回一个新函数, 执行新函数能撤销这个修改.

因此, 当我们执行撤销, 就会恢复到初始状态.

let sourceView = view.copy

let reverse = Trait.apply(view)
reverse(view)

sourceView == view

这种模式下, 我们可以控制修改运行中的 App 的 side-effect.

序列化

使用函数的话, 代码非常好理解.

然而, 我们无法实现更多的功能, 因为 Trait 类型, 不仅有函数, 还需要包含一些 metadata.

在 feature 列表中, 尤其是实时修改运行中的 App, 我们需要序列化更新的代码, 通过网络传输给正在运行中的 App.

我们的需求设定了我们必须支持双向的序列化, 因为我们使用 JSON 所以 Object-Mapper 是个不错的选择.

如果我们不是正在设计一个库的原型的话, 我更建议采用方便我们阅读的格式 比如 YAML, JSON 格式更适合程序读取.

我们将序列化我们的修改函数, 用这种模式实现不同的 Traits, e.g 阴影

open override func mapping(map: Map) {
    super.mapping(map: map)
    color <- (map["color"], ColorTransform())
    offset <- (map["offset"], SizeTransform())
    opacity <- map["opacity"]
}

实时刷新

得益于我们使用 JSON 格式传输数据, 我们现在就支持了动态更新和远程修改.

首先, 我们需要监听代码的修改, 可以利用我写的库 KZFileWatchers 来实现本地和远程的监听.

接下来, 我们需要一个注入修改代码到程序中的方式, TraitsProvider

TraitsProvider 的功能 :

  • 找到已有的 traits
  • 使用 reverse 函数回滚已有的修改
  • 执行新的修改代码 traits, 并保存 reverse 函数
traits.forEach {
  var removeClosure = {}
  $0.apply(to: target, remove: &removeClosure)

  var stack = target.traitsReversal
  stack.append(RemoveClosureWrapper(block: removeClosure))
  target.traitsReversal = stack
}

这里我使用 inout 来修饰 removeClosure 参数, 因为我发现, 迁移到 Swift3 后, 之前返回 closure 的方式有编译的 Bug.

鉴别 traits 并最小化修改原来的代码

每个 View 需要执行不同的 traits, 那我们怎么确定哪一个对象执行哪一个 traits 呢, 我们可以使用 associated objects

简单的写一个 Extension 来实现 :

public extension NSObject {
  var traitSpec: String? {
      get { return objc_getAssociatedObject(self, &Keys.specKey) as? String }

      set {
          if let key = newValue {
              TraitsProvider.loadSpecForKey(key, target: self)
          }

          objc_setAssociatedObject(self, &Keys.specKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
      }
  }
}

public extension UIView {
    /// Defines an identifier for trait specification.
    @IBInspectable override var traitSpec: String? { get { return super.traitSpec } set { super.traitSpec = newValue } }
}

当一个新的 trait 被应用时, 就让 TraitsProvider 执行这个 trait Interface Builder 和 代码修改都可以这样来实现.

支持动态修改库本身

我们希望可以创建新的 Trait 并立即执行它, 不用重新编译项目.

第一步是使用 code injection, 第二步是设计我们的库, 可以随时执行 traits 而不用提前注册.

我们可以使用 runtime 特性找到继承 Trait 类的所有子类 :

typealias Factory = (_ map: Map) -> Trait?
static func getTraitFactories() -> [String: Factory] {
    let classes = classList()
    var ret = [String: Factory]()

    for cls in classes {
        var current: AnyClass? = cls
        repeat {
            current = class_getSuperclass(current)
        } while (current != nil && current != Trait.self)

        if current == nil { continue }

        if let typed = cls as? Trait.Type {
            ret[String(describing: cls)] = typed.init
        }
    }
    return ret
}

然后我们只需要可自动重新注册已有的 Traits 即可.

/// This function will be called on code injection, thus allowing us to load new Trait classes as they appear.
class func injected() {
    Trait.factories = Trait.getTraitFactories()
}

这样意味着我们可以写新的 Traits 不用重新编译我们的项目, 这个例子是我添加一个新的 CornerRadius trait

不仅仅在修改 UI 上使用它

这种模式不仅可以用在修改 UI 上, 可以利用这个实时修改的特性在 App 的其它部分. 我们怎么样安全的实现它呢 ?

我们必须避免开发者尝试给一个模型类添加阴影 trait, 我们可以定义 trait 支持修改的类型.

我们可以生命每一种 trait 支持在什么类型的对象上执行 : var restrictedTypes: [AnyClass]? { return [UIView.self] }

我们的 TraitsProvider 需要在执行 trait 之前验证对象的类型, 保证 trait 可以再该对象上执行.

奖赏 : 避免硬编码字符串

硬编码字符串是非常丑陋的. 我们不能避免在 Interface Builder 中 硬编码字符串, 但是在代码布局中可以.

我们可以写一个简单的脚本, 扫描所有的 Storyboard/xibs 然后为它创建一个类型化的接口. 把它加入到编译阶段, 就能得到更安全的 API 了.

我在上面的实时修改代码的 demo 中就是这样做的.

结论

完整的代码, 文档和测试可以在 Github 找到

开发者使用这个库唯一要做的就是设置一个标记来表明需要修改的对象, 然后执行我们的守护进程.

我们库的设计允许随意修改添加 Traits, 不用重新编译整个项目, 通过 JSON 传输修改代码到正在运行的 App 中, 实时执行更新.

调整我们在 Interface Builder 或者 代码布局的界面, 都是非常容易的, 我们的库也非常简明.


原文 : How hard would it be to adjust your iOS app in real-time?

原作者 : Krzysztof Zabłocki Twitter: @merowing_