2024年安卓最全Glide load源码、缓存机制分析,2024年最新带你全面掌握高级知识点

学习交流

群内有许多来自一线的技术大牛,也有在小厂或外包公司奋斗的码农,我们致力打造一个平等,高质量的Android交流圈子,不一定能短期就让每个人的技术突飞猛进,但从长远来说,眼光,格局,长远发展的方向才是最重要的。

35岁中年危机大多是因为被短期的利益牵着走,过早压榨掉了价值,如果能一开始就树立一个正确的长远的职业规划。35岁后的你只会比周围的人更值钱。

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

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

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

cb.onResourceReady(active, DataSource.MEMORY_CACHE);

if (VERBOSE_IS_LOGGABLE) {

logWithTimeAndKey(“Loaded resource from active resources”, startTime, key);

}

return null;

}

调用了 loadFromActiveResources(),传入了之前的生成的key。如果得到一个 EngineResource对象,则回调 onResourceReady(), onResourceReady是这个加载图片的出口。来看下 loadFromActiveResources()做了啥:

// Engine.java

private final ActiveResources activeResources;

@Nullable

private EngineResource<?> loadFromActiveResources(Key key, boolean isMemoryCacheable) {

if (!isMemoryCacheable) {

return null;

}

EngineResource<?> active = activeResources.get(key);

if (active != null) {

active.acquire();

}

return active;

}

第一个if是如果 isMemoryCacheable为false则返回null,从字面意思上可以知道这个字段的意思是 是否开启内存缓存

然后调用 EngineResource<?> active = activeResources.get(key),它是通过key去ActiveResources里面取一个 EngineResource:

// ActiveResources.java

final Map<Key, ResourceWeakReference> activeEngineResources = new HashMap<>();

@Nullable

EngineResource<?> get(Key key) {

ResourceWeakReference activeRef = activeEngineResources.get(key); // 1

if (activeRef == null) {

return null;

}

EngineResource<?> active = activeRef.get(); // 2

if (active == null) {

cleanupActiveReference(activeRef);

}

return active;

}

从注释1、2可以看到,ActiveResources维护一个HashMap,Key为 Key类,我们刚刚的EngineKey就是Key的派生类,Value为 Resources的弱引用。

也就是说,如果之前在Map中存放了对应Key的弱引用,则取出这个弱引用中的EngineResource,否则返回null。

可以看出这里实现了一个缓存,由于它是运行时才有这个Map的,所以它是一个 内存缓存,它的存放的是弱引用的图片资源,所以在内存回收时是会被立即回收的。

回到 loadFromActiveResources()中:

// Engine.java

@Nullable

private EngineResource<?> loadFromActiveResources(Key key, boolean isMemoryCacheable) {

EngineResource<?> active = activeResources.get(key);

if (active != null) {

active.acquire();

}

return active;

}

// EngineResource.java

void acquire() {

if (isRecycled) {

throw new IllegalStateException(“Cannot acquire a recycled resource”);

}

if (!Looper.getMainLooper().equals(Looper.myLooper())) {

throw new IllegalThreadStateException(“Must call acquire on the main thread”);

}

++acquired;

}

如果返回的 active不为null,则调用 active.acquire(),它的作用是给计数器 acquired自增1(在主线程和非回收状态下),这里并不知道为什么要这么做。所以就回去了。这样loadFromActiveResources()就分析完了,往下走:

//Engine.java #load

EngineResource<?> cached = loadFromCache(key, isMemoryCacheable);

if (cached != null) {

cb.onResourceReady(cached, DataSource.MEMORY_CACHE);

if (VERBOSE_IS_LOGGABLE) {

logWithTimeAndKey(“Loaded resource from cache”, startTime, key);

}

return null;

}

loadFromCache()中获取EngineResource,如果拿得到就调用 onResourceRead()返回。来看下这个方法:

// Engine.java

private EngineResource<?> loadFromCache(Key key, boolean isMemoryCacheable) {

if (!isMemoryCacheable) {

return null;

}

EngineResource<?> cached = getEngineResourceFromCache(key);

if (cached != null) {

cached.acquire();

activeResources.activate(key, cached);

}

return cached;

}

这个方法和之前的 loadFromActiveResources差别不大,先看看 getEngineResourceFromCache(key)

// Engine.java

private final MemoryCache cache;

private EngineResource<?> getEngineResourceFromCache(Key key) {

Resource<?> cached = cache.remove(key); // 1

final EngineResource<?> result;

if (cached == null) {

result = null;

} else if (cached instanceof EngineResource) {

result = (EngineResource<?>) cached;

} else {

result = new EngineResource<>(cached, true /isMemoryCacheable/, true /isRecyclable/);

}

return result;

}

这个方法就是从 MemoryCache中拿出一个EngineResource并返回。那我们要去看看 MemoryCache做了啥,但是MemoryCache是一个接口类,没有实现,在向前找的时候发现它是在创建 Glide initalizeGlide() 的过程中创建的,这里直接看创建的代码:

// GlideBuilder.java

@NonNull

Glide build(@NonNull Context context) {

if (memoryCache == null) {

memoryCache = new LruResourceCache(memorySizeCalculator.getMemoryCacheSize());

}

}

// LruResourceCache.java

public class LruResourceCache extends LruCache<Key, Resource<?>> implements MemoryCache {

}

LruResourceCache实现了MemoryCache,并继承了LruCache。

这里简单的描述一下LruCache,它是最近最少使用策略,实现原理是 accessOrder为true的 LinkedHashMap。所以在getEngineResourceFromCache的注释1中,它就是调用了 LinkedHashMap.remove(key)来看Map中是否有key对应的 EngineResources。

回到 loadFromCache中:

// Engine.java #loadFromCache

EngineResource<?> cached = getEngineResourceFromCache(key);

if (cached != null) {

cached.acquire();

activeResources.activate(key, cached);

}

如果remove到的资源是不为null的,则调用 acquire()方法,并且调用 activeResources.activate(key, cached),我们看看这个方法做了什么:

// ActiveResources.java

void activate(Key key, EngineResource<?> resource) {

ResourceWeakReference toPut =

new ResourceWeakReference(

key,

resource,

getReferenceQueue(),

isActiveResourceRetentionAllowed);

ResourceWeakReference removed = activeEngineResources.put(key, toPut);

if (removed != null) {

removed.reset();

}

}

它把 key和EngineResource作为元素 put到了 ActiveResources的 HashMap中。便于以后 loadFromActiveResources去拿。

然后最后return为null。 loadFromCache()就结束了。可以看出来 LruCache也是在运行时产生的,所以它也是内存缓存

这里出现了一个问题,这里出现了两个内存缓存,一个是HashMap缓存,一个是LruResourcesCache的缓存,在前者拿资源拿不到的情况下,去拿后者,如果后者拿到了,会把该资源放到前者中缓存。乍一看是没事找事,为什么不把所有的资源都放在一个cache下存储呢?这需要往后面的代码看。

接下来继续load()函数中:

private final Jobs jobs;

// Engine.java #load

EngineJob<?> current = jobs.get(key, onlyRetrieveFromCache);

if (current != null) {

current.addCallback(cb);

if (VERBOSE_IS_LOGGABLE) {

logWithTimeAndKey(“Added to existing load”, startTime, key);

}

return new LoadStatus(cb, current);

}

通过 Jobs去拿一个 EngineJob,如果 EngineJob不为null,则调用其 addCallback(),这个方法最终也会调用 onResourceReady(),并返回一个 LoadStatus 。先来看看 jobs的get方法:

// Jobs.java

private final Map<Key, EngineJob<?>> jobs = new HashMap<>();

private final Map<Key, EngineJob<?>> onlyCacheJobs = new HashMap<>();

EngineJob<?> get(Key key, boolean onlyRetrieveFromCache) {

return getJobMap(onlyRetrieveFromCache).get(key);

}

private Map<Key, EngineJob<?>> getJobMap(boolean onlyRetrieveFromCache) {

return onlyRetrieveFromCache ? onlyCacheJobs : jobs;

}

onlyRetrieveFromCache这个字段中文意思为:是否只从Cache中搜索。默认情况下是 false。它就会去 jobs这个HashMap中拿EngineJob。这里也是一个缓存,但他并不是之间缓存图片资源。

EngineJob它不是一个图片资源,那它是什么呢?这里还不是很清晰,先往load下面走,

// Engine.java #load

EngineJob engineJob =

engineJobFactory.build(

key,

isMemoryCacheable,

useUnlimitedSourceExecutorPool,

useAnimationPool,

onlyRetrieveFromCache);

DecodeJob decodeJob =

decodeJobFactory.build(

glideContext,

model,

key,

signature,

width,

height,

resourceClass,

transcodeClass,

priority,

diskCacheStrategy,

transformations,

isTransformationRequired,

isScaleOnlyOrNoTransform,

onlyRetrieveFromCache,

options,

engineJob);

jobs.put(key, engineJob);

engineJob.addCallback(cb);

engineJob.start(decodeJob);

这里是load方法的最后一步,在上述三种缓存都命中不到资源的情况下,会创建一个 EngineJob和一个 DecodeJob,将 key和engineJob一起绑定加入到 Jobs的HashMap,这样之后上面的jobs缓存就有资源可以找,然后调用 EngineJob.addCallbackEngine.start()

这里的重点是 start方法,它是没有内存缓存后,去做的事情,所以这里算是一个新的开始。 也就是说,load可以看成两个部分,这里开始就是第二个部分。

1.2 EngineJob的start方法解析


// EngineJob.java

private final GlideExecutor diskCacheExecutor;

private final GlideExecutor sourceExecutor;

private final GlideExecutor sourceUnlimitedExecutor;

private final GlideExecutor animationExecutor;

public void start(DecodeJob decodeJob) {

this.decodeJob = decodeJob;

GlideExecutor executor = decodeJob.willDecodeFromCache()

? diskCacheExecutor
getActiveSourceExecutor();

executor.execute(decodeJob);

}

EngineJob里面有线程池执行器 GlideExecutor,它就是一个ExecutorService,因为加载图片是一个耗时操作,所以放到子线程做,所以这里出现了 Executor,其次,在EngineJob中,exeutor执行器并不是只有一个:

它会先根据缓存策略,拿到对应executor,比如这里的 :

// EngineJob.java

GlideExecutor executor = decodeJob.willDecodeFromCache()

? diskCacheExecutor
getActiveSourceExecutor();

就会根据 “是否在Cache中解码” 这个条件来拿 disCacheExecutor,还是其他三个中的执行器。

在默认情况下, DiskCacheStategey是 AUTOMATIC的,这里就是true,即用的是 diskCacheExecutor。

接下来就会拿着这个线程执行器去执行 DecodeJob,就是调用它的 run()

// DecodeJob.java

@Override

public void run() {

DataFetcher<?> localFetcher = currentFetcher;

try {

if (isCancelled) {

notifyFailed();

return;

}

runWrapped();

} catch (Throwable t) {

} finally {

}

}

notifyFailed()是通知失败的方法,然后没取消,就调用 runWrapped()

// DecodeJob.java

private void runWrapped() {

switch (runReason) {

case INITIALIZE:

stage = getNextStage(Stage.INITIALIZE);

currentGenerator = getNextGenerator();

runGenerators();

break;

case SWITCH_TO_SOURCE_SERVICE:

runGenerators();

break;

case DECODE_DATA:

decodeFromRetrievedData();

break;

default:

throw new IllegalStateException("Unrecognized run reason: " + runReason);

}

}

这里根据 runReason分成了三种情况:

  • INITIALIZE

在第一次我们提交执行任务的状态

  • SWITCH_TO_SOURCE_SERVICE

当我们想要从 DiskCache 的做法切换到 Source执行的做法(这里我也不知道干嘛的)

  • DECODE_DATA

该状态标识着:当我们在子线程中拿到了一个我们从未拥有过的资源,这个时候我们需要切换回主线程,去让主线程得到这个资源。

当然了,上面的三个是执行 DecodeJob的原因,它强调的是原因,DecodeJob又维护了一套当前的状态,和上面产生了联系:

  • INITIALIZE

和上面初始时一样

  • RESOURCE_CACHE

根据一个 缓存资源 进行 解码(Decode)

  • DATA_CACHE

根据一个 缓存的源数据 进行 解码(Decode)

  • SOURCE

根据 获取到的源数据 进行 解码

  • ENCODE

在一个 资源成功加载后, 为了将它缓存起来,进行编码

  • FINISHED

要结束了

假设我们是第一次进入到这个方法,那我们的runReason是 INITIALIZE,接下来会调用 getNextState(Stage.INITIALIZE):

// DecodeJob.java

private Stage getNextStage(Stage current) {

switch (current) {

case INITIALIZE:

return diskCacheStrategy.decodeCachedResource()

? Stage.RESOURCE_CACHE : getNextStage(Stage.RESOURCE_CACHE);

case RESOURCE_CACHE:

return diskCacheStrategy.decodeCachedData()

? Stage.DATA_CACHE : getNextStage(Stage.DATA_CACHE);

case DATA_CACHE:

return onlyRetrieveFromCache ? Stage.FINISHED : Stage.SOURCE;

case SOURCE:

case FINISHED:

return Stage.FINISHED;

default:

throw new IllegalArgumentException("Unrecognized stage: " + current);

}

}

// 这里是默认情况下的 DiskCacheStaratey(磁盘)的缓存模式:

// DiskCacheStrategy。java

public static final DiskCacheStrategy AUTOMATIC = new DiskCacheStrategy() {

@Override

public boolean isDataCacheable(DataSource dataSource) {

return dataSource == DataSource.REMOTE;

}

@Override

public boolean isResourceCacheable(boolean isFromAlternateCacheKey, DataSource dataSource,

EncodeStrategy encodeStrategy) {

return ((isFromAlternateCacheKey && dataSource == DataSource.DATA_DISK_CACHE)

|| dataSource == DataSource.LOCAL)

&& encodeStrategy == EncodeStrategy.TRANSFORMED;

}

@Override

public boolean decodeCachedResource() {

return true;

}

@Override

public boolean decodeCachedData() {

return true;

}

};

在 INITIALIZE状态下调用了 getNextStage(),state就变成了 Stage.RESOURCE_CACHE,接下来在 runWrapped中:

调用了 currentGenerator = getNextGenerator()

private DataFetcherGenerator getNextGenerator() {

switch (stage) {

case RESOURCE_CACHE:

return new ResourceCacheGenerator(decodeHelper, this);

case DATA_CACHE:

return new DataCacheGenerator(decodeHelper, this);

case SOURCE:

return new SourceGenerator(decodeHelper, this);

case FINISHED:

return null;

default:

throw new IllegalStateException("Unrecognized stage: " + stage);

}

}

返回了一个 ResourceCacheGenerator,它现在是做什么的还不知道。

继续往下走 ,会调用 runGenerators()

// DecodeJob.java

private void runGenerators() {

currentThread = Thread.currentThread();

startFetchTime = LogTime.getLogTime();

boolean isStarted = false;

while (!isCancelled && currentGenerator != null

&& !(isStarted = currentGenerator.startNext())) { // 1

stage = getNextStage(stage); // 2

currentGenerator = getNextGenerator(); // 3

if (stage == Stage.SOURCE) {

reschedule(); // 4

return;

}

}

if ((stage == Stage.FINISHED || isCancelled) && !isStarted) {

notifyFailed();

}

}

这个方法会开启一个 while循环,判断条件中第三个较为关键:调用 currentGenerator.startNext()

如果这个方法的结果为false,则 stage进入到下一个状态, currentGenerator换成下一个执行器。

在当前状态为 Stage.SOURCE时,调用了 reschedule(),然后return,也就是说 SOURCE为当前decodeJob执行的最后一个状态。

如果要一直到最后 SOURCE,那么当前的DecodeJob会走这么个顺序:

(1)DecodeJob的状态:

RESOURCE_CACHE -> DATA_CACHE -> SOURCE

(2)currentGenrator的变化:

ResourceCacheGenerator -> DataCacheGenerator -> SourceGenerator

(3) startNext的执行

ResourceCacheGenerator.startNext() = false -> DataCacheGenerator .startNext() = false

这就是在 startNext()一直为false的情况下。 我们现在就去研究一下这个方法,由于 Generator是一个发生器,在这里分成了两个,所以startNext()有必要拆出来讲解。

startNext()这个方法有点长,并且借助了一个新的类 DecodeHelper的辅助,这个类主要做了组件注册,用来扩展或替换Glide的默认加载,还有负责解码、编码的Registry类。而且我们其实还并不是很清楚 ResourceCacheGenerator、DataCacheGenerator、SourceGenerator这些所关联 DataFetcher的类是做什么的,所以有必要先简单的介绍他们。

1.3 DataFetcher、ModelLoader以及DecodeHelper的介绍


1.关于DataFetcher

它是一个接口,先来看看它的定义:

// DataFetcher.java

public interface DataFetcher {

interface DataCallback {

void onDataReady(@Nullable T data); // 资源已经弄好了

void onLoadFailed(@NonNull Exception e); //加载失败回调

}

void loadData(@NonNull Priority priority, @NonNull DataCallback<? super T> callback); //加载方法

void cleanup(); //资源释放

void cancel(); // 取消加载

@NonNull

Class getDataClass();

@NonNull

DataSource getDataSource();

}

DataFetcher是Glide的数据加载模块,定义了数据的加载时遵循的框架。

它的使用大概就是:通过调用 loadData()来加载资源,当加载成功后,调用 onDataReady()进行回调。如果失败,调用 onLoadFailed()。非常好懂。

它有很多个实现类,比如:

  • LocalUriFetcher

使用 ContentResolver 从本地资源的Uri加载数据

  • AssetPathFetcher

使用 AssetManager从asset path中获取数据的抽象类

  • HttpUrlFetcher

具体加载的数据类型为 InputStream,从网络Url中获取数据

2.关于ModelLoader

它也是个接口,来看看代码:

public interface ModelLoader<Model, Data> {

class LoadData {

public final Key sourceKey;

public final List alternateKeys;

public final DataFetcher fetcher;

public LoadData(@NonNull Key sourceKey, @NonNull DataFetcher fetcher) {

this(sourceKey, Collections.emptyList(), fetcher);

}

public LoadData(@NonNull Key sourceKey, @NonNull List alternateKeys,

@NonNull DataFetcher fetcher) {

this.sourceKey = Preconditions.checkNotNull(sourceKey);

this.alternateKeys = Preconditions.checkNotNull(alternateKeys);

this.fetcher = Preconditions.checkNotNull(fetcher);

}

}

@Nullable

LoadData buildLoadData(@NonNull Model model, int width, int height,

@NonNull Options options);

boolean handles(@NonNull Model model);

}

它是一个工厂类的接口,用于将任意复杂的数据模型转换为具体的数据类型。

复杂的数据模型:通过给Glide设置了的各种属性,比如动画、缩放、模糊等等配置

具体的数据类型:LoadData ---- 由一系列的Key和DataFetcher组成的数据结构

它可以构建像 HttpGlideUrlLoader,内部构建一个HttpUrlFetcher,来进行网络的下载资源

也可以构建 FileLoader,内部构建一个 FileFetcher,来进行文件资源的获取。

它和DataFetcher是一起绑定的。

3. DecodeHelper

具体讲解需要费大量时间,所以可能会对本文出现岔道,所以具体请看这篇 DecodeHelper类相关方法分析

我们在 Glide.with().load(url) 的load中输入了获取图片资源的方式,这个方式可能是String、BitMap、Uri等。而 DecodeHelper的作用就是通过分析这个方式,分析出我们想要获取图片资源,需要哪些 ModelLoader、CacheKeys等。

比如当我们输入的是一个Url的字符串,它就能分析出我们所需的 cacheKey为:[GlideUrl,ObjectKey],从而获取到对应的LoadData

1.4 DataFetcherGenerator.startNext()


在1.2节中讲到,如果说一直要执行到 SOURCE状态,要先执行 ResourceCacheGenerator.startNext()DataCacheGenerator .startNext() = false ,这里看下两个方法的实现。

1.关于ResourceCacheGenerator的实现

// ResourceCacheGenerator.java

public boolean startNext() {

List sourceIds = helper.getCacheKeys(); // 1

if (sourceIds.isEmpty()) { // 2

return false;

}

List<Class<?>> resourceClasses = helper.getRegisteredResourceClasses(); // 3

if (resourceClasses.isEmpty()) {

if (File.class.equals(helper.getTranscodeClass())) {

return false;

}

}

while (modelLoaders == null || !hasNextModelLoader()) {

resourceClassIndex++;

if (resourceClassIndex >= resourceClasses.size()) {

sourceIdIndex++;

if (sourceIdIndex >= sourceIds.size()) {

return false;

}

resourceClassIndex = 0;

}

Key sourceId = sourceIds.get(sourceIdIndex);

Class<?> resourceClass = resourceClasses.get(resourceClassIndex);

Transformation<?> transformation = helper.getTransformation(resourceClass);

currentKey =

new ResourceCacheKey(

helper.getArrayPool(),

sourceId,

helper.getSignature(),

helper.getWidth(),

helper.getHeight(),

transformation,

resourceClass,

helper.getOptions()); // 4

cacheFile = helper.getDiskCache().get(currentKey); // 5

尾声

如果你想成为一个优秀的 Android 开发人员,请集中精力,对基础和重要的事情做深度研究。

对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。 整理的这些架构技术希望对Android开发的朋友们有所参考以及少走弯路,本文的重点是你有没有收获与成长,其余的都不重要,希望读者们能谨记这一点。

这里,笔者分享一份从架构哲学的层面来剖析的视频及资料分享给大家梳理了多年的架构经验,筹备近6个月最新录制的,相信这份视频能给你带来不一样的启发、收获。

PS:之前因为秋招收集的二十套一二线互联网公司Android面试真题 (含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

架构篇

《Jetpack全家桶打造全新Google标准架构模式》

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

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

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

ceId = sourceIds.get(sourceIdIndex);

Class<?> resourceClass = resourceClasses.get(resourceClassIndex);

Transformation<?> transformation = helper.getTransformation(resourceClass);

currentKey =

new ResourceCacheKey(

helper.getArrayPool(),

sourceId,

helper.getSignature(),

helper.getWidth(),

helper.getHeight(),

transformation,

resourceClass,

helper.getOptions()); // 4

cacheFile = helper.getDiskCache().get(currentKey); // 5

尾声

如果你想成为一个优秀的 Android 开发人员,请集中精力,对基础和重要的事情做深度研究。

对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。 整理的这些架构技术希望对Android开发的朋友们有所参考以及少走弯路,本文的重点是你有没有收获与成长,其余的都不重要,希望读者们能谨记这一点。

这里,笔者分享一份从架构哲学的层面来剖析的视频及资料分享给大家梳理了多年的架构经验,筹备近6个月最新录制的,相信这份视频能给你带来不一样的启发、收获。[外链图片转存中…(img-WqyGErhB-1715748082319)]

PS:之前因为秋招收集的二十套一二线互联网公司Android面试真题 (含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)

[外链图片转存中…(img-EyOy8296-1715748082319)]

架构篇

《Jetpack全家桶打造全新Google标准架构模式》
[外链图片转存中…(img-blu7qB9Z-1715748082320)]

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

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

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

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值