YRoute开发随笔

50次阅读

共计 6101 个字符,预计需要花费 16 分钟才能阅读完成。

YRoute 是一个新开发的 Android 路由库,使用了 arrow 函数式库作为核心库,是之前对于函数范式学习和思考的集大成者。但目前还在前期开发阶段,仅实现了一些简单的功能做架构验证用。

OOP 中的 23 种设计模式相信大家已经烂熟于心了, 它们已经被广泛应用于软件工业的各个领域. 它们当初被创造是因为当时旧的编程思想在软件规模逐渐庞大的情况下已经难以驾驭了. 然而随着软件工业这么多年的持续发展, 同样的问题又来到了 OOP 的面前, 现在的代码抽象度越来越高, OOP 的很多技法已经开始有点捉襟见肘, 这也是为什么这几年抽象度更高的函数范式的概念被越来越多的提起
函数式编程中单子、高阶类型等概念被经常提起, 但 面向组合子编程 的概念却少有提及, 它是一种与以前构建程序完全的不同的思维模式: 由下至上构建程序.
YRoute 是使用这种方式进行构建的, 希望通过这个库和这篇对于开发过程描述的文章, 对大家会有所启发

前因

FragmentManager 是几年前个人开发的一个 Fragment 管理库,相比其他库有 Rx 方式启动、多堆栈切换、Fragment 与 Activity 一致的动画处理等等。此库在多个实际项目中被使用,功能被不断完善,稳定性、灵活性也得到了项目的验证,所以现在基本是项目开发的默认基础库了。

但对于个人而言其实一直不满于这个库本身的架构技术。最开始构建的时候以功能实现为主,也以以前习惯的 OOP 思想进行构建(因为那个时候还没有被函数范式“荼毒”),导致了架构上的各种问题:

    1. 状态和逻辑混杂在一起
    1. 由于需求的功能基于 Fragment 和 Activity 的很多基础方法和生命周期,导致需要强继承 BaseFragmentManagerActivity BaseManagerFragment
    2. BaseFragmentManagerActivity被设计为了“超级类”:功能强大,但包含了大量可以被分离的逻辑,导致逻辑代码混杂
    3. 由于启动、切换等功能逻辑很复杂,需要很多的判断和异常处理,导致有些方法虽然很相似却依然无法被重构合并,方法的粒度大,难以被组合
    4. 虽然中期在添加新功能的时候尝试进行逻辑分离(比如侧滑返回返回功能SwipeBackUtil 、Rx 启动功能、抖动抑制ThrottleUtil ),但基于 OOP 设计本身的缺点,它们的分离没有统一的模式,也无法真正清晰的分离,实际功能代码还是需要依赖混杂到BaseFragmentManagerActivity 
    5. 后期虽然也希望借鉴 Redux 等架构设计进行了几次重构,但那时对函数范式架构的理解还不够深,导致架构本身难以承受库本身复杂的功能,而且也没有达到最初希望的灵活性,甚至相比原始的架构更加难用了

    得益于对函数范式在实践中的更多理解, 才有了 YRoute 这个库的出现

    YRoute 之前

    要理解 YRoute 库, 首先需要介绍一下相关的几个数据结构

    Lens

    这是一个用于类型转换的数据类型, 从它的定义上就可以看出

    data class Lens<S, T>(get: (S) -> T,
        set: (S, T) -> S
    )

    它包含两个函数, 一个是从数据类型 S 中提取 T 的 get 函数, 二一个是将旧的 S 和 T 数据组合成为新的 S 的函数set

    用法可以参照:

    data class Player(val health: Int)
    
    val playerLens: Lens<Player, Int> = Lens(get = { player -> player.health},
        set = {player, value -> player.copy(health = value) }
    )
    
    val player = Player(70)

    Reader

    在函数范式中我们会提取很多的单子, 而其中函数本身其实也是一种单子, 而函数 (D) -> A 所抽取的单子就是Reader:

    class Reader<D, A>(val run: (D) -> A)

    Reader 的意思可以理解为 从数据类型 D 中读取 A 数据

    它还有个高阶类型的版本ReaderT:

    class ReaderT<F, D, A>(val run: (D) -> Kind<F, A>)

    这个版本之后会使用到

    State

    State正如其名, 是状态机的高级抽象, 本质上而言它就是 (S) -> Pair<S, A> 函数, 即是表示 输入旧的状态, 返回一个新状态并得到一个值 A , 每运行一次便代表状态机状态的一次转化

    它也有一个高阶类型的版本StateT:

    data class StateT<F, S, A>(val run: (S) -> Kind<F, Pair<S, A>>)

    返回的不是纯粹的元组Pair<S, A>, 而是一个被单子 F 包裹的元组

    IO

    这里的 IO 不同于 Java 或者其他语言中所简单代表的 Input/Output, 函数范式要解决的核心问题是如何去掉 副作用 , 然而副作用是程序中必须的存在, 输入 / 输出就是典型的副作用, 程序都是通过一系列输入产生一系列输出而运行的. 所以函数范式或者说 Haskell 中不是 去掉 副作用, 而是 隔离 副作用, 通过类型的方式. 而 IO 数据类型就是用于描述、包裹副作用的单子, 可以认为看到 IO 类型就知道里面是带副作用的, 而组合 IO 类型或者没有 IO 类型的话就是无副作用的纯函数

    IO 数据类型本身是纯的, 组合它也是纯的, 只有在最后执行它的时候会产生副作用, 即 unsafeRunAsyncunsafeRunSync 等方法, 可以看到这些执行方法前面都有一个 unsafe 前缀, 因为这些方法都不是纯函数, 因为它们执行了副作用. 也正因如此, 通常只会在一个地方使用这个方法, 那就是入口函数main

    理解了以上一些基础类型之后, 可以开始进入 YRoute 库了

    进入 YRoute

    YRoute 的核心其实很简单, 就是类型YRoute<S, R>, 可以看一下库中对它的定义:

    typealias YRoute<S, R> = StateT<ReaderTPartialOf<ForIO, RouteCxt>, S, YResult<R>>

    由于 Kotlin 类型系统不够强大, 只能这样描述. 结合我们上面对其中使用的几种数据类型讲解, 实际上 YRoute 类型可以看作:

    (S, RouteCxt) -> IO<Pair<S, YResult<R>>>

    其中 RouteCxt 是 YRoute 中定义的上下文数据, 所以它可以看作: 输入旧状态 S 和上下文 RouteCxt, 输出包裹副作用的 IO 类型, 其中 IO 返回的值为新的状态 S 和运行结果 YResult<R>

    这是对 路由 这种业务逻辑的高阶抽象: 路由就是对上下文进行操作 (比如启动 Activity 或者管理 Fragment 等) 然后将一些额外的状态进行变换, 得到一个新的状态以及运行结果(结果可能是失败或者成功)

    YRoute 中的基本组合子

    那么上面的 YRoute 类型最开始是如何被确定的呢

    其实最开始使用了 YRoute<S, P, R> 的数据类型,即在 Route 构建时便固定了输入参数 P 的类型。这是基于最开始提取核心类型时建模为 S -> (P -> R), 即希望最终 Route 的运行结果是一个 P -> R 的函数, 所以类型是

    (S) -> Pair<S, (P) -> R>

    但实际由于函数肯定中间会涉及副作用,所以输出结果肯定需要由 IO 类型进行包裹, 同时运行结果不一定会成功, 需要 Result 类型表示成功或失败, 于是类型变为

    (S) -> IO<Pair<S, YResult<(P) -> R>>>

    上面的输入中没有上下文, 而实际路由过程中 Context 肯定是必须的 (因为需要操作界面), 所以定义了一个RouteCxt 上下文类型并作为输入:

    (S) -> (RouteCxt) -> IO<Pair<S, YResult<(P) -> R>>>

    这个函数用上一节我们介绍的几个单子可以组合为类型:

    StateT<ReaderTPartialOf<ForIO, Tuple2<RouteCxt, P>>, S, Result<R>>

    可以看到最初构建的时候相比现在多了一个 P 类型, 作为路由输入参数的表示

    这个版本的 YRoute 的 3 个类型是有不同的变换方式的:
    S:S1 到 S2 的状态变换需要 S1 -> S2 和 S1 <- S2 两个函数(使用 Lens 类型进行描述)
    P:P1 变换到 P2 需要 P1 <- P2 函数
    R:R1 变换到 R2 需要 R1 -> R2 函数

    由于它们的变换方向完全不同,会导致这时 YRoute 进行组合的时候非常复杂,通常需要考虑三种状态的分别变换,如果相互之间还需要提取转换的话会变得更加复杂。

    而这种复杂是否真的值得呢?不一定。

    追寻保留 P 类型的最开始初衷,是希望 P 可以被 延迟 (lazy) 提供,这样可以使得 Route 的构建和 Route 的使用完全的分开。但实际上有些 Route 构建时需要的参数只是一些中间参数,并不需要保留到外面;同时这样做使得 Route 需要的参数可以被放在函数参数中(如 startActivity(builder: Builder))、也可以放在 YRoute 范型中(如YRoute<S, Builder, R>),两种方式好像一样又好像有点区别,容易引起混淆,而这两种方式使用和变换上由有些不同,影响了使用的灵活性;而实际上P 这个类型和 Route 路由运行时没有关系,核心运行时 Core 的最终作用是执行 Route 并返回 R,它并不关心 P,所以实际上 P 必须在放进 Core 执行前就被 固定 住。

    而最终改变 YRoute 类型定义的最核心一个原因是:作为延迟参数类型的 P 实际是 YRoute<S, R> 类型的一个增强类型而已。回到最上面说的 YRoute<S, P, R> 类型的函数表示:
    (S) -> (RouteCxt, P) -> IO<S, Result<R>>

    稍微变换一下参数顺序:
    (P) -> (RouteCxt, S) -> IO<S, Result<R>>

    对于只关心结果 IO<S, Result<R>>  的我们而言它们是 等效,所以我们完全可以将 YRoute 定义为:YRoute<S, R> = (RouteCxt, S) -> IO<S, Result<R>> ,而定义LazyYRoute<S, P, R> = (P) -> YRoute<S, R>

    这样 Route 的定义可以不再考虑 P 的变换,变得更加自由、简单。而如果需要 延迟参数 的功能使用 LazyYRoute 类型包装即可。

    这就是函数编程通过组合的方式增强类型能力的编程手法

    从砖块到大厦

    经过一系列的脑力运动后, 我们确定了我们的核心组合子YRoute, 这就相当于我们的砖块, 但我们的目标是搭一个大楼出来, 那就来看看我们用它能做点什么吧.

    以启动 Activity 的功能为例, Android 中默认的流程是: 创建 Intent、然后用 startActivity 方法启动, 那么我们就现构造两个个基本路由:

    fun <T : Activity, VD> createActivityIntent(builder: ActivityBuilder<T>): YRoute<VD, Intent>
    
    fun startActivity(intent: Intent): YRoute<ActivitiesState, Activity>

    createActivityIntent用于创建 Intent; startActivity用于通过 Intent 启动新 Activity. 于是可以组合这两个函数成为新 YRoute 实现新功能:

    val newRoute = createActivityIntent(builder)
        .flatMap {intent -> startActivity(intent) } 

     StackRoute  中有更复杂的组合示例

    再回到副作用

    函数式编程中我们会反复讨论 副作用, 因此一些“函数式”架构也会主打副作用隔离, 比如 Redux 和 Flux, 它们尝试通过分层的方式隔离掉副作用, 即中间件, 它们希望副作用只在中间件中执行, Reducer 是纯函数.

    但实际使用过这些架构的人就会知道它是多么的“反人类”: 一个简单的逻辑被分散到了 Controller、Action、Reducer、Middleware 以及相应的 State, 整个程序到处散落着界面的逻辑; 本身 Action 和 Reducer 等又有着大量的模版代码.

    这导致之前使用这些架构对 FragmentManager 进行重构的时候各种带刺

    这里的根本原因是, 它们虽然了解了副作用的处理对于程序的重要性, 但解决上却仍然是使用的 OOP 的思维方式. 它们是尝试通过“分层”的方式分离副作用, 这是一种粒度很大的隔离方式, 缺乏组合性

    而 Haskell 中是使用类型 IO 进行副作用分离的, 正如上文所说, IO 会包裹副作用, 但对 IO 的操作除了那些 unsafe 方法其他都是无副作用的, 所以 IO 可以存在于程序的任何地方, 也可以与其他“纯”的数据结构进行任意组合而不会破坏程序的“纯度”, 这就是通过“类型”的方式进行副作用隔离

    最后是 Rx

    Rx 系列是近几年非常火的库, 但它既不是 ORM 库也不是网络库, 实际上它本身没有任何业务逻辑, 但当在项目中使用它的时候却能确实地感觉到它与其他库的与众不同, 它给程序构建带来的全面的改变.

    这是为什么呢? 或者说 Rx 究竟是一个什么库?

    • 它像 IO 类型一样包裹副作用、将副作用隔离, 操作它的函数大部分是纯函数保证代码本身的纯度
    • 它使用函数范式中类似 Either 的方式分离出异常处理逻辑, 让当前代码可以专注业务逻辑
    • 它有着 Async 类型类的功能, 抽象了异步模式
    • 它有着大量类型类的方法: Fold、Flatten、Functor 等等, 提供了丰富而高度自由的操作符

    可以看到, 它就是函数范式中基本工具的集大成者, 它是一个函子、是一个单子、是一个 IO、是一个 Async 等等, 它把这些功能统统集中起来, 当然最核心的是实现了 Push-Pull FRP 流

    但反过来说, 它的这种通用和强大反而是一种不足, 它成为了一个“超级类”: 一个类型里面包含了过多的功能, 导致描述性降低. 这句话可能难以理解, 举个栗子: 当我们一个函数返回 Single<Int> 的时候, 我们可以解读出这些信息:

    1. 它可能内部可能会产生异常, 但我们不知道异常的类型是什么
    2. 它内部可能是异步的, 但我们不知道是在什么线程执行的

    由于不确定, 所以我们需要处理所有的情况, 就像传入一个 Any 我们就要判断所有可能的值, 这就是描述性不足: 无法准确表达程序的意义

    除此之外还有组合性的问题, 明明 MaybeSingle都有一个叫 map 的函数, 却必须写两遍, 因为他们被视为两个不同的函数, 无法被抽象成同一个函数

    System F-sub

    以上这些当然最核心的原因是限制于 Java 本身语言表现力的问题, 所以无法完全按照函数范式的方式来实现. 反观其他语言, 更具语言表现力的 Scala 中, RxScala 并不流行; 纯函数式语言 Haskell 中根本就没有 Rx, 因为它有 Reactive-Banana、Yampa 等更强大的库, 它们是更贴近 FRP 理论本源的实现

    Rx 不是一个通用异步处理工具这么简单, 它将函数范式的一瞥带入了 OOP 中, 即带来了极大的改变. 虽然它有一些不足, 但限于语言本身很难一下加入很多高级特性, 能做到 Arrow 这一步已经是非常艰辛了, 作为 Android 开发的我们可能很长一段时间还是会依赖 Rx

    结语

    希望这篇笔记和这个库可以给各位一些启发, 欢迎前来 star (^ ^) YRoute

    正文完
     0