深入剖析 Android NestedScrollView 使用原理

深入剖析 Android NestedScrollView 使用原理

一、引言

在 Android 开发中,滚动视图是非常常见的 UI 组件,用于展示超出屏幕范围的内容。NestedScrollView 作为 Android 系统提供的一个重要滚动视图组件,它支持嵌套滚动机制,使得开发者可以更灵活地实现复杂的滚动效果。本文将从源码级别深入分析 NestedScrollView 的使用原理,帮助开发者更好地理解和运用这个组件。

二、NestedScrollView 概述

2.1 基本介绍

NestedScrollView 是 Android 提供的一个垂直滚动的视图容器,它继承自 FrameLayout,并实现了 NestedScrollingParentNestedScrollingChild 接口,从而支持嵌套滚动功能。嵌套滚动允许父视图和子视图之间协同处理滚动事件,实现更流畅的滚动效果。

2.2 应用场景

NestedScrollView 常用于以下场景:

  • 复杂布局滚动 :当布局中包含多个滚动视图或需要实现多层嵌套滚动时,NestedScrollView 可以帮助协调各个滚动视图之间的滚动行为。
  • 沉浸式体验 :在实现沉浸式界面时,NestedScrollView 可以与其他组件(如 AppBarLayout)配合使用,实现滚动时的标题栏渐变、隐藏等效果。

三、NestedScrollView 源码结构分析

3.1 类定义

java 复制代码
// NestedScrollView 继承自 FrameLayout,这意味着它可以作为一个容器来包含其他视图
public class NestedScrollView extends FrameLayout implements NestedScrollingParent2,
        NestedScrollingChild2, ScrollingView {
    // 构造函数,用于在代码中创建 NestedScrollView 实例
    public NestedScrollView(Context context) {
        this(context, null);
    }

    // 构造函数,用于在 XML 布局中使用 NestedScrollView 时调用
    public NestedScrollView(Context context, AttributeSet attrs) {
        this(context, attrs, android.R.attr.scrollViewStyle);
    }

    // 构造函数,允许指定自定义样式
    public NestedScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    // 构造函数,支持自定义样式和资源 ID
    public NestedScrollView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        // 初始化滚动相关的属性和对象
        initScrollView();
        // 解析 XML 布局中的属性
        TypedArray a = context.obtainStyledAttributes(attrs,
                R.styleable.NestedScrollView, defStyleAttr, defStyleRes);
        // 获取滚动条相关属性
        setVerticalScrollBarEnabled(a.getBoolean(
                R.styleable.NestedScrollView_verticalScrollBarEnabled, true));
        // 获取滚动条风格属性
        setScrollBarStyle(a.getInt(R.styleable.NestedScrollView_scrollBarStyle,
                View.SCROLLBARS_INSIDE_OVERLAY));
        // 获取滚动条动画持续时间属性
        mScrollAnimationDuration = a.getInt(
                R.styleable.NestedScrollView_scrollAnimationDuration,
                DEFAULT_SCROLL_ANIMATION_DURATION);
        // 回收 TypedArray 对象,避免内存泄漏
        a.recycle();
    }
}

3.2 主要成员变量

java 复制代码
// 用于处理滚动动画的 Scroller 对象
private Scroller mScroller;
// 用于跟踪触摸事件速度的 VelocityTracker 对象
private VelocityTracker mVelocityTracker;
// 用于记录上一次触摸事件的 Y 坐标
private float mLastMotionY;
// 用于判断是否正在被拖动的标志位
private boolean mIsBeingDragged;
// 最小触摸滑动阈值,用于判断是否开始拖动
private int mTouchSlop;
// 最大滚动速度
private int mMaximumVelocity;
// 最小滚动速度
private int mMinimumVelocity;
// 嵌套滚动相关的变量
private final int[] mScrollOffset = new int[2];
private final int[] mScrollConsumed = new int[2];
private int mNestedYOffset;
private NestedScrollingParentHelper mNestedScrollingParentHelper;
private NestedScrollingChildHelper mNestedScrollingChildHelper;

3.3 接口实现

NestedScrollView 实现了 NestedScrollingParent2NestedScrollingChild2 接口,这两个接口定义了嵌套滚动的相关方法,使得 NestedScrollView 可以作为嵌套滚动的父视图和子视图参与滚动事件的处理。

java 复制代码
// 实现 NestedScrollingParent2 接口,作为嵌套滚动的父视图
public interface NestedScrollingParent2 extends NestedScrollingParent {
    // 开始嵌套滚动时调用,返回值表示是否接受嵌套滚动
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type);
    // 嵌套滚动开始时调用,用于准备处理滚动事件
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type);
    // 嵌套滚动结束时调用
    void onStopNestedScroll(@NonNull View target, int type);
    // 子视图滚动时调用,用于处理嵌套滚动
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, int type);
    // 子视图预滚动时调用,用于提前处理滚动事件
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type);
}

// 实现 NestedScrollingChild2 接口,作为嵌套滚动的子视图
public interface NestedScrollingChild2 extends NestedScrollingChild {
    // 开始嵌套滚动,参数表示滚动方向
    boolean startNestedScroll(int axes, int type);
    // 停止嵌套滚动
    void stopNestedScroll(int type);
    // 判断是否有嵌套滚动的父视图
    boolean hasNestedScrollingParent(int type);
    // 分发嵌套滚动事件
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type);
    // 分发预嵌套滚动事件
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, int type);
}

四、NestedScrollView 触摸事件处理

4.1 事件拦截

NestedScrollView 通过 onInterceptTouchEvent 方法来判断是否拦截触摸事件,当满足一定条件时,会拦截事件并开始处理滚动。

java 复制代码
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    // 获取触摸事件的动作类型
    final int action = ev.getActionMasked();

    // 如果当前正在进行滚动动画,拦截事件
    if (action == MotionEvent.ACTION_MOVE && mIsBeingDragged) {
        return true;
    }

    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            // 记录触摸事件的 Y 坐标
            mLastMotionY = (int) ev.getY();
            // 初始化速度跟踪器
            ensureVelocityTracker();
            // 开始嵌套滚动,指定垂直方向
            startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
            // 标记未开始拖动
            mIsBeingDragged = false;
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            // 获取当前触摸事件的 Y 坐标
            final int y = (int) ev.getY();
            // 计算 Y 方向的偏移量
            final int yDiff = (int) Math.abs(y - mLastMotionY);
            // 如果偏移量超过最小触摸滑动阈值,开始拖动
            if (yDiff > mTouchSlop) {
                mIsBeingDragged = true;
                // 记录当前触摸事件的 Y 坐标
                mLastMotionY = y;
                // 初始化速度跟踪器
                ensureVelocityTracker();
                // 开始嵌套滚动,指定垂直方向
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                return true;
            }
            break;
        }
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            // 停止嵌套滚动
            stopNestedScroll(ViewCompat.TYPE_TOUCH);
            // 标记未开始拖动
            mIsBeingDragged = false;
            // 回收速度跟踪器
            if (mVelocityTracker != null) {
                mVelocityTracker.recycle();
                mVelocityTracker = null;
            }
            break;
    }

    // 如果速度跟踪器不为空,添加当前触摸事件
    if (mVelocityTracker != null) {
        mVelocityTracker.addMovement(ev);
    }

    return mIsBeingDragged;
}

4.2 事件处理

NestedScrollView 拦截触摸事件后,会通过 onTouchEvent 方法来处理滚动事件。

java 复制代码
@Override
public boolean onTouchEvent(MotionEvent ev) {
    // 确保速度跟踪器已初始化
    ensureVelocityTracker();
    // 将当前触摸事件添加到速度跟踪器中
    mVelocityTracker.addMovement(ev);

    // 获取触摸事件的动作类型
    final int action = ev.getActionMasked();

    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            // 如果当前正在进行滚动动画,停止动画
            if (!mScroller.isFinished()) {
                mScroller.abortAnimation();
            }
            // 记录触摸事件的 Y 坐标
            mLastMotionY = (int) ev.getY();
            // 开始嵌套滚动,指定垂直方向
            startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            // 获取当前触摸事件的 Y 坐标
            final int y = (int) ev.getY();
            // 计算 Y 方向的偏移量
            int deltaY = (int) (mLastMotionY - y);
            // 记录上一次的 Y 坐标
            mLastMotionY = y;

            // 分发预嵌套滚动事件
            if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                    ViewCompat.TYPE_TOUCH)) {
                // 减去父视图消费的滚动距离
                deltaY -= mScrollConsumed[1];
                // 更新嵌套滚动的 Y 偏移量
                mNestedYOffset += mScrollOffset[1];
            }

            // 如果正在被拖动
            if (mIsBeingDragged) {
                // 处理滚动事件
                if (deltaY > 0) {
                    // 向上滚动
                    int scrollY = getScrollY();
                    if (scrollY < getScrollRange()) {
                        // 计算滚动距离
                        int scrollBy = Math.min(deltaY, getScrollRange() - scrollY);
                        // 调用 scrollBy 方法进行滚动
                        scrollBy(0, scrollBy);
                    }
                } else if (deltaY < 0) {
                    // 向下滚动
                    int scrollY = getScrollY();
                    if (scrollY > 0) {
                        // 计算滚动距离
                        int scrollBy = Math.min(-deltaY, scrollY);
                        // 调用 scrollBy 方法进行滚动
                        scrollBy(0, -scrollBy);
                    }
                }

                // 分发嵌套滚动事件
                if (dispatchNestedScroll(0, 0, 0, deltaY, mScrollOffset,
                        ViewCompat.TYPE_TOUCH)) {
                    // 更新嵌套滚动的 Y 偏移量
                    mNestedYOffset += mScrollOffset[1];
                }
            }
            break;
        }
        case MotionEvent.ACTION_UP:
            // 计算当前的滚动速度
            mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
            int initialVelocity = (int) mVelocityTracker.getYVelocity();
            // 如果速度大于最小滚动速度,进行惯性滚动
            if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                flingWithNestedDispatch(-initialVelocity);
            } else if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
                    getScrollRange())) {
                // 回弹动画
                invalidate();
            }
            // 停止嵌套滚动
            stopNestedScroll(ViewCompat.TYPE_TOUCH);
            // 标记未开始拖动
            mIsBeingDragged = false;
            // 回收速度跟踪器
            if (mVelocityTracker != null) {
                mVelocityTracker.recycle();
                mVelocityTracker = null;
            }
            break;
        case MotionEvent.ACTION_CANCEL:
            // 如果正在被拖动
            if (mIsBeingDragged) {
                // 回弹动画
                if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
                        getScrollRange())) {
                    invalidate();
                }
            }
            // 停止嵌套滚动
            stopNestedScroll(ViewCompat.TYPE_TOUCH);
            // 标记未开始拖动
            mIsBeingDragged = false;
            // 回收速度跟踪器
            if (mVelocityTracker != null) {
                mVelocityTracker.recycle();
                mVelocityTracker = null;
            }
            break;
    }

    return true;
}

五、NestedScrollView 嵌套滚动机制

5.1 嵌套滚动流程

嵌套滚动的流程主要包括以下几个步骤:

  1. 开始嵌套滚动 :子视图调用 startNestedScroll 方法通知父视图开始嵌套滚动,父视图通过 onStartNestedScroll 方法决定是否接受嵌套滚动。
  2. 预滚动处理 :子视图在滚动前调用 dispatchNestedPreScroll 方法分发预滚动事件,父视图通过 onNestedPreScroll 方法处理预滚动事件,并返回消费的滚动距离。
  3. 滚动处理 :子视图处理剩余的滚动距离,并调用 dispatchNestedScroll 方法分发滚动事件,父视图通过 onNestedScroll 方法处理滚动事件。
  4. 停止嵌套滚动 :子视图调用 stopNestedScroll 方法通知父视图停止嵌套滚动,父视图通过 onStopNestedScroll 方法进行清理工作。

5.2 源码分析

java 复制代码
// 开始嵌套滚动
@Override
public boolean startNestedScroll(int axes, int type) {
    // 调用 NestedScrollingChildHelper 的 startNestedScroll 方法
    return mNestedScrollingChildHelper.startNestedScroll(axes, type);
}

// 停止嵌套滚动
@Override
public void stopNestedScroll(int type) {
    // 调用 NestedScrollingChildHelper 的 stopNestedScroll 方法
    mNestedScrollingChildHelper.stopNestedScroll(type);
}

// 判断是否有嵌套滚动的父视图
@Override
public boolean hasNestedScrollingParent(int type) {
    // 调用 NestedScrollingChildHelper 的 hasNestedScrollingParent 方法
    return mNestedScrollingChildHelper.hasNestedScrollingParent(type);
}

// 分发嵌套滚动事件
@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
    // 调用 NestedScrollingChildHelper 的 dispatchNestedScroll 方法
    return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
            dxUnconsumed, dyUnconsumed, offsetInWindow, type);
}

// 分发预嵌套滚动事件
@Override
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
        @Nullable int[] offsetInWindow, int type) {
    // 调用 NestedScrollingChildHelper 的 dispatchNestedPreScroll 方法
    return mNestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed,
            offsetInWindow, type);
}

// 作为父视图,处理开始嵌套滚动事件
@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
    // 判断是否为垂直滚动
    return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}

// 作为父视图,处理嵌套滚动接受事件
@Override
public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
    // 调用 NestedScrollingParentHelper 的 onNestedScrollAccepted 方法
    mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type);
    // 开始嵌套滚动,指定垂直方向
    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type);
}

// 作为父视图,处理停止嵌套滚动事件
@Override
public void onStopNestedScroll(@NonNull View target, int type) {
    // 调用 NestedScrollingParentHelper 的 onStopNestedScroll 方法
    mNestedScrollingParentHelper.onStopNestedScroll(target, type);
    // 停止嵌套滚动
    stopNestedScroll(type);
}

// 作为父视图,处理嵌套滚动事件
@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed, int type) {
    // 分发嵌套滚动事件
    dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, null, type);
}

// 作为父视图,处理预嵌套滚动事件
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
    // 如果向上滚动
    if (dy > 0) {
        int scrollY = getScrollY();
        if (scrollY < getScrollRange()) {
            // 计算消费的滚动距离
            int scrollBy = Math.min(dy, getScrollRange() - scrollY);
            // 调用 scrollBy 方法进行滚动
            scrollBy(0, scrollBy);
            // 记录消费的滚动距离
            consumed[1] = scrollBy;
        }
    }
}

六、NestedScrollView 滚动动画处理

6.1 滚动动画原理

NestedScrollView 使用 Scroller 类来处理滚动动画,Scroller 类通过计算滚动的起始位置、结束位置和持续时间,模拟出平滑的滚动效果。

6.2 源码分析

java 复制代码
// 初始化 Scroller 对象
private void initScrollView() {
    // 创建 Scroller 对象
    mScroller = new Scroller(getContext(), sInterpolator);
    // 获取最小触摸滑动阈值
    final ViewConfiguration configuration = ViewConfiguration.get(getContext());
    mTouchSlop = configuration.getScaledTouchSlop();
    // 获取最大滚动速度
    mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
    // 获取最小滚动速度
    mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
    // 初始化嵌套滚动相关的帮助类
    mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
    mNestedScrollingChildHelper = new NestedScrollingChildHelper(this);
    // 设置是否支持嵌套滚动
    setNestedScrollingEnabled(true);
}

// 处理惯性滚动
private void flingWithNestedDispatch(int velocityY) {
    // 判断是否可以进行滚动
    final boolean canFling = (getScrollY() > 0 || velocityY > 0)
            && (getScrollY() < getScrollRange() || velocityY < 0);
    // 分发预嵌套滚动事件
    if (!dispatchNestedPreFling(0, velocityY)) {
        // 分发嵌套滚动事件
        dispatchNestedFling(0, velocityY, canFling);
        // 调用 fling 方法进行惯性滚动
        fling(velocityY);
    }
}

// 进行惯性滚动
public void fling(int velocityY) {
    // 如果有子视图
    if (getChildCount() > 0) {
        // 获取视图的高度
        int height = getHeight() - getPaddingBottom() - getPaddingTop();
        // 获取子视图的高度
        int bottom = getChildAt(0).getHeight();
        // 调用 Scroller 的 fling 方法开始滚动动画
        mScroller.fling(getScrollX(), getScrollY(), 0, velocityY,
                0, 0, 0, Math.max(0, bottom - height));
        // 如果需要回弹,触发重绘
        if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
                Math.max(0, bottom - height))) {
            invalidate();
        }
    }
}

// 计算滚动偏移量
@Override
public void computeScroll() {
    // 如果 Scroller 还在滚动中
    if (mScroller.computeScrollOffset()) {
        // 获取当前的滚动位置
        int oldX = getScrollX();
        int oldY = getScrollY();
        int x = mScroller.getCurrX();
        int y = mScroller.getCurrY();
        // 如果滚动位置发生变化
        if (oldX != x || oldY != y) {
            // 调用 scrollTo 方法更新滚动位置
            scrollTo(x, y);
        }
        // 触发重绘,继续滚动动画
        invalidate();
    }
}

七、NestedScrollView 与其他组件的配合使用

7.1 与 RecyclerView 的配合

NestedScrollView 可以与 RecyclerView 配合使用,实现嵌套滚动效果。在布局中,将 RecyclerView 放置在 NestedScrollView 内部,通过设置 RecyclerViewnestedScrollingEnabled 属性为 false,可以避免 RecyclerView 自己处理滚动事件,从而让 NestedScrollView 统一处理滚动。

xml 复制代码
<androidx.core.widget.NestedScrollView
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:nestedScrollingEnabled="false" />
</androidx.core.widget.NestedScrollView>

7.2 与 AppBarLayout 的配合

NestedScrollView 可以与 AppBarLayout 配合使用,实现滚动时标题栏的渐变、隐藏等效果。在布局中,将 AppBarLayout 放置在 NestedScrollView 外部,通过设置 AppBarLayoutScrollingViewBehavior,可以实现滚动联动。

xml 复制代码
<com.google.android.material.appbar.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <androidx.appcompat.widget.Toolbar
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin" />
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <!-- 内容视图 -->
    </androidx.core.widget.NestedScrollView>
</com.google.android.material.appbar.CoordinatorLayout>

八、总结与展望

8.1 总结

通过对 NestedScrollView 的源码分析,我们深入了解了其使用原理。NestedScrollView 作为一个支持嵌套滚动的视图容器,通过实现 NestedScrollingParentNestedScrollingChild 接口,与父视图和子视图之间协同处理滚动事件,实现了流畅的滚动效果。其触摸事件处理机制、嵌套滚动机制和滚动动画处理机制相互配合,为开发者提供了强大的滚动功能。

8.2 展望

随着 Android 系统的不断发展,NestedScrollView 可能会在以下方面得到进一步的优化和扩展:

  • 性能优化:进一步优化滚动性能,减少滚动时的卡顿现象,提高用户体验。
  • 功能扩展:增加更多的滚动效果和交互方式,如弹性滚动、阻尼滚动等。
  • 兼容性提升:更好地兼容不同版本的 Android 系统和各种设备,确保在不同环境下都能正常使用。

总之,NestedScrollView 作为 Android 开发中重要的滚动视图组件,将在未来的开发中发挥更加重要的作用。开发者可以根据实际需求,灵活运用 NestedScrollView 的特性,实现各种复杂的滚动效果。

以上是一篇关于 Android NestedScrollView 使用原理的技术博客,通过对源码的详细分析,帮助开发者深入理解 NestedScrollView 的工作原理和使用方法。希望对你有所帮助!

相关推荐
Lary_Rock2 分钟前
Android 编译问题 prebuilts/clang/host/linux-x86
android·linux·运维
王江奎37 分钟前
Android FFmpeg 交叉编译全指南:NDK编译 + CMake 集成
android·ffmpeg
limingade1 小时前
手机打电话通话时如何向对方播放录制的IVR引导词声音
android·智能手机·蓝牙电话·手机提取通话声音
天天扭码1 小时前
深入讲解Javascript中的常用数组操作函数
前端·javascript·面试
渭雨轻尘_学习计算机ing1 小时前
二叉树的最大宽度计算
算法·面试
mazhimazhi1 小时前
GC垃圾收集时,居然还有用户线程在奔跑
后端·面试
Java技术小馆2 小时前
SpringBoot中暗藏的设计模式
java·面试·架构
Aniugel2 小时前
JavaScript高级面试题
javascript·设计模式·面试
lqstyle2 小时前
Redis的Set:你以为我是青铜?其实我是百变星君!
后端·面试
hepherd2 小时前
Flutter 环境搭建 (Android)
android·flutter·visual studio code