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);
            }
        }
    }

}
相关推荐
程序猿online7 分钟前
nvm安装使用,控制node版本
开发语言·前端·学习
web Rookie17 分钟前
React 中 createContext 和 useContext 的深度应用与优化实战
前端·javascript·react.js
男孩1221 分钟前
react高阶组件及hooks
前端·javascript·react.js
m0_7482517241 分钟前
DataOps驱动数据集成创新:Apache DolphinScheduler & SeaTunnel on Amazon Web Services
前端·apache
珊珊来吃42 分钟前
EXCEL中给某一列数据加上双引号
java·前端·excel
氤氲息1 小时前
Android 底部tab,使用recycleview实现
android
onejason1 小时前
深度解析:利用Python爬虫获取亚马逊商品详情
前端·python
胡西风_foxww1 小时前
【ES6复习笔记】Spread 扩展运算符(8)
前端·笔记·es6·扩展·运算符·spread
Clockwiseee1 小时前
PHP之伪协议
android·开发语言·php
小林爱2 小时前
【Compose multiplatform教程08】【组件】Text组件
android·java·前端·ui·前端框架·kotlin·android studio