Android LayoutManager打造最好用的吸顶效果

前言

在之前的一些文章中,我们实现过各种各样的布局效果,当然也有吸顶效果,在写本篇之前可以看看之前的文章。

上面的文章基本都是View内部布局方式实现的,当然也有Scrolling机制、ViewDragger、内部事件等。其实,按照Android官方的意图,从约束布局和RecyclerView上看,其目标是减少对View内部的实质性修改,而通过布局辅助器增强View的功能,因为不断的自定义View对使用者的学习成本比较高,甚至有很多人都疲倦去学习新的View用法,新View涉及导包、api、布局等,经常要学习,久而久之利用率显然不太理想。而对于开发者比较熟悉的View上进行扩展,但又能让开发者快速接入,显然LayoutManager或者各种Helper方式显然效果更好一些。

下面是本篇的效果

Layout自定义知识点回顾

其实自定义Layout重点在测量、布局、绘制、事件处理,这里其实大家耳熟能详了。

基本知识

  • 测量:测量子View或者自身View的大小,由外到内测量,测量有三种模式,但父View可以决定子View的模式。
  • 布局:布置子View或者自身View的位置,由外到内测量
  • 绘制:将View的图形描述绘制到Canvas上
  • 事件:一般指Touch事件和Key事件,前者在触屏模式使用,后者在焦点模式使用 (注意:我这里说的模式,而不是设备,因为Android设备这两种都支持)

我们着重了解下事件,因为是老生常谈的事情。

事件拦截:

  • 捕获事件必须接受DOWN事件
  • KEY_EVENT可以直达焦点View,而Touch事件需要层层传递
  • 同一ViewGroup的子View中,默认情况下,绘制顺序越靠后,越容易先接收到事件,因为绘制靠后的View是后续加入的,层级较高。
  • 在事件传递的过程中,事件传递过程中ViewGroup至少有2次以上的拦截机会。
  • KEY_CENTER\KEY_ENTER 等部分事件会被判定长按,其他事件会被判断为多次点击
  • onClick和onLongClick是通过定时触发的
  • hotspot 可以让drawable接收到事件
  • 事件接受时间是不连续的
  • EventHub负责接收手机,通过InputChannel向前台Activity传递事件
  • Window接收事件的顺序是在Activity之后 ....

requestLayout抑制

  • 不要修改布局边界,多用Matrix去处理,如scale、rotate、translate等
  • 按照显示隐藏频度,高频使用INVSIBLE & VISIBLE
  • 设置drawable之前提前设置drawable大小,避免setBackground内部触发requestLayout
  • TextView固定大小或者自定义文本展示,避免requestLayout
  • 进度类型,不要修改布局边界,建议修改drawable的边界
  • 减少布局层级,降低requestLayout measure的几率
  • 减少addView、removeView、offsetXXX方法的调用,适当使用removeViewInLayout或者addViewInLayout,当然addViewInLayout外部无法调用,那就使用detachViewFromParent和attachViewFromParent。

建议

避免过多的LayoutInflater,提高可移植性 尽可能减少requestLayout,提高绘制帧率 高帧率异步渲染、必要时使用SurfaceView 尽可能使用Adapter实现View的复用 减少主线程耗时 ...

吸顶效果原理

目前,网上有两种主流的实现方案:

利用ItemDecoration绘制

这种有个比较明显的缺陷就是点击事件很难响应,因为绘制区域无法拦截事件

父View Wrapper

这种是利用父View,从Recycler缓存中拿一个和RecyclerView相同类型的View,可以处理事件,但是由于和RecyclerView上的Item是相互独立的因此需要进行状态同步,比如在RecyclerView上的是CheckBox,那么显然需要LiveData或者EventBus去处理,这样耦合逻辑会很多。

自定义LayoutManager

我们这里不是继承LayoutManager,因为毕竟RecyclerView原始逻辑很成熟,我们只需要继承LinearLayoutManager或者GridLayoutManager。

自定义LayoutManager的开源项目中你很难看到对这两者的扩展,毕竟实在是太复杂了。

LinearLayoutManager和GridLayoutManager的布局思想

LayoutManager只初始化布局和布局item滑动时填充。

关于滑动

我们之前很多自定义Layout的文章中提到过,在Android中View的滑动方式有两种:

  • 第一种是"齿轮传动",核心原理是Matrix 变换 (x,y,scale),代表View是ScrollView,当然这种性能很高,但是在View变多时性能会显著下降;
  • 另一种是滑板派,所有子View的布局边界联动(left、right、top、bottom),单一操作性能一般,但是配合Adapter不断复用回收,相比ScrollView在大量View的情况下性能显然高很多。

关于填充

由于要配合Recycler机制,LayoutManager需要不断回收和复用View,但是重点是其填充逻辑。

填充逻辑

LinearLayoutManager的填充逻辑是

  • 尝试移除View并回收
  • 查找锚点(默认取第一个)
  • 然后执行三种layout steps
  • 布局完成

为什么很少有LinearLayoutManager的吸顶,主要是锚点问题,好消息是onAnchorReady这个方法是可以修改锚点的,换消息是只对包内子View开放,所以你需要在androidx.recyclerview.widget下继承。

当然,本篇没有这么做,因为还是太复杂。

本篇主要分为三步:

  • 釜底抽薪,不让吸顶View成为锚点
  • 执行父类方法
  • 重新布置吸顶View的位置

下面是核心过程

核心思想

釜底抽薪

首先,我们要解决的是如何避免要吸顶的View不被选择为锚点?因为一旦选择为锚点,那么其他子View会参考锚点位置布局,所以,要在LayoutManager选择锚点前"无刷新移除"View,这里我们可以使用removeAndRecycleView。

这招可以称为"釜底抽薪"

这里我们只需要在布局之前将锚点移除

java 复制代码
//先移除吸顶的View,防止LayoutManager将吸顶的View作为anchor 锚点
removeStickyView(recycler);
//让LayoutManager布局,其实这时候可能会将吸顶View加入进去,不过没关系,RecyclerView的addView很强大
super.onLayoutChildren(recycler, state);

同样纵向也是

java 复制代码
//先移除吸顶的View,防止LayoutManager将吸顶的View作为anchor 锚点
removeStickyView(recycler);
//让LayoutManager布局,其实这时候可能会将吸顶View加入进去,不过没关系,RecyclerView的addView很强大
int scrollOffsetY = super.scrollVerticallyBy(dy, recycler, state);

删除可见View

删除怎么删呢,怎么知道哪些要被删除呢,其实我们这里需要定义ItemViewType,和Adapter中的itemViewType映射。

ini 复制代码
private int[] stickyItemTypes = null;

删除的时候,不是从缓存中拿View,而是删除上一次在界面上存在的View,当然,我们要删的是吸顶的View和移出视觉区域的View,而不是所有的见面上的Sticky View。

java 复制代码
/**
 * 删除正在吸顶的View
 * @param recycler
 */
private void removeStickyView(RecyclerView.Recycler recycler) {
    int count = getChildCount();
    if (count <= 0) {
        return;
    }
    /**
     * 注意,这里一定要删除页面上的View,而不是从缓存中拿出来删,那样是无用功
     */
    for (int i = 1; i < count; i++) {
        View child = getChildAt(i);
        if (child == null) continue;
        int itemViewType = getItemViewType(child);
        if (!isStickyItemType(itemViewType)) {
            continue;
        }
        int decoratedTop = getDecoratedTop(child);
        if (decoratedTop <= 0) {
            //删除 top <= 0的吸顶View,因为正常情况下页面child要么在吸顶,要么不可见了
            removeAndRecycleView(child, recycler);
        }
    }
}

先让LayoutManager自己布局

我们要保证原始的布局逻辑保持不变,但是这时候吸顶的View可能也被加入了布局。了解过自定义View机制你就会知道,在布局方法或者onSizeChanged方法中频繁删除和重建View并不会影响展示,因此,我们可以把原有的View拿到,如果拿不到就从缓存中拿,拿到之后让其吸顶,且不会影响原有布局中的item位置。

我们开头说过,RecyclerView属于滑板派,只要你不requestLayout,每个View的left、top、right、bottom还是会保持原来的位置。

addView魔法

我们要知道的是,让其他ItemView不要盖住StickyView

我们文章开头说过: 后加入的View最后绘制,事件最优先接收,显然吸顶的View要在最后加入,才能不被遮盖。

问题是,吸顶的View可能已经加入进去了,怎么办?

我们文章开头还说过:

"减少addView、removeView、offsetXXX方法的调用,适当使用removeViewInLayout或者addViewInLayout,当然addViewInLayout外部无法调用,那就使用detachViewFromParent和attachViewFromParent",这些方法可以帮助我们调整View顺序,当然这是最初的想法。但是现实是RecyclerView 似乎和这些有冲突,然后去看addView源码,无意间发现LayoutManager#addView竟然可以移动View的顺序。

显然我们要做的是重置顺序,当然有人会说View#bingToFront不行么?如果在ScrollView中是可行的,但是在RecyclerView中是不行的,因为其内部有调用requestLayout,不适合滑动过程布局。

我们先看看addView核心逻辑,从代码中可以看到,其内部调用的方法很少触发requestLayout的条件,所以一定要知道的是,在滑动过程中切忌不要调用触发requestlayout的方法。

java 复制代码
private void addViewInt(View child, int index, boolean disappearing) {
    final ViewHolder holder = getChildViewHolderInt(child);
    if (disappearing || holder.isRemoved()) {
    
        mRecyclerView.mViewInfoStore.addToDisappearedInLayout(holder);
    } else {
        mRecyclerView.mViewInfoStore.removeFromDisappearedInLayout(holder);
    }
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    if (holder.wasReturnedFromScrap() || holder.isScrap()) {
        if (holder.isScrap()) {
            holder.unScrap();
        } else {
            holder.clearReturnedFromScrapFlag();
        }
        mChildHelper.attachViewToParent(child, index, child.getLayoutParams(), false);
        if (DISPATCH_TEMP_DETACH) {
            ViewCompat.dispatchFinishTemporaryDetach(child);
        }
    } else if (child.getParent() == mRecyclerView) { // it was not a scrap but a valid child
        // ensure in correct position
        int currentIndex = mChildHelper.indexOfChild(child);
        if (index == -1) {
            index = mChildHelper.getChildCount();
        }
        if (currentIndex == -1) {
            throw new IllegalStateException("Added View has RecyclerView as parent but"
                    + " view is not a real child. Unfiltered index:"
                    + mRecyclerView.indexOfChild(child) + mRecyclerView.exceptionLabel());
        }
        if (currentIndex != index) {
            mRecyclerView.mLayout.moveView(currentIndex, index);
        }
    } else {
        mChildHelper.addView(child, index, false);
        lp.mInsetsDirty = true;
        if (mSmoothScroller != null && mSmoothScroller.isRunning()) {
            mSmoothScroller.onChildAttachedToWindow(child);
        }
    }
    if (lp.mPendingInvalidate) {
        if (DEBUG) {
            Log.d(TAG, "consuming pending invalidate on child " + lp.mViewHolder);
        }
        holder.itemView.invalidate();
        lp.mPendingInvalidate = false;
    }
}

重新布局

首先我们知道页面上第一个View的位置,我们可以由此定位到其所在的分组itemViewType类型,如果其不属于要吸顶的item,那么继续向前搜索,如果是立即布局,下面首先查询可以吸顶且越第一个ItemView"血缘"最近的分组。

java 复制代码
private View lookupStickyItemView(RecyclerView.Recycler recycler) {
    int childCount = getChildCount();
    if (childCount <= 0) {
        return null;
    }
    //先看看第一个View是不是可以吸顶,如果不可以,则从缓存中查询
    View view = getChildAt(0);
    int itemViewType = getItemViewType(view);
    int adapterPosition = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewAdapterPosition();
    View groupView = null;
    if (!isStickyItemType(itemViewType)) {
        //一般来说下,吸顶View的itemType在前面查询,如果要改成吸底的则在后面查询,因此这里逆序
        for (int i = adapterPosition - 1; i >= 0; i--) {
            //从缓存中查询
            View childView = recycler.getViewForPosition(i);
            //获取View类型
            itemViewType = getItemViewType(childView);
            if (isStickyItemType(itemViewType)) {
                groupView = childView;
                break;
            }
        }
    } else {
        //页面上第一个View就是吸顶的View
        groupView = view;
    }

    if (groupView == null) {
        Log.d(TAG, "not found " + itemViewType + " ,topChildPosition =" + adapterPosition);
        return null;
    }
    return groupView;
}

布局

scss 复制代码
addView(currentStickyItemView);
//测量多次没有问题,允许多次测量
measureChildWithMargins(currentStickyItemView, 0, 0);
int top = 0;
int right = getDecoratedMeasuredWidth(currentStickyItemView);
layoutDecoratedWithMargins(currentStickyItemView, 0, 0, right, bottom);

问题是,页面上可能有多个吸顶ItemView,当向上滑动时吸顶的View要保证下面要吸顶的不被遮盖,那就意味着吸顶的View需要滑动。

怎么做?

当然是查找当前吸顶View的下一个可吸顶的兄弟,当然我们只需要在页面上查找,Adapter查找没有意义,因为只会用到离当前吸顶View最近的,不在页面或者没出生的肯定不能算。

java 复制代码
/**
 * 获取当前页面布局区域内的所有吸顶View
 * @return
 */
private List<View> getStickyItemViews() {
    stickyAttachedViewList.clear();
    int childCount = getChildCount();
    if (childCount <= 0) {
        return stickyAttachedViewList;
    }
    for (int i = 1; i < childCount; i++) {
        View child = getChildAt(i);
        if (child == null) continue;
        int itemViewType = getItemViewType(child);
        if (isStickyItemType(itemViewType)) {
            stickyAttachedViewList.add(child);
        }
    }
    return stickyAttachedViewList;
}

上面的查找肯定也会查找到正在吸顶的ItemView,为了避免逻辑错误,我们把其删除掉

java 复制代码
/**
 * 因为不能保证吸顶的View顺序是最理想的按默认排列,因此这里正在西定的View在绘制顺序的最顶部,
 * 但是其他可以吸顶的View是正常顺序,因此删除掉,从开始位置计算,如果下一个离正在吸顶View最近的View顶到了它 (哈哈,莫要想歪了),
 * 那么就得让他偏移
 */
stickyChildren.remove(currentStickyItemView);

那么位置计算呢? 首先吸顶的View top 默认是0,因此向上滑动top应该变成负值,我们用下一个要吸顶的View的top减去当前吸顶View的高度即可,但是前提是这个高度必须已经触及了正在吸顶View的边缘。

java 复制代码
for (int index = 0; index < size; index++) {
    View nextChild = stickyChildren.get(index);
    int nextStickyViewTop = getDecoratedTop(nextChild);
    if (nextStickyViewTop < topStickyViewTop) {
        continue;
    }
    if (nextStickyViewTop > topStickyViewHeight) {
        continue;
    }
    top = nextStickyViewTop - topStickyViewHeight; //计算偏移距离
    break;
}

调整布局逻辑

scss 复制代码
int bottom = top + topStickyViewHeight;
layoutDecoratedWithMargins(currentStickyItemView, 0, top, getDecoratedMeasuredWidth(currentStickyItemView), bottom);

用法

为了方便使用,我们其实使用GridLayoutManager实现了吸顶灯效果,下面是本文效果图的展示实现。

ini 复制代码
public class MainActivity extends Activity {

    private RecyclerView recyclerView;
    private QuickAdapter quickAdapter;
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.recycle_main);
        recyclerView = findViewById(R.id.recycleView);
        int[] stickyItemTypes = new int[]{
                ItemType.VIEW_TYPE_GROUP, //此类型需要吸顶
                ItemType.VIEW_TYPE_GROUP_ICON //此类型需要吸顶
        };
        recyclerView.setLayoutManager(new StickyGridLayoutManager(this, stickyItemTypes,1));
        quickAdapter = new QuickAdapter(createFakeDatas());
        recyclerView.setAdapter(quickAdapter);
    }

    private List<DataModel> createFakeDatas() {
        List<DataModel> list = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            DataModel child = new ItemDataModel("第" + 0 + "组第" + (i + 1) + "号");
            list.add(child);
        }
        for (int g = 0; g < 10; g++) {
            DataModel group = (g % 2 == 0) ? new GroupDataModel("第" + (g + 1) + "组") : new GroupDataModelIcon("第" + (g + 1) + "组");
            list.add(group);
            int count = (int) (10 + 10 * Math.random());
            for (int i = 0; i < count; i++) {
                DataModel child = new ItemDataModel("第" + (g + 1) + "组第" + (i + 1) + "号");
                list.add(child);
            }
        }
        return list;
    }

}

总结

特点

到这里我们创建吸顶LayoutManager就结束了,相比网上的其他两种方案,这种方案优势明显:

  • 耦合度更小
  • 可移植性更高
  • 状态不需要同步
  • 支持事件
  • 不依赖itemDecoration
  • 不依赖父布局
  • 不依赖Adapter

全部代码

按照惯例,这里提供实现源码,方便大家参考和改造。

java 复制代码
public class StickyGridLayoutManager extends GridLayoutManager {

    private static final String TAG = "StickyGridManager";
    private final List<View> stickyAttachedViewList = new ArrayList<>();
    private int[] stickyItemTypes = null;


    public StickyGridLayoutManager(Context context, int[] stickyItemTypes, int spanCount) {
        super(context, spanCount);
        this.stickyItemTypes = stickyItemTypes;
    }

    public StickyGridLayoutManager(Context context, int[] stickyItemTypes, int spanCount, int orientation, boolean reverseLayout) {
        super(context, spanCount, orientation, reverseLayout);
        this.stickyItemTypes = stickyItemTypes;

    }

    public StickyGridLayoutManager(Context context, AttributeSet attrs, int[] stickyItemTypes, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        this.stickyItemTypes = stickyItemTypes;
    }


    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (this.stickyItemTypes == null
                || this.stickyItemTypes.length == 0
                || getOrientation() != RecyclerView.VERTICAL) {

            super.onLayoutChildren(recycler, state);
            return;
        }
        //先移除吸顶的View,防止LayoutManager将吸顶的View作为anchor 锚点
        removeStickyView(recycler);
        //让LayoutManager布局,其实这时候可能会将吸顶View加入进去,不过没关系,RecyclerView的addView很强大
        super.onLayoutChildren(recycler, state);
        //布局吸顶的View
        layoutStickyView(recycler, state);
    }
    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (this.stickyItemTypes == null || this.stickyItemTypes.length == 0) {
            return super.scrollVerticallyBy(dy, recycler, state);
        }
        //先移除吸顶的View,防止LayoutManager将吸顶的View作为anchor 锚点
        removeStickyView(recycler);
        //让LayoutManager布局,其实这时候可能会将吸顶View加入进去,不过没关系,RecyclerView的addView很强大
        int scrollOffsetY = super.scrollVerticallyBy(dy, recycler, state);
        //布局吸顶的View
        layoutStickyView(recycler, state);
        return scrollOffsetY;
    }

    private void layoutStickyView(RecyclerView.Recycler recycler, RecyclerView.State state) {
        View currentStickyItemView = lookupStickyItemView(recycler);
        if (currentStickyItemView == null) {
            return;
        }

        /**
         * 下面方法将当前要吸顶的View添加进去
         * 注意1:addView被RecyclerView魔改过,正常情况下一个View只能被addView一次
         * 注意2: LayoutManager的addView会尽可能抑制requestLayout,正常情况下,addView必然会requestLayout
         * 注意3: LayoutManager多次addView同一个View,如果两次位置不一样,那只会改变View的加入顺序和绘制顺序
         * 注意4: 在Android系统的中,最后加入的View绘制顺序和接受事件的优先级是最高的。
         */
        addView(currentStickyItemView);

        measureChildWithMargins(currentStickyItemView, 0, 0);
        List<View> stickyChildren = getStickyItemViews();

        int top = 0;
        int topStickyViewHeight = getDecoratedMeasuredHeight(currentStickyItemView);
        int topStickyViewTop = getDecoratedTop(currentStickyItemView);

        /**
         * 因为不能保证吸顶的View顺序是最理想的按默认排列,因此这里正在西定的View在绘制顺序的最顶部,
         * 但是其他可以吸顶的View是正常顺序,因此删除掉,从开始位置计算,如果下一个离正在吸顶View最近的View顶到了它 (哈哈,莫要想歪了),
         * 那么就得让他偏移
         */
        stickyChildren.remove(currentStickyItemView);

        int size = stickyChildren.size();
        for (int index = 0; index < size; index++) {
            View nextChild = stickyChildren.get(index);
            int nextStickyViewTop = getDecoratedTop(nextChild);
            if (nextStickyViewTop < topStickyViewTop) {
                continue;
            }
            if (nextStickyViewTop > topStickyViewHeight) {
                continue;
            }
            top = nextStickyViewTop - topStickyViewHeight; //计算偏移距离
            break;
        }
        int bottom = top + topStickyViewHeight;
        layoutDecoratedWithMargins(currentStickyItemView, 0, top, getDecoratedMeasuredWidth(currentStickyItemView), bottom);
    }

    /**
     * 获取当前页面布局区域内的所有吸顶View
     * @return
     */
    private List<View> getStickyItemViews() {
        stickyAttachedViewList.clear();
        int childCount = getChildCount();
        if (childCount <= 0) {
            return stickyAttachedViewList;
        }
        for (int i = 1; i < childCount; i++) {
            View child = getChildAt(i);
            if (child == null) continue;
            int itemViewType = getItemViewType(child);
            if (isStickyItemType(itemViewType)) {
                stickyAttachedViewList.add(child);
            }
        }
        return stickyAttachedViewList;
    }

    @Nullable
    private View lookupStickyItemView(RecyclerView.Recycler recycler) {
        int childCount = getChildCount();
        if (childCount <= 0) {
            return null;
        }
        //先看看第一个View是不是可以吸顶,如果不可以,则从缓存中查询
        View view = getChildAt(0);
        int itemViewType = getItemViewType(view);
        int adapterPosition = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewAdapterPosition();
        View groupView = null;
        if (!isStickyItemType(itemViewType)) {
            //一般来说下,吸顶View的itemType在前面查询,如果要改成吸底的则在后面查询,因此这里逆序
            for (int i = adapterPosition - 1; i >= 0; i--) {
                //从缓存中查询
                View childView = recycler.getViewForPosition(i);
                //获取View类型
                itemViewType = getItemViewType(childView);
                if (isStickyItemType(itemViewType)) {
                    groupView = childView;
                    break;
                }
            }
        } else {
            //页面上第一个View就是吸顶的View
            groupView = view;
        }

        if (groupView == null) {
            Log.d(TAG, "not found " + itemViewType + " ,topChildPosition =" + adapterPosition);
            return null;
        }
        return groupView;
    }

    private boolean isStickyItemType(int itemViewType) {
        if (this.stickyItemTypes == null || this.stickyItemTypes.length == 0) {
            return false;
        }
        for (int i = 0; i < this.stickyItemTypes.length; i++) {
            if(this.stickyItemTypes[i] == itemViewType){
                return true;
            }
        }
        return false;
    }

    /**
     * 删除正在吸顶的View
     * @param recycler
     */
    private void removeStickyView(RecyclerView.Recycler recycler) {
        int count = getChildCount();
        if (count <= 0) {
            return;
        }
        /**
         * 注意,这里一定要删除页面上的View,而不是从缓存中拿出来删,那样是无用功
         */
        for (int i = 1; i < count; i++) {
            View child = getChildAt(i);
            if (child == null) continue;
            int itemViewType = getItemViewType(child);
            if (!isStickyItemType(itemViewType)) {
                continue;
            }
            int decoratedTop = getDecoratedTop(child);
            if (decoratedTop <= 0) {
                //删除 top <= 0的吸顶View,因为正常情况下页面child要么在吸顶,要么不可见了
                removeAndRecycleView(child, recycler);
            }
        }
    }

}
相关推荐
温辉_xh8 分钟前
uiautomator案例
android
y先森12 分钟前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy12 分钟前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu108301891115 分钟前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
工业甲酰苯胺1 小时前
MySQL 主从复制之多线程复制
android·mysql·adb
IT女孩儿1 小时前
CSS查缺补漏(补充上一条)
前端·css
少说多做3432 小时前
Android 不同情况下使用 runOnUiThread
android·java
吃杠碰小鸡2 小时前
commitlint校验git提交信息
前端
虾球xz3 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
Estar.Lee3 小时前
时间操作[计算时间差]免费API接口教程
android·网络·后端·网络协议·tcp/ip