LiveData 面试题库、解答、源码分析(下)

简介: LiveData 面试题库、解答、源码分析

4. 粘性的 LiveData 会造成什么问题?怎么解决?


购物车-结算场景:假设有一个购物车界面,点击结算后跳转到结算界面,结算界面可以回退到购物车界面。这两个界面都是 Fragment。


结算界面和购物车界面通过共享ViewModel的方式共享商品列表:


class MyViewModel:ViewModel() {
    // 商品列表
    val selectsListLiveData = MutableLiveData<List<String>>()
    // 更新商品列表
    fun setSelectsList(goods:List<String>){
       selectsListLiveData.value = goods
    }
}


下面是俩 Fragment 界面依托的 Activity


class StickyLiveDataActivity : AppCompatActivity() {
    // 用 DSL 构建视图
    private val contentView by lazy {
        ConstraintLayout {
            layout_id = "container"
            layout_width = match_parent
            layout_height = match_parent
        }
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(contentView)
        // 加载购物车界面
        supportFragmentManager.beginTransaction()
            .add("container".toLayoutId(), TrolleyFragment())
            .commit()
    }
}


其中使用了 DSL 方式声明性地构建了布局,详细介绍可以点击Android性能优化 | 把构建布局用时缩短 20 倍(下)


购物车页面如下:


class TrolleyFragment : Fragment() {
    // 获取与宿主 Activity 绑定的 ViewModel
    private val myViewModel by lazy { 
        ViewModelProvider(requireActivity()).get(MyViewModel::class.java) 
    }
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent
            // 向购物车添加两件商品
            onClick = {
                myViewModel.setSelectsList(listOf("meet","water"))
            }
            TextView {
                layout_id = "balance"
                layout_width = wrap_content
                layout_height = wrap_content
                text = "balance"
                gravity = gravity_center
                // 跳转结算页面
                onClick = {
                    parentFragmentManager.beginTransaction()
                        .replace("container".toLayoutId(), BalanceFragment())
                        .addToBackStack("trolley")
                        .commit()
                }
            }
        }
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 观察商品列表变化
        myViewModel.selectsListLiveData.observe(viewLifecycleOwner) { goods ->
            // 若商品列表超过2件商品,则 toast 提示已满
            goods.takeIf { it.size >= 2 }?.let {
                Toast.makeText(context,"购物车已满",Toast.LENGTH_LONG).show()
            }
        }
    }
}


在 onViewCreated() 中观察购物车的变化,如果购物车超过 2 件商品,则 toast 提示。

下面是结算页面:


class BalanceFragment:Fragment() {
    private val myViewModel by lazy { 
        ViewModelProvider(requireActivity()).get(MyViewModel::class.java) 
    }
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent
        }
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 结算界面获取购物列表的方式也是观察商品 LiveData
        myViewModel.selectsListLiveData.observe(viewLifecycleOwner) {...}
    }
}


跑一下 demo,当跳转到结算界面后,点击返回购物车,toast 会再次提示购物车已满。

因为在跳转结算页面之前,购物车列表 LiveData 已经被更新过。当购物车页面重新展示时,onViewCreated()会再次执行,这样一个新观察者被添加,因为 LiveData 是粘性的,所以上一次购物车列表会分发给新观察者,这样 toast 逻辑再一次被执行。


解决方案一:带消费记录的值


// 一次性值
open class OneShotValue<out T>(private val value: T) {
    // 值是否被消费
    private var handled = false
    // 获取值,如果值未被处理则返回,否则返回空
    fun getValue(): T? {
        return if (handled) {
            null
        } else {
            handled = true
            value
        }
    }
    // 获取上次被处理的值
    fun peekValue(): T = value
}


在值的外面套一层,新增一个标记位标识是否被处理过。


用这个方法重构下 ViewModel:


class MyViewModel:ViewModel() {
    // 已选物品列表
    val selectsListLiveData = MutableLiveData<OneShotValue<List<String>>>()
    // 更新已选物品
    fun setSelectsList(goods:List<String>){
       selectsListLiveData.value = OneShotValue(goods)
    }
}


观察购物车的逻辑也要做修改:


class TrolleyFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        myViewModel.selectsListLiveData.observe(viewLifecycleOwner) { goods ->
            goods.getValue()?.takeIf { it.size >= 2 }?.let {
                Toast.makeText(context,"购物车满了",Toast.LENGTH_LONG).show()
            }
        }
    }
}


重复弹 toast 的问题是解决了,但引出了一个新的问题:当购物车满弹出 toast 时,购物车列表已经被消费掉了,导致结算界面就无法再消费了。


这时候只能用peekValue()来获取已经被消费的值:


class BalanceFragment:Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        myViewModel.selectsListLiveData.observe(viewLifecycleOwner) {
            val list = it.peekValue()// 使用 peekValue() 获取购物车列表
        }
    }
}


bug 全解完了。但不觉得这样处理有一些拧巴吗?


用“一次性值”封装 LiveData 的值,以去除其粘性。使用该方案得甄别出哪些观察者需要粘性值,哪些观察者需要非粘性事件。当观察者很多的时候,就很难招架了。若把需要粘性处理和非粘性处理的逻辑写在一个观察者中,就 GG,还得新建观察者将它们分开。


解决方案二:带有最新版本号的观察者


通知观察者前需要跨过三道坎(详见第三节),其中有一道坎是版本号的比对。若新建的观察者版本号小于最新版本号,则表示观察者落后了,需要将最新值分发给它。


LiveData 源码中,新建观察者的版本号总是 -1。


// 观察者包装类型
private abstract class ObserverWrapper {
    // 当前观察者最新值版本号,初始值为 -1
    int mLastVersion = START_VERSION;
    ...
}


若能够让新建观察者的版本号被最新版本号赋值,那版本号对比的那道坎就过不了,新值就无法分发到新建观察者。


所以得通过反射修改 mLastVersion 字段。


该方案除了倾入性强之外,把 LiveData 粘性彻底破坏了。但有的时候,我们还是想利用粘性的。。。


解决方案三:SingleLiveEvent


这是谷歌给出的一个解决方案,源码可以点击这里


public class SingleLiveEvent<T> extends MutableLiveData<T> {
    // 标志位,用于表达值是否被消费
    private final AtomicBoolean mPending = new AtomicBoolean(false);
    public void observe(LifecycleOwner owner, final Observer<T> observer) {
        // 中间观察者
        super.observe(owner, new Observer<T>() {
            @Override
            public void onChanged(@Nullable T t) {
                // 只有当值未被消费过时,才通知下游观察者
                if (mPending.compareAndSet(true, false)) {
                    observer.onChanged(t);
                }
            }
        });
    }
    public void setValue(@Nullable T t) {
        // 当值更新时,置标志位为 true
        mPending.set(true);
        super.setValue(t);
    }
    public void call() {
        setValue(null);
    }
}


专门设立一个 LiveData,它不具备粘性。它通过新增的“中间观察者”,拦截上游数据变化,然后在转发给下游。拦截之后通常可以做一点手脚,比如增加一个标记位mPending是否消费过的判断,若消费过则不转发给下游。


在数据驱动的 App 界面下,存在两种值:1. 非暂态数据 2. 暂态数据


demo 中用于提示“购物车已满”的数据就是“暂态数据”,这种数据是一次性的,转瞬即逝的,可以消费一次就扔掉。


demo 中购物车中的商品列表就是“非暂态数据”,它的生命周期要比暂态数据长一点,在购物车界面和结算界面存活的期间都应该能被重复消费。


SingleLiveEvent 的设计正是基于对数据的这种分类方法,即暂态数据使用 SingleLiveEvent,非暂态数据使用常规的 LiveData。


这样尘归尘土归土的解决方案是符合现实情况的。将 demo 改造一下:


class MyViewModel : ViewModel() {
    // 非暂态购物车列表 LiveData
    val selectsListLiveData = MutableLiveData<List<String>>()
    // 暂态购物车列表 LiveData
    val singleListLiveData = SingleLiveEvent<List<String>>()
    // 更新购物车列表,同时更新暂态和非暂态
    fun setSelectsList(goods: List<String>) {
        selectsListLiveData.value = goods
        singleListLiveData.value = goods
    }
}


在购物车界面做相应的改动:


class TrolleyFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 只观察非暂态购物车列表
        myViewModel.singleListLiveData.observe(viewLifecycleOwner) { goods ->
            goods.takeIf { it.size >= 2 }?.let {
                Toast.makeText(context,"full",Toast.LENGTH_LONG).show()
            }
        }
    }
}


但该方案有局限性,若为 SingleLiveEvent 添加多个观察者,则当第一个观察者消费了数据后,其他观察者就没机会消费了。因为mPending是所有观察者共享的。


解决方案也很简单,为每个中间观察者都持有是否消费过数据的标记位:


open class LiveEvent<T> : MediatorLiveData<T>() {
    // 持有多个中间观察者
    private val observers = ArraySet<ObserverWrapper<in T>>()
    @MainThread
    override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
        observers.find { it.observer === observer }?.let { _ ->
            return
        }
        // 构建中间观察者
        val wrapper = ObserverWrapper(observer)
        observers.add(wrapper)
        super.observe(owner, wrapper)
    }
    @MainThread
    override fun observeForever(observer: Observer<in T>) {
        observers.find { it.observer === observer }?.let { _ ->
            return
        }
        val wrapper = ObserverWrapper(observer)
        observers.add(wrapper)
        super.observeForever(wrapper)
    }
    @MainThread
    override fun removeObserver(observer: Observer<in T>) {
        if (observer is ObserverWrapper && observers.remove(observer)) {
            super.removeObserver(observer)
            return
        }
        val iterator = observers.iterator()
        while (iterator.hasNext()) {
            val wrapper = iterator.next()
            if (wrapper.observer == observer) {
                iterator.remove()
                super.removeObserver(wrapper)
                break
            }
        }
    }
    @MainThread
    override fun setValue(t: T?) {
        // 通知所有中间观察者,有新数据
        observers.forEach { it.newValue() }
        super.setValue(t)
    }
    // 中间观察者
    private class ObserverWrapper<T>(val observer: Observer<T>) : Observer<T> {
        // 标记当前观察者是否消费了数据
        private var pending = false
        override fun onChanged(t: T?) {
            // 保证只向下游观察者分发一次数据
            if (pending) {
                pending = false
                observer.onChanged(t)
            }
        }
        fun newValue() {
            pending = true
        }
    }
}


解决方案四:Kotlin Flow


限于篇幅原因及主题的原因(主题是 LiveData),直接给出代码(当前做法有问题),关于 LiveData vs Flow 的详细分析可以点击如何把业务代码越写越复杂?(二)| Flow 替换 LiveData 重构数据链路,更加 MVI


class MyViewModel : ViewModel() {
    // 商品列表流
    val selectsListFlow = MutableSharedFlow<List<String>>()
    // 更新商品列表
    fun setSelectsList(goods: List<String>) {
        viewModelScope.launch {
            selectsListFlow.emit(goods)
        }
    }
}


购物车代码如下:


class TrolleyFragment : Fragment() {
    private val myViewModel by lazy { 
        ViewModelProvider(requireActivity()).get(MyViewModel::class.java) 
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 1.先产生数据
        myViewModel.setSelectsList(listOf("food_meet", "food_water", "book_1"))
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 2.再订阅商品列表流
        lifecycleScope.launch {
            myViewModel.selectsListFlow.collect { goods ->
                goods.takeIf { it.size >= 2 }?.let {
                    Log.v("ttaylor", "购物车满")
                }
            }
        }
    }
}


数据生产在订阅之前,订阅后并不会打印 log。


如果这样修改 SharedFlow 的构建参数,则可以让其变得粘性:


class MyViewModel : ViewModel() {
    val selectsListFlow = MutableSharedFlow<List<String>>(replay = 1)
}


replay = 1 表示会将最新的那个数据通知给新进的订阅者。


这只是解决了粘性/非粘性之间方便切换的问题,并未解决仍需多个流的问题。带下一篇继续深入分析。


5. 什么情况下 LiveData 会丢失数据?


先总结,再分析:


在高频数据更新的场景下使用 LiveData.postValue() 时,会造成数据丢失。因为“设值”和“分发值”是分开执行的,之间存在延迟。值先被缓存在变量中,再向主线程抛一个分发值的任务。若在这延迟之间再一次调用 postValue(),则变量中缓存的值被更新,之前的值在没有被分发之前就被擦除了。


下面是 LiveData.postValue() 的源码:


public abstract class LiveData<T> {
    // 暂存值字段
    volatile Object mPendingData = NOT_SET;
    private final Runnable mPostValueRunnable = new Runnable() {
        @Override
        public void run() {
            Object newValue;
            synchronized (mDataLock) {
                // 同步地获取暂存值
                newValue = mPendingData;
                mPendingData = NOT_SET;
            }
            // 分发值
            setValue((T) newValue);
        }
    };
    protected void postValue(T value) {
        boolean postTask;
        synchronized (mDataLock) {
            postTask = mPendingData == NOT_SET;
            // 暂存值
            mPendingData = value;
        }
        ...
        // 向主线程抛 runnable
        ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable);
    }
}


6. 在 Fragment 中使用 LiveData 需注意些什么?


先总结,再分析:


在 Fragment 中观察 LiveData 时使用viewLifecycleOwner而不是this。因为 Fragment 和 其中的 View 生命周期不完全一致。LiveData 内部判定生命周期为 DESTROYED 时,才会移除数据观察者。存在一种情况,当 Fragment 之间切换时,被替换的 Fragment 不执行 onDestroy(),当它再次展示时会再次订阅 LiveData,于是乎就多出一个订阅者。


还是购物-结算的场景:购物车和结算页都是两个 Fragment,将商品列表存在共享 ViewMode 的 LiveData 中,购物车及结算页都观察它,结算页除了用它列出购物清单之外,还可以通过更改商品数量来修改 LiveData。当从结算页返回购物车页面时,购物车界面得刷新商品数量。


上述场景,若购物车页面观察 LiveData 时使用this会发生什么?


// 购物车界面
class TrolleyFragment : Fragment() {
    private val myViewModel by lazy { 
        ViewModelProvider(requireActivity()).get(MyViewModel::class.java) 
    }
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent
            onClick = {
                parentFragmentManager.beginTransaction()
                    .replace("container".toLayoutId(), BalanceFragment())
                    .addToBackStack("trolley")// 将购物车页面添加到 back stack
                    .commit()
            }
        }
    }
    // 不得不增加这个注释,因为 this 会飘红
    @SuppressLint("FragmentLiveDataObserve")
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 将 this 作为生命周期拥有者传给 LiveData
        myViewModel.selectsListLiveData.observe(this, object : Observer<List<String>> {
            override fun onChanged(t: List<String>?) {
                Log.v("ttaylor", "商品数量发生变化")
            }
        })
    }
}


这样写this会飘红,AndroidStudio 不推荐使用它作为生命周期拥有者,不得不加 @SuppressLint("FragmentLiveDataObserve")


结算界面修改商品数量的代码如下:


// 结算界面
class BalanceFragment:Fragment() {
    private val myViewModel by lazy { 
        ViewModelProvider(requireActivity()).get(MyViewModel::class.java) 
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 模拟结算界面修改商品数量
        myViewModel.selectsListLiveData.value = listOf("数量+1")
    }
}


当从结算页返回购物车时,“商品数量发生变化” 会打印两次,如果再进一次结算页并返回购物车,就会打印三次。


若换成viewLifecycleOwner就不会有这个烦恼。因为使用 replace 更换 Fragment 时,Fragment.onDestroyView()会执行,即 Fragment 对应 View 的生命周期状态会变为 DESTROYED。


LiveData 内部会将生命周期为 DESTROYED 的数据观察者移除(详见第二节)。当再次返回购物车时,onViewCreated() 重新执行,LiveData 会添加一个新的观察者。一删一增,整个过程 LiveData 始终只有一个观察者。又因为 LiveData 是粘性的,即使修改商品数量发生在观察之前,最新的商品数量还是会被分发到新观察者。(详见第三节)

但当使用 replace 更换 Fragment 并将其压入 back stack 时,

Fragment.onDestroy() 不会调用(因为被压栈了,并未被销毁)。这导致 Fragment 的生命周期状态不会变为 DESTROYED,所以 LiveData 的观察者不会被自动移除。当重新返回购物车时,又添加了新的观察者。如果不停地在购物车和结算页间横跳,则观察者数据会不停地增加。


在写 demo 的时候遇到一个坑:


// 购物车界面
class TrolleyFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // 故意使用 object 语法
        myViewModel.selectsListLiveData.observe(this, object : Observer<List<String>> {
            override fun onChanged(t: List<String>?) {
                Log.v("ttaylor", "商品数量发生变化")
            }
        })
    }
}


在构建 Observer 实例的时候,我特意使用了 Kotlin 的 object 语法,其实明明可以使用 lambda 将其写得更简洁:


class TrolleyFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        myViewModel.selectsListLiveData.observe(this) {
            Log.v("ttaylor", "商品数量发生变化")
        }
    }
}


如果这样写,那 bug 就无法复现了。。。。


因为 java 编译器会擅作主张地将同样的 lambda 优化成静态的,可以提升性能,不用每次都重新构建内部类。但不巧的是 LiveData 在添加观察者时会校验是否已存在,若存在则直接返回:


// `androidx.lifecycle.LiveData
public void observe( LifecycleOwner owner,  Observer<? super T> observer) {
    ...
    LifecycleBoundObserver wrapper = new LifecycleBoundObserver(owner, observer);
    // 调用 map 结构的写操作,若 key 已存在,则返回对应 value
    ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);
    ...
    // 已存在则直接返回
    if (existing != null) {
        return;
    }
    owner.getLifecycle().addObserver(wrapper);
}


这样的话,Fragment 界面之间反复横跳也不会新增观察者。


7. 如何变换 LiveData 数据及注意事项?


先总结,再分析:


androidx.lifecycle.Transformations类提供了三个变换 LiveData 数据的方法,最常用的是 Transformations.map(),它使用MediatorLiveData作为数据的中间消费者,并将变换后的数据传递给最终消费者。需要注意的是,数据变化操作都发生在主线程,主线程有可能被耗时操作阻塞。解决方案是将 LiveData 数据变换操作异步化,比如通过CoroutineLiveData


还是购物-结算的场景:购物车和结算页都是两个 Fragment,将商品列表存在 LiveData 中,购物车及结算页都观察它。结算界面对打折商品有一个特殊的 UI 展示。


此时就可以将商品列表 LiveData 进行一次变换(过滤)得到一个新的打折商品列表:


class MyViewModel : ViewModel() {
    // 商品列表
    val selectsListLiveData = MutableLiveData<List<String>>()
    // 打折商品列表
    val foodListLiveData = Transformations.map(selectsListLiveData) { list ->
        list.filter { it.startsWith("discount") }
    }
}


每当商品列表发生变化,打折商品列表都会收到通知,并过滤出新的打折商品。打折商品列表是一个新的 LiveData,可以单独被观察。


其中的过滤列表操作发生在主线程,如果业务略复杂,数据变换操作耗时的话,可能阻塞主线程。


如何将 LiveData 变换数据异步化?


LiveData 的 Kotlin 扩展包里提供了一个将 LiveData 和协程结合的产物:


class MyViewModel : ViewModel() {
    // 商品列表
    val selectsListLiveData = MutableLiveData<List<String>>()
    // 用异步方式获取打折商品列表
    val asyncLiveData = selectsListLiveData.switchMap { list ->
        // 将源 LiveData 中的值转换成一个 CoroutineLiveData
        liveData(Dispatchers.Default) {
            emit( list.filter { it.startsWith("discount") } )
        }
    }
}


其中的switchMap()是 LiveData 的扩展方法,它是对Transformations.switchMap()的封装,用于方便链式调用:


public inline fun <X, Y> LiveData<X>.switchMap(
    crossinline transform: (X) -> LiveData<Y>
): LiveData<Y> = Transformations.switchMap(this) { transform(it) }


switchMap() 内部将源 LiveData 的每个值都转换成一个新的 LiveData 并订阅。


liveData是一个顶层方法,用于构建CoroutineLiveData


public fun <T> liveData(
    context: CoroutineContext = EmptyCoroutineContext,
    timeoutInMs: Long = DEFAULT_TIMEOUT,
    block: suspend LiveDataScope<T>.() -> Unit
): LiveData<T> = CoroutineLiveData(context, timeoutInMs, block)


CoroutineLiveData 将更新 LiveData 值的操作封装到一个挂起方法中,可以通过协程上下文指定执行的线程。


使用 CoroutineLiveData 需要添加如下依赖:


CoroutineLiveData 将更新 LiveData 值的操作封装到一个挂起方法中,可以通过协程上下文指定执行的线程。
使用 CoroutineLiveData 需要添加如下依赖:


参考


Jetpack MVVM 七宗罪之四: 使用 LiveData/StateFlow 发送 Events


推荐阅读


面试系列文章列表如下:


面试题 | 怎么写一个又好又快的日志库?(一)


面试题 | 怎么写一个又好又快的日志库?(二)


面试题 | 徒手写一个 ConcurrentLinkedQueue?


来讨论下 Android 面试该问什么类型的题目?


RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?


面试题 | 有用过并发容器吗?有!比如网络请求埋点


目录
相关文章
|
8月前
|
SQL Oracle 关系型数据库
mysql面试题库
mysql面试题库
|
6月前
|
JavaScript 前端开发 小程序
CoderGuide 程序员前后端面试题库,打造全网最高质量题库
CoderGuide涵盖范围包括且不限于:前端面试题(Vue,React,JS,HTTP,HTML,CSS面试题等),后端面试题(Java,Python,Golang,PHP,Linux,Mysql面试题等),以及算法面试题,大厂面试题,高频面试题,校招面试题等,你想要的,这里都有!
98 2
|
8月前
|
存储 Java
java面试题大全带答案_面试题库_java面试宝典2018
java面试题大全带答案_面试题库_java面试宝典2018
|
8月前
|
SQL 前端开发 Java
2019史上最全java面试题题库大全800题含答案(面试宝典)(4)
2019史上最全java面试题题库大全800题含答案(面试宝典)
|
8月前
|
存储 NoSQL Redis
redis面试题库
redis面试题库
|
8月前
|
SQL 关系型数据库 MySQL
sql面试题库
sql面试题库
|
8月前
|
存储 设计模式 Java
java实习生面试题_java基础面试_java面试题2018及答案_java面试题库
java实习生面试题_java基础面试_java面试题2018及答案_java面试题库
|
8月前
|
安全 算法 Java
java线程面试题_2019java面试题库
java线程面试题_2019java面试题库
|
8月前
|
前端开发 Dubbo Java
spring面试题_spring mvc面试题_springboot面试题库
spring面试题_spring mvc面试题_springboot面试题库
|
8月前
|
负载均衡 Dubbo 安全
dubbo面试题库
dubbo面试题库

热门文章

最新文章