Jetpack Compose自定义Layout详解

发表于 2年以前  | 总阅读数:354 次

前言

最近在使用Jetpack Compose进行应用开发,不得不说相比较命令式布局方式Xml,Compose声明式UI真香。然而仅使用基础的Compose控件在面对UI大佬的设计图时,总会出现一些“小问题”,包括部分页面不能通过标准布局实现、需要特别精准的控制测量和放置的场景、需要实现布局动画等,这个时候往往需要通过自定义布局去解决这些“小问题”,来达到项目验收标准。既然是要自定义组件,那么首先了解一下Compose的布局模型吧,从布局模型中找到自定义组件的切入点。

布局模型

Compose布局模型可以分为三个部分 组合、布局、绘制

首先组合阶段会执行可组合函数,从而创建界面树,举个例子

上图就是按照 SearchResult 组合函数内部子组件顺序生成界面树的示例。日常开发中可组合函数内部可能会包含各种状态逻辑和控制流,反映到组合阶段就是根据不同的逻辑控制来生成不同的界面树。

接下来的 Layout(布局) 阶段会遍历界面树,通过测量组件以确定自身大小以及在手机上应该摆放的位置,这点和View视图的 onMeasure 和 onLayout 类似,都是先测量然后放置,但是也有不一样的地方,View视图可以经过多次测量来确定子视图的大小和位置信息,但是在Compose中每个组件只允许测量一次,多次测量会报错,正是这一特性可以让我们在使用过程中任意嵌套组件而不会造成很大的性能问题。

最后的 Draw(绘制) 阶段会再次遍历界面树,这个阶段主要做的事情就是把组件绘制到界面上。主要涉及 Canvas 操作,Compose 中画布操作和Android中画布操作大同小异,函数式风格的API使用起来会更方便,虽然有些画布操作 Compose 没有支持,但我们也可以通过 drawIntoCanvas 方法拿到原生Android 环境下的 Canvas 和 Paint 对象进行绘制。这个阶段本文不做深入探讨。

在了解了Compose的布局三个阶段之后,要想自定义组件,切入点还是得从 Layout(布局) 阶段开始,本文主要对该阶段进行深入探讨。

Layout阶段主要涉及了三个步骤:

  • 测量所有子项
  • 确定自己的尺寸
  • 放置其子项

这个看起来跟Android中onMeasure和onLayout的合并,其实在Compose中任何组件都是基于Layout(...)可组合函数进行搭建的,接下来通过源码剖析来探究其内部做了什么? 如果要自定义需要如何做?

Layout可组合函数

@Composable inline fun Layout(
    content: @Composable @UiComposable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
   ...
}

Layout()可组合函数构成很简单

  • content里面可以放置子组件布局
  • modifier修饰符设置大小、背景等修饰都靠它,其实还可以做更多事后面专门分析。
  • measurePolicy负责测量和放置逻辑。

MeasurePolucy是一个接口,需要组件自己实现,像我们经常使用的ColumnRow等组件都是通过实现自己的逻辑来达到不同的约束效果。我们如果要自己实现一个Column怎么做呢? 首先需要声明我们自己的可组合函数,内部调用Layout()然后在measurePolicy中实现测量和放置逻辑,代码如下:

@Composable
fun CustomColumnLayout(
   modifier: Modifier, 
   content: @Composable () -> Unit  
){
    Layout(
        content = content,
        modifier = modifier,
        //这个地方为了更加直观使用了 object 匿名函数声明方式,当然也可以简化成lambda表达式
        //measurePolicy = MeasurePolicy { measurables, constraints -> 
        //      
        //}) 
        measurePolicy = object : MeasurePolicy {
            override fun MeasureScope.measure(
                measurables: List<Measurable>,
                constraints: Constraints
            ): MeasureResult {
              //测量和放置逻辑
              ...
            }
        })
}

constraints表示父元素允许子元素的尺寸范围,如果子元素从该范围内选择了尺寸,那么父元素必须接受并处理子元素。尺寸范围包含最大最小高度和最大最小宽度,当最大最小高度/宽度相等的时候表示高度/宽度为确定值。

class Constraints {
    val minWidth: Int
    val maxWidth: Int
    val minHeight: Int
    val maxHeight: Int
}

measurables表示子元素布局列表, Measurable可以通过measure(constraints: Constraints)方法进行测量并得到一个新的可放置的布局Placeable。所有子元素测量完成后,在根据得到的测量结果进行具体的放置工作,用代码体现出来如下:

@Composable
fun CustomColumnLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
         modifier = modifier,
         content = content
    ) { measurables: List<Measurable>,
         constraints: Constraints ->
        // placeables 是经过测量的子元素,它拥有自身的尺寸值
        val placeables = measurables.map { measurable ->
            // 测量所有子元素,这里不编写任何自定义测量逻辑,只是简单地
            // 调用 Measurable 的 measure 函数并传入 constraints
            measurable.measure(constraints)
        }
        val width = placeables.sumOf { it.height }// 根据 placeables 计算得出
        val height = placeables.maxOf { it.width }// 根据 placeables 计算得出
        // 报告所需的尺寸
        layout (width, height) {
            var y = 0
            placeables.foreach { placeable ->
                //通过遍历将每个项目放置到最终的预期位置
                 placeable.placeRelative(x = 0, y = y)
                // 按照所放置项目的高度增加 y 坐标值
                 y += placeable.height            
            }
        }
   }
}

从代码中可以看到,测量得到的placebles提供了placeRelative()方法,我们可以指定xy值来确定位置,此方法还有额外的作用,如果布局方向为从右到左,则给定的位置将被水平镜像。相对应的如果不想对布局从右到左还是从左到右作出反应可以使用place()方法,该方法只通过指定xy值来确定位置。到这里一个属于自己的Column组件就搞定了,使用起来也很简单

@Composable
fun Test(){
    CustomColumnLayout{
        Text(text = "test1")
        Text(text = "text2")
        Text(text = "text3")
    }
}

接下来我们将分析Layout()可组合函数的另一个参数Modifier,在Compose开发中Modifier起着非常重要的作用,我们使用标准布局也好,使用自定义布局也好都离不开Modifier,那么它究竟是如何运作的呢?我们可不可以通过Modifier来对已有的组件进行自定义布局信息呢?带着这两个疑问让我们进入Modifier的世界去寻找答案。

修饰符

Compose中修饰符虽然有很多种,但它们的基类Modifier其实没有多复杂

interface Modifier {
    fun <R> foldIn(initial: R, operation: (R, Element) -> R): R
    fun <R> foldOut(initial: R, operation: (Element, R) -> R): R
    fun any(predicate: (Element) -> Boolean): Boolean
    fun all(predicate: (Element) -> Boolean): Boolean

    infix fun then(other: Modifier): Modifier =
        if (other === Modifier) this else CombinedModifier(this, other)

    interface Element : Modifier {
        override fun <R> foldIn(initial: R, operation: (R, Element) -> R): R =
            operation(initial, this)

        override fun <R> foldOut(initial: R, operation: (Element, R) -> R): R =
            operation(this, initial)

        override fun any(predicate: (Element) -> Boolean): Boolean = predicate(this)

        override fun all(predicate: (Element) -> Boolean): Boolean = predicate(this)
    }

    companion object : Modifier {
        override fun <R> foldIn(initial: R, operation: (R, Element) -> R): R = initial
        override fun <R> foldOut(initial: R, operation: (Element, R) -> R): R = initial
        override fun any(predicate: (Element) -> Boolean): Boolean = false
        override fun all(predicate: (Element) -> Boolean): Boolean = true
        override infix fun then(other: Modifier): Modifier = other
        override fun toString() = "Modifier"
    }
}

如果我们把没有默认实现的foldIn()foldOut()any()all()方法屏蔽掉

interface Modifier {
             ...
    infix fun then(other: Modifier): Modifier =
       if (other === Modifier) this else CombinedModifier(this, other)

    interface Element : Modifier {
             ...
     }

    companion object : Modifier {
             ...
    override infix fun then(other: Modifier): Modifier = other
    override fun toString() = "Modifier"
  }
}

可以很明显的看到Element以及Modifier的伴生对象都是实现了Modifier接口,其实还有一个类也实现了,就是CombineModifier这个类,它在"合并"不同的Modifier以及遍历的时候很关键。总结一下直接实现Modifier接口的三个类:

  • Element接口
  • Modifier接口伴生对象
  • CombineModifier

我们通过一段代码来更加深入的了解Modifier的运作原理

Column(
    modifier = Modifier
        .fillMaxSize()
        .padding(5.dp)
        .background(Color.White)
) {
    ...
}

上面代码通过Modifier设置了大小、边距和背景色。代码是链式调用,通过深入源码看下究竟是如何串起来的,从调用链第一个fillMaxSize()着手分析

fun Modifier.fillMaxSize(fraction: Float = 1f) =
    this.then(if (fraction == 1f) FillWholeMaxSize else createFillSizeModifier(fraction))

fillMaxSize()是以扩展函数的方式声明并返回Modifier实例,this.then(...)因为是链式调用第一个所以调用的应该是Modifier接口伴生对象内部默认实现的then(...)方法

interface Modifier {
             ...
   companion object : Modifier {
             ...
      override infix fun then(other: Modifier): Modifier = other
             ...
  }
}

then(...)方法非常简单直接返回传入的other,在这里应该是FillModifier,所以当前的Modifier就是FillModifier

接下来是padding(5.dp)

fun Modifier.padding(all: Dp) =
    this.then(
        PaddingModifier(
           ...
        )
   )

这里this.then(...)调用的是FillModifier中实现的方法,FillModifier内部没有重写该方法我们向上查找,根据继承链 FillModifier->LayoutModifier->Element->Modifier,最终找到then()方法的实现是在顶层Modifier接口中

interface Modifier {
    infix fun then(other: Modifier): Modifier =
       if (other === Modifier) this else CombinedModifier(this, other)
}

class CombinedModifier(
    private val outer: Modifier,
    private val inner: Modifier
) : Modifier

if(other === Modifier)这个条件反编译成java后查看是等同于if(other == Modifier.Companion),因为伴生对象也是实现Modifier接口的。所以这里可以直接把传入的other实例和接口的伴生对象对比,如果相等直接返回,如果不等则使用CombinedModifier进行处理。CombinedModifier的处理也比较简单,接收两个Modifier类型参数分别定义为outerinner,经过链式调用的第二步我们的当前的Modifier结构如下:

链式调用最后一个background(Color.White)

fun Modifier.background(
    color: Color,
    shape: Shape = RectangleShape
) = this.then(
    Background(
        ...
    )
)

这里跟上面一样都是通过调用then()方法将Background与之前的Modifier合并,这里Background是实现DrawModifier接口的,那么经过这一轮调用我们可以得到类似上图的结构,只不过多了一层,以此类推如果我们通过Modifier创建了很长的调用链都可以转换为下图:

最后生成的Modifier链会被传入到Layout(...)可组合函数中,在函数中通过materializerOf(modifier)方法进行处理

@Composable inline fun Layout(
    content: @Composable @UiComposable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    ...
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = ComposeUiNode.Constructor,
        update = {
            ...
        },
        skippableUpdate = materializerOf(modifier), //这里处理modifier
        content = content
    )
}

materializerOf方法中使用到了两个方法foldIn()foldOut(),前面提到我们在组合函数中声明的Modifier修饰都会转成对应的Modifier链,foldIn()foldOut()就是Modifier提供的遍历方法。

//Element中实现
override fun <R> foldIn(initial: R, operation: (R, Element) -> R): R = operation(initial, this)
override fun <R> foldOut(initial: R, operation: (Element, R) -> R): R = operation(this, initial)

//CombinedModifier中实现
 override fun <R> foldIn(initial: R, operation: (R, Modifier.Element) -> R): R =
     inner.foldIn(outer.foldIn(initial, operation), operation)

 override fun <R> foldOut(initial: R, operation: (Modifier.Element, R) -> R): R =
     outer.foldOut(inner.foldOut(initial, operation), operation)

从代码中我们可以看出foldIn()foldOut()正好相反,拿之前示例的代码进行测试,比如打印出Modifier链的长度

val modifier = Modifier
    .fillMaxSize()
    .padding(5.dp)
    .background(Color.White)
val result = modifier.foldIn(0) { index, element ->
    Log.d("Modifier", "index = $index element = $element")
    index + 1
}

使用foldIn()输出结果

image-20220615151315538

使用foldOut()输出结果

image-20220615151552354

对比结果,我们发现CombinedModifier并没有输出,看来CombinedModier`仅仅是作为内部数据结构在使用,官方如此设计应该是希望对上层开发者无感知。

回到materializerOf中继续分析,最终调用Composer的materialize方法,在这个方法内判断如果Modifier链中没有ComposedModifier则不做处理并返回原Modifier链,否则通过foldIn()进行遍历,遇到ComposeModifer类型直接通过它提供的工厂方法构造新的Modifier,生成新的Modifier也有可能还是ComposerModifer继续重复执行,最终得到Modifier链是不包含ComposerModifier的,即是完全摊开的Modifier链。

fun Composer.materialize(modifier: Modifier): Modifier {
    if (modifier.all { it !is ComposedModifier }) return modifier
    ...
    val result = modifier.foldIn<Modifier>(Modifier) { acc, element ->
        acc.then(
            if (element is ComposedModifier) {
                ...
                val factory = element.factory as Modifier.(Composer, Int) -> Modifier
                val composedMod = factory(Modifier, this, 0)
                materialize(composedMod)
            } else element
        )
    }
    ...
    return result
}

其实这仅仅是开始,Modifier链的核心处理是通过LayoutNode来实现的,在LayoutNode中进一步生成LayoutNodeWrapper链,节点转换有很多种,我们拿LayoutModifier来分析,在节点转换时候会包装成ModifiedLayoutNode,然后在Layout()可组合函数中通过Measurable调用measure方法时会触发ModifiedLayoutNode中的measure方法,在内部会触发LayoutModifiermeasure()方法,该方法的实现都是在LayoutModifier的子类中,我们拿PaddingModifier来具体分析:

private class PaddingModifier(
     ...
  ) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
     ...
    override fun MeasureScope.measure(
        measurable: Measurable,//下一个 LayoutNodeWrapper 节点
        constraints: Constraints//来自父容器或者来自上一个节点的约束
    ): MeasureResult {

        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()

        //测量下一个节点
        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            if (rtlAware) {
                placeable.placeRelative(start.roundToPx(), top.roundToPx())
            } else {
                placeable.place(start.roundToPx(), top.roundToPx())
            }
        }
    }
}

一个元素的所有LayoutNodeWrapper节点测量完成后,会通过Layout()可组合函数中的MeasurePolicy实现继续测量子元素,重复流程直到所有的子元素测量完成。

分析到这里我们就可以发现自定义Layout除了通过自定义实现measurePolicy之外,通过自定义Modifier也可以达到修改布局的目的。这两者就类似于自定义ViewGroup和自定义View,通过Layout()可组合函数自定义相当于自定义ViewGroup,通过Modifier自定义相当于自定义View。对于第二种自定义方式可以通过Modifier提供的layout方法去实现

fun Modifier.customLayout() = layout { measurable, constraints ->
    val placeable = measurable.measure(constraints) //测量
        //在这里可以增加逻辑代码 如果需要的话   
        ...                              
    layout(constraints.maxWidth, constraints.maxHeight) {
        //位置放置 可以实现自己的放置逻辑
        placeable.placeRelative(0, 0)
    }
}

另外官方文档实现的firstBaselineToTop就是一个很不错的例子值得一看。

总结

通过Compose布局模型,着重分析了组合->布局->绘制三个阶段中的布局阶段,以实例和源码结合作为切入点总结出了 Compose中自定义Layout的两种方式

  • 通过调用Layout可组合函数重写measurePolicy,内部实现自己的测量和放置逻辑,跟Android中自定义ViewGroup类似。
  • 实现Modifierlayout方法,内部实现自己的测量和放置逻辑,跟Android中自定义View类似。

这两种布局方式可以根据具体需求灵活应用。另外Compose中组件都是可组合函数,自定义属性可以通过可组合函数参数传递,代码更加直观,使用起来更方便。

以上就是本文的全部内容啦,谢谢阅读。

引用

官方文档:自定义布局 | Jetpack Compose | Android Developers https://developer.android.com/jetpack/compose/layouts/custom?hl=zh-cn

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/ylWOllpzY80ovdH-KeY64Q

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237269次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8108次阅读
 目录