2024年安卓最全Android 存储优化 —— MMKV 集成与原理,面试中Handler这些必备知识点你都知道吗

总结

Android架构学习进阶是一条漫长而艰苦的道路,不能靠一时激情,更不是熬几天几夜就能学好的,必须养成平时努力学习的习惯。所以:贵在坚持!

上面分享的字节跳动公司2020年的面试真题解析大全,笔者还把一线互联网企业主流面试技术要点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。

就先写到这,码字不易,写的很片面不好之处敬请指出,如果觉得有参考价值的朋友也可以关注一下我

①「Android面试真题解析大全」PDF完整高清版+②「Android面试知识体系」学习思维导图压缩包阅读下载,最后觉得有帮助、有需要的朋友可以点个赞

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

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

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

  • m_metaFile 文件摘要的映射
  • loadFromFile 数据的载入

接下来我们先看看, 文件摘要信息的映射

一) 文件摘要的映射

// MmapedFile.cpp
MmapedFile::MmapedFile(const std::string &path, size_t size, bool fileType)
: m_name(path), m_fd(-1), m_segmentPtr(nullptr), m_segmentSize(0), m_fileType(fileType) {
// 用于内存映射的文件
if (m_fileType == MMAP_FILE) {
// 1. 打开文件
m_fd = open(m_name.c_str(), O_RDWR | O_CREAT, S_IRWXU);
if (m_fd < 0) {
MMKVError(“fail to open:%s, %s”, m_name.c_str(), strerror(errno));
} else {
// 2. 创建文件锁
FileLock fileLock(m_fd);
InterProcessLock lock(&fileLock, ExclusiveLockType);
SCOPEDLOCK(lock);
// 获取文件的信息
struct stat st = {};
if (fstat(m_fd, &st) != -1) {
// 获取文件大小
m_segmentSize = static_cast<size_t>(st.st_size);
}
// 3. 验证文件的大小是否小于一个内存页, 一般为 4kb
if (m_segmentSize < DEFAULT_MMAP_SIZE) {
m_segmentSize = static_cast<size_t>(DEFAULT_MMAP_SIZE);
// 3.1 通过 ftruncate 将文件大小对其到内存页
// 3.2 通过 zeroFillFile 将文件对其后的空白部分用 0 填充
if (ftruncate(m_fd, m_segmentSize) != 0 || !zeroFillFile(m_fd, 0, m_segmentSize)) {
// 说明文件拓展失败了, 移除这个文件
close(m_fd);
m_fd = -1;
removeFile(m_name);
return;
}
}
// 4. 通过 mmap 将文件映射到内存, 获取内存首地址
m_segmentPtr =
(char *) mmap(nullptr, m_segmentSize, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_segmentPtr == MAP_FAILED) {
MMKVError(“fail to mmap [%s], %s”, m_name.c_str(), strerror(errno));
close(m_fd);
m_fd = -1;
m_segmentPtr = nullptr;
}
}
}
// 用于共享内存的文件
else {

}
}

MmapedFile 的构造函数处理的事务如下

  • 打开指定的文件
  • 创建这个文件锁
  • 修正文件大小, 最小为 4kb
  • 前 4kb 用于统计数据总大小
  • 通过 mmap 将文件映射到内存

好的, 通过 MmapedFile 的构造函数, 我们便能够获取到映射后的内存首地址了, 操作这块内存时 Linux 内核会负责将内存中的数据同步到文件中

比起 SP 的数据同步, mmap 显然是要优雅的多, 即使进程意外死亡, 也能够通过 Linux 内核的保护机制, 将进行了文件映射的内存数据刷入到文件中, 提升了数据写入的可靠性

结下来看看数据的载入

二) 数据的载入

// MMKV.cpp
void MMKV::loadFromFile() {

…// 忽略匿名共享内存相关代码

// 若已经进行了文件映射
if (m_metaFile.isFileValid()) {
// 则获取相关数据
m_metaInfo.read(m_metaFile.getMemory());
}
// 获取文件描述符
m_fd = open(m_path.c_str(), O_RDWR | O_CREAT, S_IRWXU);
if (m_fd < 0) {
MMKVError(“fail to open:%s, %s”, m_path.c_str(), strerror(errno));
} else {
// 1. 获取文件大小
m_size = 0;
struct stat st = {0};
if (fstat(m_fd, &st) != -1) {
m_size = static_cast<size_t>(st.st_size);
}
// 1.1 将文件大小对其到内存页的整数倍
if (m_size < DEFAULT_MMAP_SIZE || (m_size % DEFAULT_MMAP_SIZE != 0)) {

}
// 2. 获取文件映射后的内存地址
m_ptr = (char *) mmap(nullptr, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0);
if (m_ptr == MAP_FAILED) {

} else {
// 3. 读取内存文件的前 32 位, 获取存储数据的真实大小
memcpy(&m_actualSize, m_ptr, Fixed32Size);

bool loadFromFile = false, needFullWriteback = false;
if (m_actualSize > 0) {
// 4. 验证文件的长度
if (m_actualSize < m_size && m_actualSize + Fixed32Size <= m_size) {
// 5. 验证文件 CRC 的正确性
if (checkFileCRCValid()) {
loadFromFile = true;
} else {
// 若不正确, 则回调异常 CRC 异常
auto strategic = mmkv::onMMKVCRCCheckFail(m_mmapID);
if (strategic == OnErrorRecover) {
loadFromFile = true;
needFullWriteback = true;
}
}
} else {
// 回调文件长度异常
auto strategic = mmkv::onMMKVFileLengthError(m_mmapID);
if (strategic == OnErrorRecover) {
writeAcutalSize(m_size - Fixed32Size);
loadFromFile = true;
needFullWriteback = true;
}
}
}
// 6. 需要从文件获取数据
if (loadFromFile) {

// 构建输入缓存
MMBuffer inputBuffer(m_ptr + Fixed32Size, m_actualSize, MMBufferNoCopy);
if (m_crypter) {
// 解密输入缓冲中的数据
decryptBuffer(*m_crypter, inputBuffer);
}
// 从输入缓冲中将数据读入 m_dic
m_dic.clear();
MiniPBCoder::decodeMap(m_dic, inputBuffer);
// 构建输出数据
m_output = new CodedOutputData(m_ptr + Fixed32Size + m_actualSize,
m_size - Fixed32Size - m_actualSize);
// 进行重整回写, 剔除重复的数据
if (needFullWriteback) {
fullWriteback();
}
}
// 7. 说明文件中没有数据, 或者校验失败了
else {
SCOPEDLOCK(m_exclusiveProcessLock);
// 清空文件中的数据
if (m_actualSize > 0) {
writeAcutalSize(0);
}
m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
// 重新计算 CRC
recaculateCRCDigest();
}

}
}

m_needLoadFromFile = false;
}

好的, 可以看到 loadFromFile 中对于 CRC 验证通过的文件, 会将文件中的数据读入到 m_dic 中缓存, 否则则会清空文件

  • 因此用户恶意修改文件之后, 会破坏 CRC 的值, 这个存储数据便会被作废, 这一点要尤为注意
  • 从文件中读取数据到 m_dic 之后, 会将 mdic 回写到文件中, 其重写的目的是为了剔除重复的数据
  • 关于为什么会出现重复的数据, 在后面 encode 操作中再分析

三) 回顾

到这里 MMKV 实例的构建就完成了, 有了 m_dic 这个内存缓存, 我们进行数据查询的效率就大大提升了

从最终的结果来看它与 SP 是一致的, 都是初次加载时会将文件中所有的数据加载到散列表中, 不过 MMKV 多了一步数据回写的操作, 因此当数据量比较大时, 对实例构建的速度有一定的影响

// 写入 1000 条数据之后, MMVK 和 SharedPreferences 实例化的时间对比
E/TAG: create MMKV instance time is 4 ms
E/TAG: create SharedPreferences instance time is 1 ms

从结果上来看, MMVK 的确在实例构造速度上有一定的劣势, 不过得益于是将 m_dic 中的数据写入到 mmap 的内存, 其真正进行文件写入的时机由 Linux 内核决定, 再加上文件的页缓存机制, 所以速度上虽有劣势, 但不至于无法接受

四. encode

关于 encode 即数据的添加与更新的流程, 这里以 encodeString 为例

public class MMKV implements SharedPreferences, SharedPreferences.Editor {

public boolean encode(String key, String value) {
return encodeString(nativeHandle, key, value);
}

private native boolean encodeString(long handle, String key, String value);

}

看看 native 层的实现

// native-bridge.cpp
namespace mmkv {

MMKV_JNI jboolean encodeString(JNIEnv *env, jobject, jlong handle, jstring oKey, jstring oValue) {
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
// 若是 value 非 NULL
if (oValue) {
// 通过 setStringForKey 函数, 将数据存入
string value = jstring2string(env, oValue);
return (jboolean) kv->setStringForKey(value, key);
}
// 若是 value 为 NULL, 则移除 key 对应的 value 值
else {
kv->removeValueForKey(key);
return (jboolean) true;
}
}
return (jboolean) false;
}

}

这里我们主要分析一下 setStringForKey 这个函数

// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
if (key.empty()) {
return false;
}
// 1. 将数据编码成 ProtocolBuffer
auto data = MiniPBCoder::encodeDataWithObject(value);
// 2. 更新键值对
return setDataForKey(std::move(data), key);
}

这里主要分为两步操作

  • 数据编码
  • 更新键值对

一) 数据的编码

MMKV 采用的是 ProtocolBuffer 编码方式, 这里就不做过多介绍了, 具体请查看 Google 官方文档

// MiniPBCoder.cpp
MMBuffer MiniPBCoder::getEncodeData(const string &str) {
// 1. 创建编码条目的集合
m_encodeItems = new vector();
// 2. 为集合填充数据
size_t index = prepareObjectForEncode(str);
PBEncodeItem *oItem = (index < m_encodeItems->size()) ? &(*m_encodeItems)[index] : nullptr;
if (oItem && oItem->compiledSize > 0) {
// 3. 开辟一个内存缓冲区, 用于存放编码后的数据
m_outputBuffer = new MMBuffer(oItem->compiledSize);
// 4. 创建一个编码操作对象
m_outputData = new CodedOutputData(m_outputBuffer->getPtr(), m_outputBuffer->length());
// 执行 protocolbuffer 编码, 并输出到缓冲区
writeRootObject();
}
// 调用移动构造函数, 重新创建实例返回
return move(*m_outputBuffer);
}

size_t MiniPBCoder::prepareObjectForEncode(const string &str) {
// 2.1 创建 PBEncodeItem 对象用来描述待编码的条目, 并添加到 vector 集合
m_encodeItems->push_back(PBEncodeItem());
// 2.2 获取 PBEncodeItem 对象
PBEncodeItem *encodeItem = &(m_encodeItems->back());
// 2.3 记录索引位置
size_t index = m_encodeItems->size() - 1;
{
// 2.4 填充编码类型
encodeItem->type = PBEncodeItemType_String;
// 2.5 填充要编码的数据
encodeItem->value.strValue = &str;
// 2.6 填充数据大小
encodeItem->valueSize = static_cast<int32_t>(str.size());
}
// 2.7 计算编码后的大小
encodeItem->compiledSize = pbRawVarint32Size(encodeItem->valueSize) + encodeItem->valueSize;
return index;
}

可以看到, 再未进行编码操作之前, 编码后的数据大小就已经确定好了, 并且将它保存在了 encodeItem->compiledSize 中, 接下来我们看看执行数据编码并输出到缓冲区的操作流程

// MiniPBCoder.cpp
void MiniPBCoder::writeRootObject() {
for (size_t index = 0, total = m_encodeItems->size(); index < total; index++) {
PBEncodeItem *encodeItem = &(m_encodeItems)[index];
switch (encodeItem->type) {
// 主要关心编码 String
case PBEncodeItemType_String: {
m_outputData->writeString(
(encodeItem->value.strValue));
break;
}

}
}
}

// CodedOutputData.cpp
void CodedOutputData::writeString(const string &value) {
size_t numberOfBytes = value.size();

// 1. 按照 varint 方式编码字符串长度, 会改变 m_position 的值
this->writeRawVarint32((int32_t) numberOfBytes);
// 2. 将字符串的数据拷贝到编码好的长度后面
memcpy(m_ptr + m_position, ((uint8_t *) value.data()), numberOfBytes);
// 更新 position 的值
m_position += numberOfBytes;
}

可以看到 CodedOutputData 的 writeString 中按照 protocol buffer 进行了字符串的编码操作

其中 m_ptr 是上面开辟的内存缓冲区的地址, 也就是说 writeString 执行结束之后, 数据就已经被写入缓冲区了

有了编码好的数据缓冲区, 接下来看看更新键值对的操作

二) 键值对的更新

// MMKV.cpp
bool MMKV::setStringForKey(const std::string &value, const std::string &key) {
// 编码数据获取存放数据的缓冲区
auto data = MiniPBCoder::encodeDataWithObject(value);
// 更新键值对
return setDataForKey(std::move(data), key);
}

bool MMKV::setDataForKey(MMBuffer &&data, const std::string &key) {

// 将键值对写入 mmap 文件映射的内存中
auto ret = appendDataWithKey(data, key);
// 写入成功, 更新散列数据
if (ret) {
m_dic[key] = std::move(data);
m_hasFullWriteback = false;
}
return ret;
}

bool MMKV::appendDataWithKey(const MMBuffer &data, const std::string &key) {
// 1. 计算 key + value 的 ProtocolBuffer 编码后的长度
size_t keyLength = key.length();
size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength);
size += data.length() + pbRawVarint32Size((int32_t) data.length());
SCOPEDLOCK(m_exclusiveProcessLock);

// 2. 验证是否有足够的空间, 不足则进行数据重整与扩容操作
bool hasEnoughSize = ensureMemorySize(size);
if (!hasEnoughSize || !isFileValid()) {
return false;
}

// 3. 更新文件头的数据总大小
writeAcutalSize(m_actualSize + size);

// 4. 将 key 和编码后的 value 写入到文件映射的内存
m_output->writeString(key);
m_output->writeData(data);

// 5. 获取文件映射内存当前 <key, value> 的起始位置
auto ptr = (uint8_t *) m_ptr + Fixed32Size + m_actualSize - size;
if (m_crypter) {
// 加密这块区域
m_crypter->encrypt(ptr, ptr, size);
}

// 6. 更新 CRC
updateCRCDigest(ptr, size, KeepSequence);
return true;
}

好的, 可以看到更新键值对的操作还是比较复杂的, 首先将键值对数据写入到文件映射的内存中, 写入成功之后更新散列数据

关于写入到文件映射的过程, 上面代码中的注释也非常的清晰, 接下来我们 ensureMemorySize 是如何进行数据的重整与扩容的

数据的重整与扩容

// MMKV.cpp
bool MMKV::ensureMemorySize(size_t newSize) {

// 计算新键值对的大小
constexpr size_t ItemSizeHolderSize = 4;
if (m_dic.empty()) {
newSize += ItemSizeHolderSize;
}
// 数据重写:
// 1. 文件剩余空闲空间少于新的键值对
// 2. 散列为空
if (newSize >= m_output->spaceLeft() || m_dic.empty()) {
// 计算所需的数据空间
static const int offset = pbFixed32Size(0);
MMBuffer data = MiniPBCoder::encodeDataWithObject(m_dic);
size_t lenNeeded = data.length() + offset + newSize;
if (m_isAshmem) {

} else {
//
// 计算每个键值对的平均大小
size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.size());
// 计算未来可能会使用的大小(类似于 1.5 倍)
size_t futureUsage = avgItemSize * std::max<size_t>(8, (m_dic.size() + 1) / 2);
// 1. 所需空间 >= 当前文件总大小
// 2. 所需空间的 1.5 倍 >= 当前文件总大小
if (lenNeeded >= m_size || (lenNeeded + futureUsage) >= m_size) {
// 扩容为 2 倍
size_t oldSize = m_size;
do {
m_size *= 2;
} while (lenNeeded + futureUsage >= m_size);

}
}

// 进行数据的重写
writeAcutalSize(data.length());

}
return true;
}

从上面的代码我们可以了解到

  • 数据的重写时机
  • 文件剩余空间少于新的键值对大小
  • 散列为空
  • 文件扩容时机
  • 所需空间的 1.5 倍超过了当前文件的总大小时, 扩容为之前的两倍

三) 回顾

至此 encode 的流程我们就走完了, 回顾一下整个 encode 的流程

  • 使用 ProtocolBuffer 编码 value
  • key编码后的 value 使用 ProtocolBuffer 的格式 append 到文件映射区内存的尾部
  • 文件空间不足
  • 判断是否需要扩容
  • 进行数据的回写
  • 即在文件后进行追加
  • 对这个键值对区域进行统一的加密
  • 更新 CRC 的值
  • 将 key 和 value 对应的 ProtocolBuffer 编码内存区域, 更新到散列表 m_dic 中

通过 encode 的分析, 我们得知 MMKV 文件的存储方式如下

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

接下来看看 decode 的流程

五. decode

decode 的过程同样以 decodeString 为例

// native-bridge.cpp
MMKV_JNI jstring
decodeString(JNIEnv *env, jobject obj, jlong handle, jstring oKey, jstring oDefaultValue) {
MMKV *kv = reinterpret_cast<MMKV *>(handle);
if (kv && oKey) {
string key = jstring2string(env, oKey);
// 通过 getStringForKey, 将数据输出到传出参数中 value 中
string value;
bool hasValue = kv->getStringForKey(key, value);
if (hasValue) {
return string2jstring(env, value);
}
}
return oDefaultValue;
}

// MMKV.cpp
bool MMKV::getStringForKey(const std::string &key, std::string &result) {
if (key.empty()) {
return false;
}
SCOPEDLOCK(m_lock);
// 1. 从内存缓存中获取数据
auto &data = getDataForKey(key);
if (data.length() > 0) {
// 2. 解析 data 对应的 ProtocolBuffer 数据
result = MiniPBCoder::decodeString(data);
return true;
}
return false;
}

const MMBuffer &MMKV::getDataForKey(const std::string &key) {
// 从散列表中获取 key 对应的 value
auto itr = m_dic.find(key);
if (itr != m_dic.end()) {
return itr->second;
}
static MMBuffer nan(0);
return nan;
}

好的可以看到 decode 的流程比较简单, 先从内存缓存中获取 key 对应的 value 的 ProtocolBuffer 内存区域, 再解析这块内存区域, 从中获取真正的 value 值

思考

看到这里可能会有一个疑问, 为什么 m_dic 不直接存储 key 和 value 原始数据呢, 这样查询效率不是更快吗?

  • 如此一来查询效率的确会更快, 因为少了 ProtocolBuffer 解码的过程

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从图上的结果可以看出, MMKV 的读取性能时略低于 SharedPreferences 的, 这里笔者给出自己的思考

  • m_dic 在数据重整中也起到了非常重要的作用, 需要依靠 m_dic 将数据写入到 mmap 的文件映射区, 这个过程是非常耗时的, 若是原始的 value, 则需要对所有的 value 再进行一次 ProtocolBuffer 编码操作, 尤其是当数据量比较庞大时, 其带来的性能损耗更是无法忽略的

既然 m_dic 还承担着方便数据复写的功能, 那能否再添加一个内存缓存专门用于存储原始的 value 呢?

  • 当然可以, 这样 MMKV 的读取定是能够达到 SharedPreferences 的水平, 不过 value 的内存消耗则会加倍, MMKV 作为一个轻量级缓存的框架, 查询时时间的提升幅度还不足以用内存加倍的代价去换取, 我想这是 Tencent 在进行多方面权衡之后, 得到的一个比较合理的解决方案

六. 进程读写的同步

说起进程间读写同步, 我们很自然的想到 Linux 的信号量, 但是这种方式有一个弊端, 那就是当持有锁的进程意外死亡的时候, 并不会释放其拥有的信号量, 若多进程之间存在竞争, 那么阻塞的进程将不会被唤醒, 这是非常危险的

MMKV 是采用 文件锁 的方式来进行进程间的同步操作

  • LOCK_SH(共享锁): 多个进程可以使用同一把锁, 常被用作读共享锁
  • LOCK_EX(排他锁): 同时只允许一个进程使用, 常被用作写锁
  • LOCK_UN: 释放锁

接下来我看看 MMKV 加解锁的操作

一) 文件共享锁

MMKV::MMKV(
const std::string &mmapID, int size, MMKVMode mode, string *cryptKey, string *relativePath)
: m_mmapID(mmapedKVKey(mmapID, relativePath))
// 创建文件锁的描述
, m_fileLock(m_metaFile.getFd())
// 描述共享锁
, m_sharedProcessLock(&m_fileLock, SharedLockType)
// 描述排它锁
, m_exclusiveProcessLock(&m_fileLock, ExclusiveLockType)
// 判读是否为进程间通信
, m_isInterProcess((mode & MMKV_MULTI_PROCESS) != 0 || (mode & CONTEXT_MODE_MULTI_PROCESS) != 0)
, m_isAshmem((mode & MMKV_ASHMEM) != 0) {

// 根据是否跨进程操作判断共享锁和排它锁的开关
m_sharedProcessLock.m_enable = m_isInterProcess;
m_exclusiveProcessLock.m_enable = m_isInterProcess;

// sensitive zone
{
// 文件读操作, 启用了文件共享锁
SCOPEDLOCK(m_sharedProcessLock);
loadFromFile();
}
}

可以看到在我们前面分析过的构造函数中, MMKV 对文件锁进行了初始化, 并且创建了共享锁和排它锁, 并在跨进程操作时开启, 当进行读操作时, 启动了共享锁

二) 文件排它锁

bool MMKV::fullWriteback() {

auto allData = MiniPBCoder::encodeDataWithObject(m_dic);
// 启动了排它锁
SCOPEDLOCK(m_exclusiveProcessLock);
if (allData.length() > 0) {
if (allData.length() + Fixed32Size <= m_size) {
if (m_crypter) {
m_crypter->reset();
auto ptr = (unsigned char *) allData.getPtr();
m_crypter->encrypt(ptr, ptr, allData.length());
}
writeAcutalSize(allData.length());
delete m_output;
m_output = new CodedOutputData(m_ptr + Fixed32Size, m_size - Fixed32Size);
m_output->writeRawData(allData); // note: don’t write size of data
recaculateCRCDigest();
m_hasFullWriteback = true;
return true;
} else {
// ensureMemorySize will extend file & full rewrite, no need to write back again
return ensureMemorySize(allData.length() + Fixed32Size - m_size);
}
}
return false;
}

在进行数据回写的函数中, 启动了排它锁

三) 读写效率表现

其进程同步读写的性能表现如下

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以看到进程同步读写的效率也是非常 nice 的

关于跨进程同步就介绍到这里, 当然 MMKV 的文件锁并没有表面上那么简单, 因为文件锁为状态锁, 无论加了多少次锁, 一个解锁操作就全解除, 显然无法应对子函数嵌套调用的问题, MMKV 内部通过了自行实现计数器来实现锁的可重入性, 更多的细节可以查看 wiki

总结

通过上面的分析, 我们对 MMKV 有了一个整体上的把控, 其具体的表现如下所示

最后

总而言之,Android开发行业变化太快,作为技术人员就要保持终生学习的态度,让学习力成为核心竞争力,所谓“活到老学到老”只有不断的学习,不断的提升自己,才能跟紧行业的步伐,才能不被时代所淘汰。

在这里我分享一份自己收录整理上述技术体系图相关的几十套腾讯、头条、阿里、美团等公司20年的面试题,把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,这里以图片的形式给大家展示一部分。

还有高级架构技术进阶脑图、Android开发面试专题资料,高级进阶架构资料 帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。

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

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

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

总而言之,Android开发行业变化太快,作为技术人员就要保持终生学习的态度,让学习力成为核心竞争力,所谓“活到老学到老”只有不断的学习,不断的提升自己,才能跟紧行业的步伐,才能不被时代所淘汰。

在这里我分享一份自己收录整理上述技术体系图相关的几十套腾讯、头条、阿里、美团等公司20年的面试题,把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,这里以图片的形式给大家展示一部分。

[外链图片转存中…(img-Wb36RGz9-1714991564472)]

[外链图片转存中…(img-l1SuBtes-1714991564473)]

[外链图片转存中…(img-IyhCyDGV-1714991564474)]

还有高级架构技术进阶脑图、Android开发面试专题资料,高级进阶架构资料 帮助大家学习提升进阶,也节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。

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

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

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值