NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

Android 多子视图嵌套通用解决方案

  • 2019-10-21
  • 本文字数:9826 字

    阅读完需:约 32 分钟

Android多子视图嵌套通用解决方案

1. 多子 view 嵌套应用背景

百度 App 在 17 年的版本中实现 2 个子 view 嵌套滚动,用于 Feed 落地页(webview 呈现文章详情 + recycle 呈现 Native 评论)。原理是在外层提供一个 UI 容器(我们称之为”联动容器”)处理 WebView 和 Recyclerview 连贯嵌套滚动。


当时的联动容器对子 view 限制比较大,仅支持 WebView 和 Recyclerview 进行联动滚动,数量也只支持 2 个子 View。


随着组件化进程的推进,为方便各业务解耦,对联动容器提出了更高的要求,需要支持任意类型、任意数量的子 view 进行联动滚动,也就是本文要阐述的多子 view 嵌套滚动通用解决方案。


先直观感受下联动容器嵌套滚动的 Demo 效果:


2. 多子 view 嵌套实现原理

同大多数自定义控件类似,联动容器也需要处理子 view 的测量、布局以及手势处理。测量和布局对联动容器的场景来说非常简单,手势处理相对复杂些。


从 demo 效果可以看出,联动容器需要处理好和子 view 嵌套滑动问题。嵌套滑动的处理方案有两种


  1. 基于 Google 的 NestedScrolling 机制实现嵌套滑动;

  2. 是由联动容器内部处理和子 view 嵌套滑动的逻辑。


百度 App 早期版本的联动容器采用的方案 2 实现的,下图为方案 2 联动容器手势处理流程:



笔者对方案 2 联动容器的实现代码做了开源,感兴趣的同学可以参考:https://github.com/baiduapp-tec/LinkageScrollLayout


基于 google 的 NestedScrolling 实现多子 view 嵌套能节省不少开发量,故笔者对多子 view 嵌套的实现采用方案一。

3. 核心逻辑

3.1 Google 嵌套滑动机制

Google 在 Android 5.0 推出了一套 NestedScrolling 机制,这套机制滚动打破了对之前 Android 传统的事件处理的认知,是按照逆向事件传递机制来处理嵌套滚动,事件传递可参考下图:



网上有很多关于 NestedScrolling 的文章,如果没接触过 NestedScrolling 的同学可参考下张鸿洋的这篇文章:https://blog.csdn.net/lmj623565791/article/details/52204039

3.2 接口设计

为了保证联动容器中子 view 的任意性,联动容器需提供完善的接口抽象供子 view 去实现。下图为联动容器暴露的接口类图:



ILinkageScroll 是置于联动容器中的子 view 必须要实现的接口,联动容器在初始化时如果发现某个子 view 没实现该接口,会抛出异常。ILinkageScroll 中又会涉及两个接口:LinkageScrollHandler、ChildLinkageEvent。


LinkageScrollHandler 接口中的方法联动容器会在需要时主动调用,以通知子 view 完成一些功能,比如:获取子 view 是否可滚动,获取子 view 滚动条相关数据等。


ChildLinkageEvent 接口定义了子 view 的一些事件信息,比如子 view 的内容滚动到顶部或底部。当发生这些事件后,子 view 主动调用对应方法,这样联动容器收到子 view 一些事件后会做出相应的反应,保证正常的联动效果。


上面仅简单说明了下接口功能,想更加深入了解的同学请参考:https://github.com/baiduapp-tec/ELinkageScroll


接下来我们详细分析下联动容器对手势处理细节,根据手势类型,将嵌套滑动分为两种情况来分析:1. scroll 手势;2. fling 手势;

3.3 scroll 手势

先给出 scroll 手势处理的核心代码:



public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent { @Override public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { boolean moveUp = dy > 0; boolean moveDown = !moveUp; int scrollY = getScrollY(); int topEdge = target.getTop(); LinkageScrollHandler targetScrollHandler = ((ILinkageScroll)target).provideScrollHandler(); if (scrollY == topEdge) { // 联动容器scrollY与当前子view的top坐标重合 if ((moveDown && !targetScrollHandler.canScrollVertically(-1)) || (moveUp && !targetScrollHandler.canScrollVertically(1))) { // 在对应的滑动方向上,如果子view不能垂直滑动,则由联动容器消费滚动距离 scrollBy(0, dy); consumed[1] = dy; } } else if (scrollY > topEdge) { // 联动容器scrollY大于当前子view的top坐标,也就是说,子view头部已经滑出联动容器 if (moveUp) { // 如果手指上滑,则由联动容器消费滚动距离 scrollBy(0, dy); consumed[1] = dy; } if (moveDown) { // 如果手指下滑,联动容器会先消费部分距离,此时联动容器的scrollY会不断减小, // 直到等于子view的top坐标后,剩余的滑动距离则由子view继续消费。 int end = scrollY + dy; int deltaY; deltaY = end > topEdge ? dy : (topEdge - scrollY); scrollBy(0, deltaY); consumed[1] = deltaY; } } else if (scrollY < topEdge) { // 联动容器scrollY小于当前子view的top坐标,也就是说,子view还没有完全露出 if (moveDown) { // 如果手指下滑,则由联动容器消费滚动距离 scrollBy(0, dy); consumed[1] = dy; } if (moveUp) { // 如果手指上滑,联动容器会先消费部分距离,此时联动容器的scrollY会不断增大, // 直到等于子view的top坐标后,剩余的滑动距离则由子view继续消费。 int end = scrollY + dy; int deltaY; deltaY = end < topEdge ? dy : (topEdge - scrollY); scrollBy(0, deltaY); consumed[1] = deltaY; } } } @Override public void scrollBy(int x, int y) { // 边界检查 int scrollY = getScrollY(); int deltaY; if (y < 0) { deltaY = (scrollY + y) < 0 ? (-scrollY) : y; } else { deltaY = (scrollY + y) > mScrollRange ? (mScrollRange - scrollY) : y; } if (deltaY != 0) { super.scrollBy(x, deltaY); } }}
复制代码


onNestedPreScroll()回调是 google 嵌套滑动机制 NestedScrollingParent 接口中的方法。当子 view 滚动时,会先通过此方法询问父 view 是否消费这段滚动距离,父 view 根据自身情况决定是否消费以及消费多少,并将消费的距离放入数组 consumed 中,子 view 再根据数组中的内容决定自己的滚动距离。


代码注释比较详细,这里整体再做个解释:通过对子 view 的上边沿阈值和联动容器的 scrollY 进行比较,处理了 3 种 case 下的滚动情况。


第 10 行,当 scrollY == topEdge 时,只要子 view 没有滚动到顶或者底,都由子 view 正常消费滚动距离,否则由联动容器消费滚动距离,并将消费的距离通过 consumed 变量通知子 view,子 view 会根据 consumed 变量中的内容决定自己的滑动距离。


第 17 行,当 scrollY > topEdge 时,也就是说当触摸的子 view 头部已经滑出联动容器,此时如果手指向上滑动,滑动距离全部由联动容器消费,如果手指向下滑动,联动容器会先消费部分距离,当联动容器的 scrollY 达到 topEdge 后,剩余的滑动距离由子 view 继续消费。


第 32 行,当 scrollY < topEdge 这个和上一个第 17 行判断类似,这里不做过多解释。scroll 手势处理流程图如下:


3.4 fling 手势

联动容器对 fling 手势的处理大致思路如下:如果联动容器的 scrollY 等于子 view 的 top 坐标,则由子 view 自身处理 fling 手势,否则由联动容器处理 fling 手势。


而且在一次完整的 fling 周期中,联动容器和各子 view 将会交替去完成滑动行为,直到速度降为 0,联动容器需要处理好交替滑动时的速度衔接,保证整个 fling 的流畅行。接下来看下详细实现:


public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {    @Override    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {        int scrollY = getScrollY();        int targetTop = target.getTop();        mFlingOrientation = velocityY > 0 ? FLING_ORIENTATION_UP : FLING_ORIENTATION_DOWN;        if (scrollY == targetTop) {    // 当联动容器的scrollY等于子view的top坐标,则由子view自身处理fling手势            // 跟踪velocity,当target滚动到顶或底,保证parent继续fling            trackVelocity(velocityY);            return false;        } else {    // 由联动容器消费fling手势            parentFling(velocityY);            return true;        }    }}
复制代码


onNestedPreFling()回调是 google 嵌套滑动机制 NestedScrollingParent 接口中的方法。当子 view 发生 fling 行为时,会先通过此方法询问父 view 是否要消费这次 fling 手势,如果返回 true,表示父 view 要消费这次 fling 手势,反之不消费。


第 6 行根据 velocityY 正负值记录本次的 fling 的方向;


第 7 行,当联动容器 scrollY 值等于触摸子 view 的 top 值,fling 手势由子 view 处理,同时联动容器对本次 fling 手势的速度进行追踪,目的是当子 view 内容滚到顶或者底时,能够获得剩余速度以让联动容器继续 fling;


第 12 行,由联动容器消费本次 fling 手势。下面看下联动容器和子 view 交替 fling 的细节:



public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent { @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { int y = mScroller.getCurrY(); y = y < 0 ? 0 : y; y = y > mScrollRange ? mScrollRange : y; // 获取联动容器下个滚动边界值,如果达到边界值,速度会传给下个子view,让子view继续快速滑动 int edge = getNextEdge(); // 边界检查 if (mFlingOrientation == FLING_ORIENTATION_UP) { y = y > edge ? edge : y; } // 边界检查 if (mFlingOrientation == FLING_ORIENTATION_DOWN) { y = y < edge ? edge : y; } // 联动容器滚动子view scrollTo(x, y); int scrollY = getScrollY(); // 联动容器最新的scrollY是否达到了边界值 if (scrollY == edge) { // 获取剩余的速度 int velocity = (int) mScroller.getCurrVelocity(); if (mFlingOrientation == FLING_ORIENTATION_UP) { velocity = velocity > 0? velocity : - velocity; } if (mFlingOrientation == FLING_ORIENTATION_DOWN) { velocity = velocity < 0? velocity : - velocity; } // 获取top为edge的子view View target = getTargetByEdge(edge); // 子view根据剩余的速度继续fling ((ILinkageScroll) target).provideScrollHandler() .flingContent(target, velocity); trackVelocity(velocity); } invalidate(); } } /** * 根据fling的方向获取下一个滚动边界, * 内部会判断下一个子View是否isScrollable, * 如果为false,会顺延取下一个target的edge。 */ private int getNextEdge() { int scrollY = getScrollY(); if (mFlingOrientation == FLING_ORIENTATION_UP) { for (View target : mLinkageChildren) { LinkageScrollHandler handler = ((ILinkageScroll)target).provideScrollHandler(); int topEdge = target.getTop(); if (topEdge > scrollY && isTargetScrollable(target) && handler.canScrollVertically(1)) { return topEdge; } } } else if (mFlingOrientation == FLING_ORIENTATION_DOWN) { for (View target : mLinkageChildren) { LinkageScrollHandler handler = ((ILinkageScroll)target).provideScrollHandler(); int bottomEdge = target.getBottom(); if (bottomEdge >= scrollY && isTargetScrollable(target) && handler.canScrollVertically(-1)) { return target.getTop(); } } } return mFlingOrientation == FLING_ORIENTATION_UP ? mScrollRange : 0; } /** * child view的滚动事件 */ private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() { @Override public void onContentScrollToTop(View target) { // 子view内容滚动到顶部回调 if (mVelocityScroller.computeScrollOffset()) { // 从速度追踪器中获取剩余速度 float currVelocity = mVelocityScroller.getCurrVelocity(); currVelocity = currVelocity < 0 ? currVelocity : - currVelocity; mVelocityScroller.abortAnimation(); // 联动容器根据剩余速度继续fling parentFling(currVelocity); } } @Override public void onContentScrollToBottom(View target) { // 子view内容滚动到底部回调 if (mVelocityScroller.computeScrollOffset()) { // 从速度追踪器中获取剩余速度 float currVelocity = mVelocityScroller.getCurrVelocity(); currVelocity = currVelocity > 0 ? currVelocity : - currVelocity; mVelocityScroller.abortAnimation(); // 联动容器根据剩余速度继续fling parentFling(currVelocity); } } };}
复制代码


fling 的速度传递分为:


  1. 从联动容器向子 view 传递;2. 从子 view 向联动容器传递。


先看速度从联动容器向子 view 传递。核心代码在 computeScroll()回调方法中。第 9 行,获取联动容器下一个滚动边界值,如果达到下一个滚动边界值,联动容器需要将剩余速度传给下个子 view,让其继续滚动。


第 46 行,getNextEdge()方法内部整体逻辑:遍历所有子 view,将联动容器当前的 scrollY 与子 view 的 top/bottom 进行比较来获取下一个滑动边界。


第 34 行,当联动容器检测到滑动到下个边界时,则调用 ILinkageScroll.flingContent()让子 view 根据剩余速度继续滚动。


再看速度从子 view 向联动容器传递,核心代码在第 76 行。当子 view 内容滚动到顶或者底,会回调 onContentScrollToTop()方法或者 onContentScrollToBottom()方法,联动容器收到回调后,在第 86 行和第 98 行,继续执行后续滚动。fling 手势处理流程图如下:


4. 滚动条

4.1 Android 系统的 ScrollBar

对于内容可滚动的页面,ScrollBar 则是一个不可或缺的 UI 组件,所以,ScrollBar 也是联动容器必须要实现的功能。


好在 Android 系统对滚动条的抽象非常友好,自定义控件只需要重写 View 中的几个方法,Android 系统就能帮助你正确绘制出滚动条。我们先看下 View 中的相关方法:


/** * <p>Compute the vertical offset of the vertical scrollbar's thumb within the horizontal range. This value is used to compute the position * of the thumb within the scrollbar's track.</p> * * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and * {@link #computeVerticalScrollExtent()}.</p> * * @return the vertical offset of the scrollbar's thumb */protected int computeVerticalScrollOffset() {    return mScrollY;}/** * <p>Compute the vertical extent of the vertical scrollbar's thumb within the vertical range. This value is used to compute the length * of the thumb within the scrollbar's track.</p> * * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and * {@link #computeVerticalScrollOffset()}.</p> * * @return the vertical extent of the scrollbar's thumb */protected int computeVerticalScrollExtent() {    return getHeight();}/** * <p>Compute the vertical range that the vertical scrollbar represents.</p> * * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollExtent()} and * {@link #computeVerticalScrollOffset()}.</p> * * @return the total vertical range represented by the vertical scrollbar */protected int computeVerticalScrollRange() {    return getHeight();}
复制代码


对于垂直 Scrollbar,我们只需要重写 computeVerticalScrollOffset(),computeVerticalScrollExtent(),computeVerticalScrollRange()这三个方法即可。Android 对这三个方法注释已经非常详细了,这里再简单解释下:


computeVerticalScrollOffset()表示当前页面内容滚动的偏移值,这个值是用来控制 Scrollbar 的位置。缺省值为当前页面 Y 方向上的滚动值。


computeVerticalScrollExtent()表示滚动条的范围,也就是滚动条在垂直方向上所能触及的最大界限,这个值也会被系统用来计算滚动条的长度。缺省值是 View 的实际高度。


computeVerticalScrollRange()表示整个页面内容可滚动的数值范围,缺省值为 View 的实际高度。


需要注意的是:offset,extent,range 三个值在单位上必须保持一致。

4.2 联动容器实现 ScrollBar

联动容器是由系统中可滚动的子 view 组成的,这些子 view(ListView、RecyclerView、WebView)肯定都实现了 ScrollBar 功能,那么联动容器实现 ScrollBar 就非常简单了,联动容器只需拿到所有子 view 的 offset,extent,range 值,然后再根据联动容器的滑动逻辑把所有子 view 的这些值转换成联动容器对应的 offset,extent,range 即可。接口设计如下:


public interface LinkageScrollHandler {    // ...省略无关代码    /**     * get scrollbar extent value     *     * @return extent     */    int getVerticalScrollExtent();    /**     * get scrollbar offset value     *     * @return extent     */    int getVerticalScrollOffset();    /**     * get scrollbar range value     *     * @return extent     */    int getVerticalScrollRange();}
复制代码


LinkageScrollHandler 接口在 3.2 小节解释过,这里不在赘述。这里面三个方法由子 view 去实现,联动容器会通过这三个方法获取子 view 与滚动条相关的值。下面看下联动容器中关于 ScrollBar 的详细逻辑:


public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {    /** 构造方法 */    public ELinkageScrollLayout(Context context, AttributeSet attrs, int defStyleAttr) {        // ...省略了无关代码        // 确保联动容器调用onDraw()方法        setWillNotDraw(false);        // enable vertical scrollbar        setVerticalScrollBarEnabled(true);    }    /** child view的滚动事件 */    private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() {        // ...省略了无关代码        @Override        public void onContentScroll(View target) {            // 收到子view滚动事件,显示滚动条            awakenScrollBars();        }    }    @Override    protected int computeVerticalScrollExtent() {        // 使用缺省的extent值        return super.computeVerticalScrollExtent();    }    @Override    protected int computeVerticalScrollRange() {        int range = 0;        // 遍历所有子view,获取子view的Range        for (View child : mLinkageChildren) {            ILinkageScroll linkageScroll = (ILinkageScroll) child;            int childRange = linkageScroll.provideScrollHandler().getVerticalScrollRange();            range += childRange;        }        return range;    }    @Override    protected int computeVerticalScrollOffset() {        int offset = 0;        // 遍历所有子view,获取子view的offset        for (View child : mLinkageChildren) {            ILinkageScroll linkageScroll = (ILinkageScroll) child;            int childOffset = linkageScroll.provideScrollHandler().getVerticalScrollOffset();            offset += childOffset;        }        // 加上联动容器自身在Y方向上的滚动偏移        offset += getScrollY();        return offset;    }}
复制代码


以上就是联动容器实现 ScrollBar 的核心代码,注释也非常详细,这里再重点强调几点:


系统为了提高效率,ViewGroup 默认不调用 onDraw()方法,这样就不会走 ScrollBar 的绘制逻辑。所以在第 6 行,需要调用 setWillNotDraw(false)打开 ViewGroup 绘制流程;


第 16 行,收到子 view 的滚动回调,调用 awakenScrollBars()触发滚动条的绘制;


对于 extent,直接使用缺省的 extent,即联动容器的高度;


对于 range,对所有子 view 的 range 进行求和,最后得到值即为联动容器的 range;


对于 offset,同样先对所有子 view 的 offset 进行求和,之后还需要加上联动容器自身的 scrollY 值,最终得到的值即为联动容器的 offset。


大家可以返回到文章开头,再看下 Demo 中滚动条的效果,相比于市面上其它使用类似联动技术的 App,本文对滚动条的实现非常接近原生了。

5. 注意事项

联动容器执行 fling 操作时,借助 OverScroller 工具类完成的。代码如下:


private void parentFling(float velocityY) {    // ... 省略了无关代码    mScroller.fling(0, getScrollY(),                0, (int) velocityY,                0, 0,                Integer.MIN_VALUE, Integer.MAX_VALUE);    invalidate();}
复制代码


借助 OverScroller.fling()方法完成联动容器的 fling 行为,这段代码在小米手机上运行联动会出现问题,mScroller.getCurrVelocity()一直是 0。


原因是小米手机 Rom 重写了 OverScroller,当 fling()方法第三个参数传 0 时,OverScroller.mCurrVelocity 一直为 NaN,导致无法计算出正确剩余速度。


为了解决小米手机的问题,我们需要将第三个参数传个非 0 值,这里给 1 即可。


private void parentFling(float velocityY) {    // ... 省略了无关代码    mScroller.fling(0, getScrollY(),                1, (int) velocityY,                0, 0,                Integer.MIN_VALUE, Integer.MAX_VALUE);    invalidate();}
复制代码

6. 总结

多子 view 嵌套实现原理并不复杂,对手势处理的边界条件比较琐碎,需要来回调试完善,欢迎业内的朋友一起交流学习。


Sample 地址: https://github.com/baiduapp-tec/ELinkageScroll


本文转载自公众号百度 App 技术


原文链接


https://mp.weixin.qq.com/s?__biz=MzUxMzk2ODI1NQ==&mid=2247483876&idx=1&sn=cb21ce495328bff86f0551e6d4e2c61b&chksm=f94c50f4ce3bd9e2c1e78d3317efe2fa70ff16a92cfacb07ba26e93640f46db577297a37a226&scene=27#wechat_redirect


2019-10-21 08:001540

评论 1 条评论

发布
用户头像
说实话,我进组做的第一个复杂交互中就包含这个,除了上下联动,还有左右联动切换,那时候还没有NetStedScrollView。我是怎么解决的呢,其实有个很取巧的方案,我让第二个view的内部增加一个header,这个header是个透明view,然后让他正好覆盖在第一个view之上,大小与第一个view相同(覆写相关measure方法),这样的话仅需要处理第二个所在ViewGroup的dispatchTouchEvent,当touch位置在这个header区域里的时候,去判断对应的手势,如果是点击,就透传给覆盖在透明区域下方的view1,如果是滑动,则走默认系统dispatch。这样很简洁,也避免了处理手势滑动和抛动的时候view1和view2的衔接问题。
2020-03-19 01:00
回复
没有更多了
发现更多内容

低成本、快速造测试数据,这个造数工具我后悔推荐晚了!

Liam

测试 Postman 自动化测试 测试工具 测试自动化

24小时智能洗车机多少钱一台

共享电单车厂家

自助洗车机价格 24小时智能洗车机 智能洗车机多少钱

电子版产品手册如何制作?简单的方法来了

小炮

产品宣传手册

共建开源组件生态 2022 OpenHarmony组件大赛等你来

科技汇

渗透测试面试问题,内含大量渗透技巧

喀拉峻

网络安全 安全 渗透测试

自助洗车怎么加盟?加盟流程介绍

共享电单车厂家

自助洗车加盟 自助洗车怎么加盟 自助洗车加盟流程

从社会学角度解读机器学习

Taylor

机器学习 深度学习 学习方法 损失函数 梯度下降

如何在众筹中充分利用区块链技术?

CECBC

这两个实用的导航网站,推荐给你!

小炮

导航网站

驱动现代金融发展的“元宇宙路径”

CECBC

渗透测试信息收集之子域名收集总结

网络安全学海

网络安全 信息安全 渗透测试 WEB安全 漏洞挖掘

数字经济多项技术突围 元宇宙被赋予更多想象

CECBC

FastDFS 海量小文件存储解决之道

vivo互联网技术

fastdfs 数据存储 分布式,

小程序生态成为私域基建必选项

Geek-peri

24小时无人自助洗车设备多少钱

共享电单车厂家

自助洗车机价格 24小时无人自助洗车 自助洗车设备多少钱

自助共享洗车加盟都有什么条件

共享电单车厂家

自助洗车加盟条件 自助共享洗车加盟

国产GPU芯片概述

Finovy Cloud

人工智能 GPU服务器 GPU算力

这个导航网站,是设计师福音!

小炮

导航网站

智能家居新浪潮 物联网潜力无限

Geek-peri

小程序 物联网 智能家居

云原生环境下的日志采集、存储、分析实践

火山引擎开发者社区

云原生 日志

物联网+车载小程序进入发展快车道

Geek-peri

小程序 车联网 物联网

啃完阿里工程师的Java面试八股文,斩获腾讯等6家大厂offer!

Java架构追梦

Java 后端开发 Java八股文

实时云渲染有哪些优势?

3DCAT实时渲染

实时云渲染

暴打力扣:王者级《数据结构与算法笔记》,一路绿灯进字节Java岗

Java架构追梦

Java 算法 java面试 后端开发

Pipy MQTT 代理之(四)安全性

Flomesh

mqtt Proxy Pipy

EMQ 云边协同解决方案在智慧工厂建设中的应用

EMQ映云科技

物联网 IoT 智慧工厂 边云协同 emq

iOS开发面试-如何打破30岁的中年危机

iOSer

ios iOS面试

猛肝《Java权威面试指南(阿里版)》,“金三银四”offer必有你的一份!

Java架构追梦

Java 程序员 java面试 后端开发

绝艺学会打麻将,腾讯AI Lab提出全新策略优化算法ACH

科技热闻

一起看看自助洗车机投放场地怎么选

共享电单车厂家

自助洗车加盟 自助洗车机投放 自助洗车场地

超全MySQL笔记整理(面试题+笔记+思维导图),面试再也不怕被MySQL难倒了

Java架构追梦

Java java面试 后端开发

Android多子视图嵌套通用解决方案_语言 & 开发_zhanghao_InfoQ精选文章