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类型最开始是如何被确定的呢

    我们首先来分析一下, 路由本质上是一个状态机: 获取当前状态执行某个动作,返回新的状态并返回一个值。比如说关闭一个Activity就是: 获取当前Activity栈状态,关闭目标的Activity,把Activity栈中的这个Activity删除,返回新的栈和执行结果(如果没有找到目标Activity则返回失败)

    因此按照上面介绍的数据结构,我们首先选用State

    // (S) -> Pair<S, R>
    State<S, R>

    但路由与普通的状态机不同,它通常是伴随着UI操作的,即会有副作用发生,因此我们需要将其包裹于IO类型中

    // (S) -> IO<Pair<S, R>>
    StateT<ForIO, S, R>

    同时,路由还有一个特性: 可以在任意位置调用。这意味着在调用它的时候我们是不能依赖于运行上下文的。换句话说,我们需要将运行上下文传入。我们知道传入函数(T) -> R的抽象为Reader,所以可以得到:

    // (S) -> (RouteCxt) -> IO<Pair<S, R>>
    StateT<ReaderTPartialOf<ForIO, RouteCxt>, S, R>

    基本雏形已经出来了,这已经和最初的发布版很相似了,但这时候有一个关于返回值的问题我们需要思考:返回值R通常我们指定的是最后成功的话返回的值。意味着只要能拿到R值,就必须执行成功。而目前我们失败的语义靠的是IO类型,而IO类型同时也包裹着新的状态S。

    那么就存在这么一种情况:比如我们希望实现连续关闭两个Activity的动作,而我们关闭第一个成功了,但第二个关闭失败了(比如没有找到),但实际上这个时候状态已经改变了(第一个Activity已经被关闭了,所以需要被删除),但由于第二个的失败我们的IO只能处于失败状态,外部无法获取到新的状态S, 状态就无法被改变,这是不可接受的

    这种情况在实际运行中会经常发生:我们路由的运行结果肯定不一定是成功的。这个问题的核心是:我们把IO的异常和运行结果的失败放在一起了。所以解决方式是需要将它们分开,定义一个新的代数数据类型YResult(前面加个Y是为了和原生的其他Result类型区分开),它有两种状态,SuccessFail,于是我们最终的组合子定义就出来了:

    // (S) -> (RouteCxt) -> IO<Pair<S, YResult<R>>>
    StateT<ReaderTPart<ForIO, RouteCxt>, S, YResult<R>>
    函数式编程是通过组合而非继承的方式扩展类型能力的

    中间的小波折

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

    // (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我们就要判断所有可能的值, 这就是描述性不足: 无法准确表达程序的意义

    Rx其实意识到了这个问题, 所以RxJava2的时候把RxJava1中“万能”的Observable分成了MaybeSingleCompletable等, 但这样的做法又带来了另一个问题

    明明MaybeSingle都有一个叫map的函数, 却必须写两遍, 因为他们只能被视为两个不同的函数, 无法被抽象成同一个函数

    System F-sub

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

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

    结语

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


    Yumenokanata
    30 声望157 粉丝

    Haskell中毒极深的Android开发工程师