转载

YRoute开发随笔

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 的函数, 却必须写两遍, 因为他们被视为两个不同的函数, 无法被抽象成同一个函数

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

    System F-sub

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

    结语

    希望这篇笔记和这个库可以给各位一些启发, 欢迎star

    原文  https://segmentfault.com/a/1190000019575543
    正文到此结束
    Loading...