2024年鸿蒙最全LiveData 面试题库、解答、源码分析_livedata面试(1),2024年最新腾讯鸿蒙面试题

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!


img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上鸿蒙开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

需要这份系统化的资料的朋友,可以戳这里获取

// 判断至少到达了某生命周期状态
public boolean isAtLeast(State state) {
return compareTo(state) >= 0;
}
}

Lifecycle 即是生命周期对应的类,提供了添加/移除生命周期观察者的方法,在其内部还定义了全部生命周期的状态及对应事件。

生命周期状态是有先后次序的,分别对应着由小到大的 int 值。

生命周期拥有者

描述生命周期的对象已经有了,如何获取这个对象需要个统一的接口(不然直接在 Activity 或者 Fragment 中新增一个方法吗?),这个接口叫LifecycleOwner

public interface LifecycleOwner {
Lifecycle getLifecycle();
}

Activity 和 Fragment 都实现了这个接口。

只要拿到 LifecycleOwner,就能拿到 Lifecycle,然后就能注册生命周期观察者。

生命周期 & 数据观察者

生命周期观察者是一个接口:

// 生命周期观察者(空接口,用于表征一个类型)
public interface LifecycleObserver {}
// 生命周期事件观察者
public interface LifecycleEventObserver extends LifecycleObserver {
void onStateChanged(LifecycleOwner source, Lifecycle.Event event);
}

要观察生命周期只要实现LifecycleEventObserver接口,并注册给LifeCycle即可。

除了生命周期观察者外,LiveData 场景中还有一个数据观察者

// 数据观察者
public interface Observer {
// 数据发生变化时回调
void onChanged(T t);
}

数据观察者 会和 生命周期拥有者 进行绑定:

public abstract class LiveData {
// 数据观察者容器
private SafeIterableMap<Observer<? super T>, ObserverWrapper> mObservers =
new SafeIterableMap<>();

public void observe(
LifecycleOwner owner, // 被绑定的生命周期拥有者
Observer<? super T> observer // 数据观察者
) {

// 将数据观察者包装成 LifecycleBoundObserver
LifecycleBoundObserver wrapper = new LifecycleBoundObserver(owner, observer);
// 存储观察者到 map 结构
ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);

// 注册生命周期观察者。
owner.getLifecycle().addObserver(wrapper);
}
}

在观察 LiveData 时,需传入两个参数,生命周期拥有者和数据观察者。这两个对象经过LifecycleBoundObserver的包装被绑定在了一起:

class LifecycleBoundObserver extends ObserverWrapper implements LifecycleEventObserver {
// 持有生命周期拥有者
final LifecycleOwner mOwner;

LifecycleBoundObserver(LifecycleOwner owner, Observer<? super T> observer) {
super(observer);
mOwner = owner;
}
// 生命周期变化回调
@Override
public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {

activeStateChanged(shouldBeActive())

}
}

// 观察者包装类型
private abstract class ObserverWrapper {
// 持有原始数据观察者
final Observer<? super T> mObserver;
// 注入数据观察者
ObserverWrapper(Observer<? super T> observer) {mObserver = observer;}
// 尝试将最新值分发给当前数据观察者
void activeStateChanged(boolean newActive) {…}

}

LifecycleBoundObserver 实现了LifecycleEventObserver接口,并且它被注册给了绑定的生命周期对象,遂具备了生命周期感知能力。同时它还持有了数据观察者,所以它还具备了数据观察能力。

2. LiveData 是如何避免内存泄漏的?

先总结,再分析:

  • LiveData 的数据观察者通常是匿名内部类,它持有界面的引用,可能造成内存泄漏。
  • LiveData 内部会将数据观察者进行封装,使其具备生命周期感知能力。当生命周期状态为 DESTROYED 时,自动移除观察者。

内存泄漏是因为长生命周期的对象持有了短生命周期对象,阻碍了其被回收。

观察 LiveData 数据的代码通常这样写:

class LiveDataActivity : AppCompatActivity() {
private val viewModel by lazy {
ViewModelProviders.of(this@LiveDataActivity).get(MyViewModel::class.java)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.livedata.observe(this@LiveDataActivity) {
// 观察 LiveData 数据更新(匿名内部类)
}
}
}

Observer 作为界面的匿名内部类,它会持有界面的引用,同时 Observer 被 LiveData 持有,LivData 被 ViewModel 持有,而 ViewModel 的生命周期比 Activity 长。

最终的持有链如下:NonConfigurationInstances 持有 ViewModelStore 持有 ViewModel 持有 LiveData 持有 Observer 持有 Activity。

所以得在界面生命周期结束的时候移除 Observer,这件事情,LiveData 帮我们做了。

在 LiveData 内部 Observer 会被包装成LifecycleBoundObserver

class LifecycleBoundObserver extends ObserverWrapper
implements LifecycleEventObserver {
final LifecycleOwner mOwner;

LifecycleBoundObserver(LifecycleOwner owner, Observer<? super T> observer) {
super(observer);
mOwner = owner;
}

@Override
public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {
// 获取当前生命周期
Lifecycle.State currentState = mOwner.getLifecycle().getCurrentState();
// 若生命周期为 DESTROYED 则移除数据观察者并返回
if (currentState == DESTROYED) {
removeObserver(mObserver);
return
}

}

}

3. LiveData 是粘性的吗?若是,它是怎么做到的?

先总结,再分析:

  • LiveData 的值被存储在内部的字段中,直到有更新的值覆盖,所以值是持久的。
  • 两种场景下 LiveData 会将存储的值分发给观察者。一是值被更新,此时会遍历所有观察者并分发之。二是新增观察者或观察者生命周期发生变化(至少为 STARTED),此时只会给单个观察者分发值。
  • LiveData 的观察者会维护一个“值的版本号”,用于判断上次分发的值是否是最新值。该值的初始值是-1,每次更新 LiveData 值都会让版本号自增。
  • LiveData 并不会无条件地将值分发给观察者,在分发之前会经历三道坎:1. 数据观察者是否活跃。2. 数据观察者绑定的生命周期组件是否活跃。3. 数据观察者的版本号是否是最新的。
  • “新观察者”被“老值”通知的现象叫“粘性”。因为新观察者的版本号总是小于最新版号,且添加观察者时会触发一次老值的分发。

如果把 sticky 翻译成“持久的”,会更好理解一些。数据是持久的,意味着它不是转瞬即逝的,不会因为被消费了就不见了,它会一直在那。而且当新的观察者被注册时,持久的数据会将最新的值分发给它。

“持久的数据”是怎么做到的?

显然是被存起来了。以更新 LiveData 数据的方法为切入点找找线索:

public abstract class LiveData {
// 存储数据的字段
private volatile Object mData;
// 值版本号
private int mVersion;
// 更新值
protected void setValue(T value) {
assertMainThread(“setValue”);
// 版本号自增
mVersion++;
// 存储值
mData = value;
// 分发值
dispatchingValue(null);
}
}

setValue() 是更新 LiveData 值时必然会调用的一个方法,即使是通过 postValue() 更新值,最终也会走这个方法。

LiveData 持有一个版本号字段,用于标识“值的版本”,就像软件版本号一样,这个数字用于判断“当前值是否是最新的”,若版本号小于最新版本号,则表示当前值需要更新。

LiveData 用一个 Object 字段mData存储了“值”。所以这个值会一直存在,直到被更新的值覆盖。

LiveData 分发值即是通知数据观察者:

public abstract class LiveData {
// 用键值对方式持有一组数据观察者
private SafeIterableMap<Observer<? super T>, ObserverWrapper> mObservers =
new SafeIterableMap<>();
void dispatchingValue(ObserverWrapper initiator) {

// 指定分发给单个数据观察者
if (initiator != null) {
considerNotify(initiator);
initiator = null;
}
// 遍历所有数据观察者分发值
else {
for (Iterator<Map.Entry<Observer<? super T>, ObserverWrapper>> iterator =
mObservers.iteratorWithAdditions(); iterator.hasNext(); ) {
considerNotify(iterator.next().getValue());
}
}

}

// 真正地分发值
private void considerNotify(ObserverWrapper observer) {
// 1. 若观察者不活跃则不分发给它
if (!observer.mActive) {
return;
}
// 2. 根据观察者绑定的生命周期再次判断它是否活跃,若不活跃则不分发给它
if (!observer.shouldBeActive()) {
observer.activeStateChanged(false);
return;
}
// 3. 若值已经是最新版本,则不分发
if (observer.mLastVersion >= mVersion) {
return;
}
// 更新观察者的最新版本号
observer.mLastVersion = mVersion;
// 真正地通知观察者
observer.mObserver.onChanged((T) mData);
}

}

分发值有两种情况:“分发给单个观察者”和“分发给所有观察者”。当 LiveData 值更新时,需分发给所有观察者。

所有的观察者被存在一个 Map 结构中,分发的方式是通过遍历 Map 并逐个调用considerNotify()。在这个方法中需要跨过三道坎,才能真正地将值分发给数据观察者,分别是:

  1. 数据观察者是否活跃。
  2. 数据观察者绑定的生命周期组件是否活跃。
  3. 数据观察者的版本号是否是最新的。

跨过三道坎后,会将最新的版本号存储在观察者的 mLastVersion 字段中,即版本号除了保存在LiveData.mVersion,还会在每个观察者中保存一个副本mLastVersion,最后才将之前暂存的mData的值分发给数据观察者。

每个数据观察者都和一个组件的生命周期对象绑定(见第一节),当组件生命周期发生变化时,会尝试将最新值分发给该数据观察者。

每一个数据观察者都会被包装(见第一节),包装类型为ObserverWrapper

// 原始数据观察者
public interface Observer {
void onChanged(T t);
}

// 观察者包装类型
private abstract class ObserverWrapper {
// 持有原始数据观察者
final Observer<? super T> mObserver;
// 当前观察者是否活跃
boolean mActive;
// 当前观察者最新值版本号,初始值为 -1
int mLastVersion = START_VERSION;
// 注入原始观察者
ObserverWrapper(Observer<? super T> observer) {mObserver = observer;}
// 当数据观察者绑定的组件生命周期变化时,尝试将最新值分发给当前观察者
void activeStateChanged(boolean newActive) {
// 若观察者活跃状态未变,则不分发值
if (newActive == mActive) {
return;
}
// 更新活跃状态
mActive = newActive;
// 若活跃,则将最新值分发给当前观察者
if (mActive) {
dispatchingValue(this);
}
}
// 是否活跃,供子类重写
abstract boolean shouldBeActive();
}

观察者的包装类型通过组合的方式持有了一个原始观察者,并在此基础上为其扩展了活跃状态和版本号的概念。

观察者包装类型是抽象的,是否活跃由子类定义:

class LifecycleBoundObserver extends ObserverWrapper implements LifecycleEventObserver {
final LifecycleOwner mOwner;

LifecycleBoundObserver(LifecycleOwner owner, Observer<? super T> observer) {
super(observer);
mOwner = owner;
}

// 当与观察者绑定的生命周期组件至少为STARTED时,表示观察者活跃
@Override
boolean shouldBeActive() {
return mOwner.getLifecycle().getCurrentState().isAtLeast(STARTED);
}

@Override
public void onStateChanged( LifecycleOwner source, Lifecycle.Event event) {
Lifecycle.State currentState = mOwner.getLifecycle().getCurrentState();
// 当生命周期状态发生变化,则尝试将最新值分发给数据观察者
while (prevState != currentState) {
prevState = currentState;
// 调用父类方法,进行分发
activeStateChanged(shouldBeActive());
currentState = mOwner.getLifecycle().getCurrentState();
}
}
}

总结一下,LiveData 有两次机会通知观察者,与之对应的有两种分发值的方式:

  1. 当值更新时,遍历所有观察者将最新值分发给它们。
  2. 当与观察者绑定组件的生命周期发生变化时,将最新的值分发给指定观察者。

假设这样一种场景:LiveData 的值被更新了一次,随后它被添加了一个新的数据观察者,与之绑定组件的生命周期也正好发生了变化(变化到RESUMED),即数据更新在添加观察者之前,此时更新值会被分发到新的观察者吗?

会!首先,更新值会被存储在 mData 字段中。

其次,在添加观察者时会触发一次生命周期变化:

// androidx.lifecycle.LifecycleRegistry
public void addObserver(@NonNull LifecycleObserver observer) {
State initialState = mState == DESTROYED ? DESTROYED : INITIALIZED;
ObserverWithState statefulObserver = new ObserverWithState(observer, initialState);

// 将生命周期事件分发给新进的观察者
statefulObserver.dispatchEvent(lifecycleOwner, upEvent(statefulObserver.mState));

}

// LifecycleBoundObserver 又被包了一层
static class ObserverWithState {
State mState;
GenericLifecycleObserver mLifecycleObserver;

ObserverWithState(LifecycleObserver observer, State initialState) {
mLifecycleObserver = Lifecycling.getCallback(observer);
mState = initialState;
}

void dispatchEvent(LifecycleOwner owner, Event event) {
State newState = getStateAfter(event);
mState = min(mState, newState);
// 分发生命周期事件给 LifecycleBoundObserver
mLifecycleObserver.onStateChanged(owner, event);
mState = newState;
}
}

最后,这次尝试必然能跨过三道坎,因为新建观察者版本号总是小于 LiveData 的版本号(-1 < 0,LiveData.mVersion 经过一次值更新后自增为0)。

这种“新观察者”会被“老值”通知的现象称为粘性

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

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

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

class MyViewModel:ViewModel() {
// 商品列表
val selectsListLiveData = MutableLiveData<List>()
// 更新商品列表
fun setSelectsList(goods:List){
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 方式声明性地构建了布局。

购物车页面如下:

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(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>>()
// 更新已选物品
fun setSelectsList(goods:List){
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 extends MutableLiveData {
// 标志位,用于表达值是否被消费
private final AtomicBoolean mPending = new AtomicBoolean(false);

public void observe(LifecycleOwner owner, final Observer observer) {
// 中间观察者
super.observe(owner, new Observer() {
@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>()
// 暂态购物车列表 LiveData
val singleListLiveData = SingleLiveEvent<List>()
// 更新购物车列表,同时更新暂态和非暂态
fun setSelectsList(goods: List) {
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 : MediatorLiveData() {
// 持有多个中间观察者
private val observers = ArraySet<ObserverWrapper>()

@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer) {
observers.find { it.observer === observer }?.let { _ ->
return
}
// 构建中间观察者
val wrapper = ObserverWrapper(observer)
observers.add(wrapper)
super.observe(owner, wrapper)
}

@MainThread
override fun observeForever(observer: Observer) {
observers.find { it.observer === observer }?.let { _ ->
return
}
val wrapper = ObserverWrapper(observer)
observers.add(wrapper)
super.observeForever(wrapper)
}

img
img

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

apper>()

@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer) {
observers.find { it.observer === observer }?.let { _ ->
return
}
// 构建中间观察者
val wrapper = ObserverWrapper(observer)
observers.add(wrapper)
super.observe(owner, wrapper)
}

@MainThread
override fun observeForever(observer: Observer) {
observers.find { it.observer === observer }?.let { _ ->
return
}
val wrapper = ObserverWrapper(observer)
observers.add(wrapper)
super.observeForever(wrapper)
}

[外链图片转存中…(img-tR5yjuQS-1715493512696)]
[外链图片转存中…(img-jJkdAV9G-1715493512697)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 5
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值