服务粉丝

我们一直在努力
当前位置:首页 > 财经 >

探索 BottomSheet 的背后原理

日期: 来源:AndroidPub收集编辑:

快手电商无线团队

https://juejin.cn/post/7156874737740677133

1. 关于 Bottom Sheet

Bottom Sheet 在 Android Design Support Library 23.2 版本引入,翻译过来即底部动作条的意思,可以设置最小高度和最大高度,执行进入/退出动画,响应拖动/滑动手势等。主要用于实现从底部弹出一个对话框的效果。

一个合理的半屏弹出容器应该具备以下功能:

  • 支持进出滑动动画及手动滑动拖拽
  • 处理滑动冲突

在 Google 官方推出 BottomSheet 之前,在 Github 上面已经有一些开源的库实现类似的效果。例如

  • https://github.com/umano/AndroidSlidingUpPanel
  • https://github.com/Flipboard/bottomsheet
  • https://github.com/soarcn/BottomSheet

在此之后因为 BottomSheet 能满足大部分半屏诉求,因此业界普遍遵循官方 Material Design 设计规范,使用官方组件来实现半屏弹出或滑动拖拽效果。

BottomSheet 具体实现主要包含:BottomSheetBeahviorBottomSheetDialogBottomSheetDialogFragment,这三个组件均可以实现半屏弹出效果,区别点在于接入和使用方式上的差异。本文重点分析 BottomSheetBeahvior,其余两个均是基于 BottomSheetBeahvior 所实现,只做简单说明,不详细展开:

  • BottomSheetBeahvior 一般直接作用在view上,一般在xml布局文件中直接对view设置属性,轻量级、代码入侵低、灵活性高,适用于复杂页面下的半屏弹出效果。app:layout_behavior="@string/bottom_sheet_behavior"
  • BottomSheetDialog 的使用和对话框的使用基本上是一样的。通过setContentView()设定布局,调用show()展示即可。因为必须要使用Dialog,使用上局限相对多,因此一般适用于底部弹出的轻交互弹窗,如底部说明弹窗等。
  • BottomSheetDialogFragment 的使用同普通的Fragment一样,可以将交互和UI写到Fragment内,适合一些有简单交互的弹窗场景,如底部分享弹窗面板等。

2、什么是Behavior

Behavior 是 Android Support Design 库里面新增的布局概念,主要的作用是用来协调 CoordinatorLayout 布局直接 Child Views 之间布局及交互行为的,包含拖拽、滑动等各种手势行为。

但是 Behavior 只能作用于 CoordinatorLayout 的直接 Child View.

e.g. 以下代码是设置给 FrameLayout,而不是 CoordinatorLayout

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/test_behavior" />

</android.support.design.widget.CoordinatorLayout>

behaior 的简单应用场景:如实现下图 FloatingActionButton 的上滑隐藏、下滑显示,实现参考:https://guides.codepath.com/android/floating-action-buttons

2.1 测量和布局

CoordinatorLayout 的 onMeasureonLayout 均代理给 Behavior 实现。

onMeasure

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    ......
    for (int i = 0; i < childCount; i++) {
        final CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
        ......
        final CoordinatorLayout.Behavior b = lp.getBehavior();
        if (b == null || !b.onMeasureChild(this, child, childWidthMeasureSpec, keylineWidthUsed,
                                           childHeightMeasureSpec, 0)) {
            onMeasureChild(child, childWidthMeasureSpec, keylineWidthUsed,
                           childHeightMeasureSpec, 0);
        }
        ......
    }
    ......
}

onLayout

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    ......
    for (int i = 0; i < childCount; i++) {
        final View child = mDependencySortedChildren.get(i);
        ......
        final CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
        final CoordinatorLayout.Behavior behavior = lp.getBehavior();
        if (behavior == null || !behavior.onLayoutChild(this, child, layoutDirection)) {
            onLayoutChild(child, layoutDirection);
        }
    }
}

2.2 普通触摸事件

CoordinatorLayout 的 onInterceptTouchEventonTouchEvent 是通过遍历 CoordinatorLayout 的子 View,找到第一个关联 Behavior 的 onInterceptTouchEvent 和 onTouchEvent 返回 true 的 Child View,并交给其 Beahvior 执行,如果没有找到,则交由 CoordinatorLayout 自身处理。

onInterceptTouchEvent:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    MotionEvent cancelEvent = null;

    final int action = MotionEventCompat.getActionMasked(ev);

    // Make sure we reset in case we had missed a previous important event.
    if (action == MotionEvent.ACTION_DOWN) {
        resetTouchBehaviors();
    }

    final boolean intercepted = performIntercept(ev, TYPE_ON_INTERCEPT);

    if (cancelEvent != null) {
        cancelEvent.recycle();
    }

    if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
        resetTouchBehaviors();
    }

    return intercepted;
}


private boolean performIntercept(MotionEvent ev, final int type) {
    boolean intercepted = false;
    boolean newBlock = false;

    MotionEvent cancelEvent = null;

    final int action = MotionEventCompat.getActionMasked(ev);

    final List<View> topmostChildList = mTempList1;
    getTopSortedChildren(topmostChildList);

    // Let topmost child views inspect first
    final int childCount = topmostChildList.size();
    for (int i = 0; i < childCount; i++) {
        final View child = topmostChildList.get(i);
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        final Behavior b = lp.getBehavior();

        if ((intercepted || newBlock) && action != MotionEvent.ACTION_DOWN) {
            // Cancel all behaviors beneath the one that intercepted.
            // If the event is "down" then we don't have anything to cancel yet.
            if (b != null) {
                if (cancelEvent == null) {
                    final long now = SystemClock.uptimeMillis();
                    cancelEvent = MotionEvent.obtain(now, now,
                            MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
                }
                switch (type) {
                    case TYPE_ON_INTERCEPT:
                        b.onInterceptTouchEvent(this, child, cancelEvent);
                        break;
                    case TYPE_ON_TOUCH:
                        b.onTouchEvent(this, child, cancelEvent);
                        break;
                }
            }
            continue;
        }

        if (!intercepted && b != null) {
            switch (type) {
                case TYPE_ON_INTERCEPT:
                    intercepted = b.onInterceptTouchEvent(this, child, ev);
                    break;
                case TYPE_ON_TOUCH:
                    intercepted = b.onTouchEvent(this, child, ev);
                    break;
            }
            if (intercepted) {
                mBehaviorTouchView = child;
            }
        }

        // Don't keep going if we're not allowing interaction below this.
        // Setting newBlock will make sure we cancel the rest of the behaviors.
        final boolean wasBlocking = lp.didBlockInteraction();
        final boolean isBlocking = lp.isBlockingInteractionBelow(this, child);
        newBlock = isBlocking && !wasBlocking;
        if (isBlocking && !newBlock) {
            // Stop here since we don't have anything more to cancel - we already did
            // when the behavior first started blocking things below this point.
            break;
        }
    }

    topmostChildList.clear();

    return intercepted;
}

onTouchEvent

@Override
public boolean onTouchEvent(MotionEvent ev) {
    boolean handled = false;
    boolean cancelSuper = false;
    MotionEvent cancelEvent = null;

    final int action = MotionEventCompat.getActionMasked(ev);

    if (mBehaviorTouchView != null || (cancelSuper = performIntercept(ev, TYPE_ON_TOUCH))) {
        // Safe since performIntercept guarantees that
        // mBehaviorTouchView != null if it returns true
        final LayoutParams lp = (LayoutParams) mBehaviorTouchView.getLayoutParams();
        final Behavior b = lp.getBehavior();
        if (b != null) {
            handled = b.onTouchEvent(this, mBehaviorTouchView, ev);
        }
    }

    // Keep the super implementation correct
    if (mBehaviorTouchView == null) {
        handled |= super.onTouchEvent(ev);
    } else if (cancelSuper) {
        if (cancelEvent != null) {
            final long now = SystemClock.uptimeMillis();
            cancelEvent = MotionEvent.obtain(now, now,
                    MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
        }
        super.onTouchEvent(cancelEvent);
    }

    if (!handled && action == MotionEvent.ACTION_DOWN) {

    }

    if (cancelEvent != null) {
        cancelEvent.recycle();
    }

    if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
        resetTouchBehaviors();
    }

    return handled;
}

3、BottomSheetBehavior布局介绍

从名字即可以看出,BottomSheetBehavior 继承自 CoordinatorLayout.Behavior,借用 behavior 的布局和事件分发能力来实现底部弹出动画及手势拖拽效果。下面首先分析下 BottomSheet 初始弹出时是如何实现弹出动画。

一个简单的半屏滑动布局如下:

3.1 BottomSheetBehavior的几种状态

  • STATE_HIDDEN :隐藏状态,关联的View此时并不是GONE,而是此时在屏幕最下方之外,此时只是无法肉眼看到

  • STATE_COLLAPSED :折叠状态,一般是一种半屏形态

  • STATE_EXPANDED :完全展开,完全展开的高度是可配置,默认即屏幕高度。类似地图首页一般完全展开态的高度配置为距离屏幕高差一小截距离。

  • STATE_DRAGGING :拖拽状态,标识人为手势拖拽中(手指未离开屏幕)

  • STATE_SETTLING :视图从脱离手指自由滑动到最终停下的这一小段时间,与STATE_DRAGGING差异在于当前并没有手指在拖拽。主要表达两种场景:初始弹出时动画状态、手指手动拖拽释放后的滑动状态。

3.2 BottomSheetBehavior的初始弹出

一般 BottomSheetBehavior 使用的场景为从底部弹出,这种场景下,当设置 STATE_COLLAPSED 状态时,经历了 STATE_HIDDEN -> STATE_SETTLING -> STATE_COLLAPSED 变化。

初始动画的弹出是有 Scroller + ViewCompat.offsetLeftAndRight 配合来实现view 移动动画。主要步骤为:

  1. 设置 STATE_COLLAPSED 状态,触发view动画逻辑,将View从屏幕外移动到屏幕内
  2. 动画逻辑为 首先计算出需要移动的距离,然后使用 Scroller 设置动画时长后,开始执行scroll。重点在于 Scroller 只是一个表达位移值变化的辅助工具,它并不会执行实际的 view 移动
  3. Scroller 开始移动后,同时会开启一个线程,不断的监听当前 Scroller 的惟一距离,并将当前View移动响应距离(ViewCompat.offsetLeftAndRight)

4、BottomSheetBehavior滑动

4.1、嵌套滑动NestedScroll

理解 BottomSheet 的滑动我们首先要了解下嵌套滑动,嵌套滑动是为了解决父view和子view 滑动冲突所提冲的一套机制。

一般的触摸消息的分发都是从外向内的,由外层的 ViewGroup 的 dispatchTouchEvent 方法调用到内层的 View 的 dispatchTouchEvent 方法.

NestedScroll 提供了一个反向的机制,内层的 view 在接收到 ACTION_MOVE 的时候,将滚动消息先传回给外层的 ViewGroup ,由外层的 ViewGroup 决定是不是需要消耗一部分的移动,然后内层的 View 再去消耗剩下的移动。内层 view 可以消耗剩下的滚动的一部分,如果还没有消耗完,外层的 view 可以再选择把最后剩下的滚动消耗掉.

为了实现嵌套滑动,需要父View 和子View 分别实现 NestedScrollingParentNestedScrollingChild 接口,来进行相关逻辑处理。

public interface NestedScrollingChild {
    public void setNestedScrollingEnabled(boolean enabled);

    public boolean isNestedScrollingEnabled();

    public boolean startNestedScroll(int axes);

    public void stopNestedScroll();

    public boolean hasNestedScrollingParent();

    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);

    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);

    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    public boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

public interface NestedScrollingParent {
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);

    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);

    public void onStopNestedScroll(View target);

    public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);

    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);

    public boolean onNestedPreFling(View target, float velocityX, float velocityY);

    public int getNestedScrollAxes();
}
方法说明
onStartNestedScroll是否接受嵌套滚动,只有它返回true,后面的其他方法才会被调用
onNestedPreScroll在内层view处理滚动事件前先被调用,可以让外层view先消耗部分滚动
onNestedScroll在内层view将剩下的滚动消耗完之后调用,可以在这里处理最后剩下的滚动
onNestedPreFling在内层view的Fling事件处理之前被调用
onNestedFling在内层view的Fling事件处理完之后调用

4.2、BottomSheetBehavior的滑动

BottomSheetBehavior 的滑动分两种:一种是子 view 实现了 NestScroll 嵌套滑动(如RecyclerView)、一种是子view没有实现嵌套滑动(如webView)。

4.2.1、非嵌套滑动

4.2.1.1、从半屏滑动到全屏

BottomSheetBehavior 在半屏下,onToucInterceptTouchEvent 默认拦截 MOVE 事件,则会走到 behavior 自身的 onTouch 事件,执行 CoordinatorLayout 容器view的自身滑动,滑动通过 ViewCompat.offsetLeftAndRight  根据move事件移动距离来实现。

4.2.1.2、全屏状态下滑动

在全屏状态下存在需要容器的滑动和内容滑动两种需求。此时需要通过事件拦截来实现,一般我们常用的内部拦截/外部拦截。在 Behavior 场景下,更多采用内部拦截,即子 View 监听 onTouch 事件,根据滑动场景调用 requestDisallowInterceptTouchEvent 来实现容器滑动/内容滑动。

4.2.2、嵌套滑动

4.2.2.1、从半屏滑动到全屏

同非嵌套滑动

4.2.2.2、全屏状态下滑动

在子View有 NestScroll 时,滑动事件会先分发到子 view,子view触发嵌套滑动,向上触发父view 的 onNestPreScroll,由父view优先进滑动的消费,onNestPreScroll 会被 CoordinatorLayout 转发到 Beahvior,由Behavior 进行实际消费处理。

  • 向下滑动容器: 当此时子view无法手势向下互动时,BottomSheetBehavior 会进行滑动距离的消费,触发容器的滑动
  • 内容上下滑动: 当子view可以让下滑动时,BottomSheetBehavior 不进行滑动距离的消费,由子view进行消费,实现子view内容的滑动。
 @Override
  public void onNestedPreScroll(
      @NonNull CoordinatorLayout coordinatorLayout,
      @NonNull V child,
      @NonNull View target,
      int dx,
      int dy,
      @NonNull int[] consumed,
      int type) {
    if (type == ViewCompat.TYPE_NON_TOUCH) {
      // Ignore fling here. The ViewDragHelper handles it.
      return;
    }
    View scrollingChild = mNestedScrollingChildRef.get();
    if (target != scrollingChild) {
      return;
    }
    int currentTop = child.getTop();
    int newTop = currentTop - dy;
    if (dy > 0) { // Upward
      if (newTop < getExpandedOffset()) {
        consumed[1] = currentTop - getExpandedOffset();
        ViewCompat.offsetTopAndBottom(child, -consumed[1]);
        setStateInternal(STATE_EXPANDED);
      } else {
        consumed[1] = dy;
        ViewCompat.offsetTopAndBottom(child, -dy);
        setStateInternal(STATE_DRAGGING);
      }
    } else if (dy < 0) { // Downward
      if (!target.canScrollVertically(-1)) {
        if (newTop <= mCollapsedOffset || mHideable) {
          consumed[1] = dy;
          ViewCompat.offsetTopAndBottom(child, -dy);
          setStateInternal(STATE_DRAGGING);
        } else {
          consumed[1] = currentTop - mCollapsedOffset;
          ViewCompat.offsetTopAndBottom(child, -consumed[1]);
          setStateInternal(STATE_COLLAPSED);
        }
      }
    }
    dispatchOnSlide(child.getTop());
    mLastNestedScrollDy = dy;
    mNestedScrolled = true;
  }

5、一些小坑

5.1 初始弹出高度

背景:在页面初始打开时,我们需要设置初始的弹出高度为 Activity 页面内容的百分比(80%),如果在 onCreate 中直接计算高度,此时获取高度会得到错误的值。

解决:通过监听 onGlobalLayout,在第一次回调时机时来进行计算,此时 Activity 内容高度已确定。

5.2 多个 NestScroll child

背景:当页面内存在两个 RecyclerView 时(两个 RecyclerView 分别标识半屏和全屏下的列表,UI样式存在差异,在半/全屏上下滑动时进行透明度的变化,以显示不同效果),此时会出现滑动不生效或者错乱。

解决:BottomSheetBehavior 获取子view中的 NestScrollChild 是遍历子View取第一个 NestScrollView,因此会导致 NestScroll 获取异常。

因此通过 BottomSheetBehavior 增加接口,主动标识当前场景下应该获取的 NestScrollChild 是哪一个。同理如果 BottomSheetBehavior 嵌套 ViewPage 再嵌套多个 RecyclerView,也会存在类似问题,可用类似方案解决。

5.3 折叠态时初次滑动卡顿

背景:当页面内存在两个 RecyclerView 时(两个 RecyclerView 分别标识半屏和全屏下的列表,半屏下只显示半屏 RecyclerView,全屏下只显示全屏 RecyclerView,通过滑动进行透明度切换),当页面初始弹出到半屏状态后,手动向上滑动,会出现明显的卡顿,之后第二次上下滑动即不再卡顿。

分析:一开始将排查重点放 behavior 自身逻辑上,但是我们发现第二次 onTouch 事件距离第一次 onTouch 事件回掉相差100ms左右,导致view拖拽动画出现断层,这也是卡顿的直接原因。

onTouch 回掉延迟,即表明第一次 onTouch 事件后发生发生了一些耗时操作,通过火焰图分析我们可以发现耗时操作大部分都是 RecyclerView 的 item 创建和绑定数据,到这里大概就可以得出卡顿的原因:

  • 半屏页面初次弹出时显示的是半屏的 RecyclerView,而全屏 RecyclerView 处于 GONE 状态,不会执行列表item的创建和绑定数据。
  • 当向上滑动时,我们会同时动态改变半屏和全屏 RecyclerView 的透明度,来实现两种UI效果的切换
  • 当第一次 onTouch 事件回掉时,此时触发列表的透明度变化,全屏 RecyclerView 开始变为 VISIBLE 状态,触发列表自身item的创建和绑定数据,这个过程是一个相对耗时的操作,且只能在UI线程进行,因此就导致后续 onTouch 事件被阻塞,发生卡顿。

解决方案:监听半屏列表渲染到屏幕后延迟100ms设置全屏 RecyclerView 为 VISIBLE,但是此时只给其设置一个极小的 alpha,这即可以保证列表提前渲染,又不影响视觉显示效果。

经验:在 bottomSheet 滑动过程中应该避免在主线程中处理耗时操作,否则会产生动画卡顿。

-- END --

推荐阅读

相关阅读

  • Android Studio Electric Eel 支持手机投屏

  • 有时当我们在线上做技术分享或者功能演示时,希望共享连接中的手机屏幕,此时我们会求助 ApowerMirror,LetsView,Vysor,Scrcpy 等工具。如果你是一个 Android Developer,那么现在你
  • 终于来了!带你体验 Compose for iOS

  • 前言目前Compose for iOS 已经有尚未开放的实验性API,乐观估计今年年底将会发布Compose for iOS。同时Kotlin也表示将在2023年发布KMM的稳定版本。届时Compose-jb + KMM 将实
  • Kotlin 协程能完全取代 RxJava 吗?

  • 作者:RainyJiang https://juejin.cn/post/7175803413232844855背景自从 jetbrains 公司提出 Kotlin 协程用来解决异步线程问题,并且衍生出来了 Flow 作为响应式框架,引来了大量
  • 一文搞懂 Android 动态加载 so

  • 作者:Pika https://juejin.cn/post/7107958280097366030背景对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到na
  • 面试官:谈谈过滤器和拦截器的区别?

  • 来源:blog.csdn.net/qq_42924666/article/details/109563400一、拦截器和过滤器的区别二、拦截器和过滤器的代码实现三、总结1、什么是Filter及其作用介绍2、Filter API介绍3
  • 字节面试6连问:讲讲 ThreadLocal 与 Handler

  • 安卓进阶涨薪训练营,让一部分人先进大厂大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。详情见文章:没错!皇叔开了
  • 动态可监控线程池,你还没用起来吗?

  • 「使用线程池 ThreadPoolExecutor 过程中你是否有以下痛点呢?」1.代码中创建了一个 ThreadPoolExecutor,但是不知道那几个核心参数设置多少比较合适2.凭经验设置参数值,上线后
  • 第 3 次面腾讯,竟然栽倒在幂等性上

  • 链接:mydlq.club/article/94前段时间,一位读者说去腾讯面试,部门是 IEG, 考幂等性的问题,他回答得不太好、不全面,整个面试过程才20分钟,就被请出来了。这已经是他第 3 次栽倒在腾
  • 厉害了,Kotlin 协程能完全取代 RxJava?

  • 安卓进阶涨薪训练营,让一部分人先进大厂大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。详情见文章:没错!皇叔开了

热门文章

  • “复活”半年后 京东拍拍二手杀入公益事业

  • 京东拍拍二手“复活”半年后,杀入公益事业,试图让企业捐的赠品、家庭闲置品变成实实在在的“爱心”。 把“闲置品”变爱心 6月12日,“益心一益·守护梦想每一步”2018年四

最新文章

  • 探索 BottomSheet 的背后原理

  • 快手电商无线团队https://juejin.cn/post/71568747377406771331. 关于 Bottom SheetBottom Sheet 在 Android Design Support Library 23.2 版本引入,翻译过来即底部动作条的
  • Android Studio Electric Eel 支持手机投屏

  • 有时当我们在线上做技术分享或者功能演示时,希望共享连接中的手机屏幕,此时我们会求助 ApowerMirror,LetsView,Vysor,Scrcpy 等工具。如果你是一个 Android Developer,那么现在你
  • NDK开发之 JNI 静态注册与动态注册

  • 一、起因前段时间学习OpenGL ES相关技术,下载了一个Github项目学习,项目地址在:https://github.com/githubhaohao/NDK_OpenGLES_3_0项目的关键代码都是C++实现的,所以需要使用JN
  • 终于来了!带你体验 Compose for iOS

  • 前言目前Compose for iOS 已经有尚未开放的实验性API,乐观估计今年年底将会发布Compose for iOS。同时Kotlin也表示将在2023年发布KMM的稳定版本。届时Compose-jb + KMM 将实
  • 十五分钟讲完个人工智能,听完了能真懂的那种

  • 我从事人工智能相关的工作,有时候亲朋好友就问我,你那个人工智能到底是什么玩意?我一般不做解释,因为很难说清楚。后来,我碰到一个做演讲的朋友,他了解了我的困惑之后,他给我布置了
  • Kotlin 高阶函数与 Standard.kt 源码详解

  • 前言在Kotlin中,高阶函数是指将一个函数作为另一个函数的参数或者返回值。如果用f(x)、g(x)用来表示两个函数,那么高阶函数可以表示为f(g(x))。Kotlin为开发者提供了丰富的高