深入Android系统 Binder-3-原理

  • 通常的IO模型,在同一个描述符上等待的多个线程会被随机唤醒一个

  • 但是Android在 Binder驱动 中记录了每次Binder调用的信息,其中就包括 线程ID,因此Binder驱动知道返回值应该交给哪个线程

  • 由驱动来处理线程的唤醒比在应用层做同样的事情要更简单,效率也更高

  • 但是,从架构的角度来看,这种设计比较糟糕,应用层和驱动产生了耦合。

好的,我们在对这部分做个小结:

  • 客户端从某个线程中发起调用,将参数打包后,通过ioctl函数传递给驱动

  • 客户端挂起并等待ioctl返回函数的结果

  • binder驱动记录下调用线程的信息,然后根据调用的binder对象寻找Binder服务所在的进程,也就是服务端

  • binder驱动找到服务端后先查看是否有空闲线程,没有则通知服务端创建

  • 服务端得到空闲线程后,根据binder驱动中保存的BBinder对象的指针调用相应的函数

  • 服务端在函数返回后在通过ioctl把结果打包传递给binder驱动

  • binder驱动根据返回信息查找调用者线程

  • binder驱动找到调用的线程后并唤醒它,并通过ioctl函数把结果传递回去

  • 客户端的线程得到返回结果后继续运行

Binder对象的传递


Binder对象作为参数传递是,会有两种情形:

  • Binder实体对象作为参数传递:非Binder对象的传递是通过在接收端复制对象完成的,但是Binder实体对象是无法复制的,因此需要在客户进程中创建一个Binder引用对象来代替实体对象。

  • Binder引用 对象作为参数传递:

  • 如果传递的目的地是该引用对象对应实体对象所在的进程,那么:

  • Binder框架必须把这个引用对象转换成Binder实体对象

  • 不能再创建一个新的实体对象

  • 必须找到并使用原来的实体对象

  • 如果传递的目的地是另一个客户端进程,那么:

  • 不能简单的复制引用对象

  • 需要建立目的进程中的引用对象实体对象的关系

  • 这个关系的建立是在Binder驱动中完成的

Binder对象传递流程简介

Binder调用的参数传递是通过Parcel类来完成的。先来简单看下Binder实体对象转换成Binder引用对象的过程:

  • 在服务进程中将IBinder(BBinder)对象加入到Parcel对象后,Parcel对象会:

  • 打包数据,并把数据类型标记为BINDER_TYPE_BINDER

  • BpBinder的指针放进cookie字段

  • 通过ioctlParcel对象的数据传递到Binder驱动

  • Binder驱动会检查传递进来的数据,如果发现了标记为BINDER_TYPE_BINDER的数据:

  • 会先查找和服务进程相关的Binder实体对象表:

  • 如果表中还没有这个实体对象的记录,则创建新的节点,并保存信息。

  • 然后驱动会查看客户进程的Binder对象引用表:

  • 如果没有引用对象的记录,同样会创建新的节点

  • 并让这个节点中某个字段指向服务进程的Binder实体对象表中的节点

  • 接下来驱动对Parcel对象中的数据进行改动:

  • 把数据从BINDER_TYPE_BINDER改为BINDER_TYPE_HANEL

  • 同时把handle的值设为Binder对象引用表中的节点

  • 最后,把改动的数据传到客户进程

  • 客户端接收到数据,发现数据中的Binder类型为BINDER_TYPE_HANEL后

  • 使用handle值作为参数,调用ProcessState类中的函数getStrongProxyFoHandle来得到BpBinder对象

  • 如果对象不存在则创建一个新的对象

  • 这个BpBinder对象会一直保存在ProcessStatemHandleToObject表中

  • 这样,客户端就得到了Binder引用对象

写入Binder对象的过程

有了上面的整体流程,我们来看下Binder对象的写入细节:

  • Parcel类中:

  • 写入Binder对象的函数是:

  • writeStrongBinder:写入强引用Binder对象

  • writeWeakBinder:写入弱引用Binder对象

  • 读取Binder对象的函数是:

  • readStrongBinder:获取强引用Binder对象

  • 强引用的Binder对象可以分为实体对象引用对象

  • readWeakBinder:获取弱引用Binder对象

  • 弱引用则没有区分实体对象引用对象

看下writeStrongBinder的代码:

status_t Parcel::writeStrongBinder(const sp& val)

{

return flatten_binder(ProcessState::self(), val, this);

}

status_t flatten_binder(const sp& /proc/,

const sp& binder, Parcel* out)

{

// flatten_binder 整个方法其实是在向obj这个结构体存放数据

flat_binder_object obj;

if (IPCThreadState::self()->backgroundSchedulingDisabled()) {

/* minimum priority for all nodes is nice 0 */

obj.flags = FLAT_BINDER_FLAG_ACCEPTS_FDS;

} else {

/* minimum priority for all nodes is MAX_NICE(19) */

obj.flags = 0x13 | FLAT_BINDER_FLAG_ACCEPTS_FDS;

}

if (binder != NULL) {

// 调用localBinder函数开区分是实体对象还是引用对象

IBinder *local = binder->localBinder();

if (!local) { //binder引用对象

BpBinder *proxy = binder->remoteBinder();

if (proxy == NULL) {

ALOGE(“null proxy”);

}

const int32_t handle = proxy ? proxy->handle() : 0;

obj.hdr.type = BINDER_TYPE_HANDLE;

obj.binder = 0; /* Don’t pass uninitialized stack data to a remote process */

obj.handle = handle;

obj.cookie = 0;

} else { // binder实体对象

obj.hdr.type = BINDER_TYPE_BINDER;

obj.binder = reinterpret_cast<uintptr_t>(local->getWeakRefs());

obj.cookie = reinterpret_cast<uintptr_t>(local);

}

} else {

obj.hdr.type = BINDER_TYPE_BINDER;

obj.binder = 0;

obj.cookie = 0;

}

return finish_flatten_binder(binder, obj, out);

}

flatten_binder整个方法其实是在向flat_binder_object这个结构体存放数据。我们看下flat_binder_object的结构:

struct flat_binder_object {

/* 8 bytes for large_flat_header. */

__u32 type;

__u32 flags;

/* 8 bytes of data. */

union {

binder_uintptr_t binder; /* local object */

__u32 handle; /* remote object */

};

/* extra data associated with local object */

binder_uintptr_t cookie;

};

我们看下flat_binder_object中的属性:

  • type 的类型:

  • BINDER_TYPE_BINDER:用来表示Binder实体对象

  • BINDER_TYPE_WEAK_BINDER:用来表示Bindr实体对象的弱引用

  • BINDER_TYPE_HANDLE:用来表示Binder引用对象

  • BINDER_TYPE_WEAK_HANDLE:用来表示Binder引用对象的弱引用

  • BINDER_TYPE_FD:用来表示一个文件描述符

  • flag字段用来保存向驱动传递的标志

  • union.binder在打包实体对象时存放的是对象的弱引用指针

  • union.handle在打包引用对象时存放的是对象中的handle值

  • cookie字段只用在打包实体对象时,存放的是BBinder指针

解析强引用Binder对象数据的过程

Parcel 类中解析数据的函数是unflatten_binder,代码如下:

status_t unflatten_binder(const sp& proc,

const Parcel& in, sp* out)

{

const flat_binder_object* flat = in.readObject(false);

if (flat) {

switch (flat->hdr.type) {

case BINDER_TYPE_BINDER:

out = reinterpret_cast<IBinder>(flat->cookie);

return finish_unflatten_binder(NULL, *flat, in);

case BINDER_TYPE_HANDLE:

*out = proc->getStrongProxyForHandle(flat->handle);

return finish_unflatten_binder(

static_cast<BpBinder*>(out->get()), *flat, in);

}

}

return BAD_TYPE;

}

unflatten_binder的逻辑是:

  • 如果是BINDER_TYPE_BINDER类型的数据,说明接收到的数据类型是Binder实体对象,此时cookie字段存放的是本进程的Binder实体对象的指针,可直接转化成IBinder的指针

  • 如果是 BINDER_TYPE_HANDLE 类型的数据,则调用 ProcessState类的 getStrongProxyForHandle函数来得到 BpBinder对象,函数代码如下:

sp ProcessState::getStrongProxyForHandle(int32_t handle)

{

sp result;

AutoMutex _l(mLock);

// 根据handle查看进程中是否已经创建了引用对象

// 如果进程中不存在handle对应的引用对象,在表中插入新的元素并返回

handle_entry* e = lookupHandleLocked(handle);

if (e != NULL) {

IBinder* b = e->binder;

//根据返回元素中的binder值来判断是否有引用对象

if (b == NULL || !e->refs->attemptIncWeak(this)) {

if (handle == 0) { // handle为0 表示是ServiceManager的引用对象

Parcel data;

//发送PING_TRANSACTION检查ServiceManager是否存在

status_t status = IPCThreadState::self()->transact(

0, IBinder::PING_TRANSACTION, data, NULL, 0);

if (status == DEAD_OBJECT)

return NULL;

}

b = BpBinder::create(handle); // 创建一个新的引用对象

e->binder = b;// 放入元素中的binder属性

if (b) e->refs = b->getWeakRefs();

result = b;

} else {

result.force_set(b);// 如果引用对象已经存在,放入到返回的对象result中

e->refs->decWeak(this);

}

}

return result;

}

getStrongProxyForHandle()函数会调用lookupHandleLocked()来查找handle在进程中对应的引用对象。所有进程的引用对象都保存在 ProcessState 的 mHandleToObject

变量中。 mHandleToObject 变量定义如下:

Vector<handle_entry> mHandleToObject;

  • mHandleToObject 是一个 Vector 集合类,元素类型 handle_entry

  • handle_entry

结构很简单:

struct handle_entry {

IBinder* binder;

RefBase::weakref_type* refs;

};

  • lookupHandleLocked()函数就是使用handle作为关键项来查找对应的handle_entry,没有则创建新的handle_entry,并添加到集合中

  • 当获得 handle_entry 后,如果 handle 值为0,表明要创建的是 ServiceManager 的 引用对象

  • 并发送PING_TRANSACTION消息来检查ServiceManager是否已经创建

IPCThreadState类


每个Binder线程都会有一个关联的IPCThreadState类的对象。IPCThreadState类主要的作用是和Binder驱动交互,发送接收Binder数据,处理和Binder驱动之间来往的消息。

我们在Binder线程模型中已经知道:

  • Binder服务启动时,服务线程调用了joinThreadPool()函数

  • 远程调用Binder服务时,客户线程调用了waitForResponse()函数

这两个函数都是定义在IPCThreadState类中,我们分别来看下这两个函数。

waitForResponse()函数

函数定义如下:

status_t IPCThreadState::waitForResponse(Parcel *reply, status_t *acquireResult)

{

uint32_t cmd;

int32_t err;

while (1) {

if ((err=talkWithDriver()) < NO_ERROR) break;//和驱动通信

err = mIn.errorCheck();

if (err < NO_ERROR) break;

if (mIn.dataAvail() == 0) continue;//没有数据,重新开始循环

cmd = (uint32_t)mIn.readInt32();//读取数据

//…

switch (cmd) {

case BR_TRANSACTION_COMPLETE:

if (!reply && !acquireResult) goto finish;

break;

//…省略部分case语句

case BR_REPLY:

// Binder调用返回的消息

default:

err = executeCommand(cmd);

if (err != NO_ERROR) goto finish;

break;

}

}

finish:

//… 错误处理

return err;

}

waitForResponse()函数中是一个无限while循环,在循环中,重复下面的工作:

  • 调用talkWithDriver函数发送/接收数据

  • 如果有消息从驱动返回,会通过 switch语句处理消息。

  • 如果收到错误消息或者调用返回的消息,将通过goto语句跳出while循环

  • 如果还有未处理的消息,则交给executeCommand函数处理

我们再仔细看下Binder调用收到的返回类型为BR_REPLY的代码:

case BR_REPLY:

{

binder_transaction_data tr;

//按照 binder_transaction_data 结构大小读取数据

err = mIn.read(&tr, sizeof(tr));

ALOG_ASSERT(err == NO_ERROR, “Not enough command data for brREPLY”);

if (err != NO_ERROR) goto finish;

if (reply) {

//reply 不为null,表示调用者需要返回结果

if ((tr.flags & TF_STATUS_CODE) == 0) {

//binder 调用成功,把从驱动来的数据设置到reply对象中

reply->ipcSetDataReference(

reinterpret_cast<const uint8_t*>(tr.data.ptr.buffer),

tr.data_size,

reinterpret_cast<const binder_size_t*>(tr.data.ptr.offsets),

tr.offsets_size/sizeof(binder_size_t),

freeBuffer, this);

} else {

//binder调用失败,使用freeBuffer函数释放驱动中分配的缓冲区

err = reinterpret_cast<const status_t>(tr.data.ptr.buffer);

freeBuffer(NULL,

reinterpret_cast<const uint8_t*>(tr.data.ptr.buffer),

tr.data_size,

reinterpret_cast<const binder_size_t*>(tr.data.ptr.offsets),

tr.offsets_size/sizeof(binder_size_t), this);

}

} else {

//调用者不需要返回结果,使用freeBuffer函数释放驱动中分配的缓冲区

freeBuffer(NULL,

reinterpret_cast<const uint8_t*>(tr.data.ptr.buffer),

tr.data_size,

reinterpret_cast<const binder_size_t*>(tr.data.ptr.offsets),

tr.offsets_size/sizeof(binder_size_t), this);

continue;

}

}

针对BR_REPLY类型的处理流程是:

  • 如果 Binder调用 成功返回,并且 调用者 也需要返回值

  • 把接收到的数据放在Parcel对象reply中返回

  • 如果 Binder调用 不成功,或者 调用者不需要返回数据

  • 通过freeBuffer释放驱动中分配的缓冲区

因为Binder提供了一块驱动和应用层共享的内存空间,所以在接收Binder数据不需要额外创建缓冲区再进行一次拷贝了,但是如果不及时通知驱动释放缓冲区中占用的无用内存,会很快会耗光这部分共享空间。

上面代码中的reply->ipcSetDataReference方法,在设置Parcel对象的同时,同样也把freeBuffer的指针作为参数传入到对象中,这样reply对象删除时,也会调用freeBuffer函数来释放驱动中的缓冲区。

waitForResponse()函数的作用是发送Binder调用的数据并等待返回值。为什么还需要使用循环的方式反复和驱动交互?原因有两点:

  • 一是消息协议中要求应用层通过 BC_TRANSACTION 发送 Binder 调用数据后:

  • 驱动要先给应用层回复BC_TRANSACTION_COMPLETE消息,表示已经说到并且认可本次Binder调用数据

  • 然后上层应用再次调用talkWithDriver来等待驱动返回调用结果

  • 如果调用结果返回了,会收到BR_REPLY消息

  • 二是等待调用返回期间,驱动可能会给线程发送消息,利用这个线程帮忙干点活。。。。

joinThreadPool函数

Binder线程池部分已经知道:应用启动时会伴随着启动Binder服务,而最后执行到的方法就是joinThreadPool函数。

我们看下函数定义:

void IPCThreadState::joinThreadPool(bool isMain)

{

mOut.writeInt32(isMain ? BC_ENTER_LOOPER : BC_REGISTER_LOOPER);

status_t result;

do {

processPendingDerefs();

//now get the next command to be processed, waiting if necessary

result = getAndExecuteCommand();//读取并处理驱动发送的消息

if (result < NO_ERROR && result != TIMED_OUT && result != -ECONNREFUSED && result != -EBADF) {

abort();

}

// Let this thread exit the thread pool if it is no longer

// needed and it is not the main process thread.

if(result == TIMED_OUT && !isMain) {

break;

}

} while (result != -ECONNREFUSED && result != -EBADF);

mOut.writeInt32(BC_EXIT_LOOPER); //退出前向驱动发送线程退出消息

talkWithDriver(false);

}

joinThreadPool函数的结构是一个while循环。

  • 传入的参数 isMain

  • 如果为 true(通常是线程池第一个线程发起的调用),则向驱动发送 BC_ENTER_LOOPER 消息

  • 发送BC_ENTER_LOOPER的线程会被驱动标记为“主”线程

  • 不会在空闲时间被驱动要求退出

  • 否则,发送BC_REGISTER_LOOPER

  • 这两条消息都是告诉驱动:本线程已经做好准备接收驱动来的Binder调用了

  • 进入循环,调用了 processPendingDerefs() 函数

  • 用来处理 IPCThreadState 对象中 mPendingWeakDerefs

和 mPendingStrongDerefs 的 Binder对象 的引用计数

  • mPendingWeakDerefsmPendingStrongDerefs都是Vector集合

  • 当接收到驱动发来的BR_RELEASE消息时,就会把其中的Binder对象放到mPendingStrongDerefs

  • 并在processPendingDerefs()函数中介绍对象的引用计数

  • 调用 getAndExecuteCommand 函数

  • 函数中调用talkWithDriver读取驱动传递的数据

  • 然后调用executeCommand来执行

到这里,我们再来看下talkWithDriverexecuteCommand两个函数

talkWithDriver函数

talkWithDriver函数的作用是把IPCThreadState类中的mOut变量保存的数据通过ioctl函数发送到驱动,同时把驱动返回的数据放到类的mIn变量中。

talkWithDriver函数的代码如下:

status_t IPCThreadState::talkWithDriver(bool doReceive)

{

if (mProcess->mDriverFD <= 0) {

return -EBADF;

}

//ioctl 传输时所使用的的数据结构

binder_write_read bwr;

// Is the read buffer empty?

// 判断 mIn 中的数据是否已经读取完毕,没有的话还需要继续读取

const bool needRead = mIn.dataPosition() >= mIn.dataSize();

// We don’t want to write anything if we are still reading

// from data left in the input buffer and the caller

// has requested to read the next data.

// 英文描述的很详细了哟

// 如果不需要读取数据(doReceive=false,needRead=true),那么就可以准备写数据了

const size_t outAvail = (!doReceive || needRead) ? mOut.dataSize() : 0;

bwr.write_size = outAvail;//表示要写入的长度

bwr.write_buffer = (uintptr_t)mOut.data();//要写入的数据的指针

// This is what we’ll read.

if (doReceive && needRead) {

bwr.read_size = mIn.dataCapacity();

bwr.read_buffer = (uintptr_t)mIn.data();

} else {

bwr.read_size = 0;

bwr.read_buffer = 0;

}

// Return immediately if there is nothing to do.

if ((bwr.write_size == 0) && (bwr.read_size == 0)) return NO_ERROR;

bwr.write_consumed = 0;//这个字段后面会被填上驱动读取到的数据长度(写入到驱动的数据长度)

bwr.read_consumed = 0;// 这个字段后面会被填上从驱动返回的数据长度

status_t err;

do {

// 9.0增加了一些平台判断,可能以后要多平台去支持了吧

#if defined(ANDROID)

// 用ioctl和驱动交换数据

if (ioctl(mProcess->mDriverFD, BINDER_WRITE_READ, &bwr) >= 0)

err = NO_ERROR;

else

err = -errno;

#else

err = INVALID_OPERATION;

#endif

if (mProcess->mDriverFD <= 0) {

// 这个情况应该是设备节点不可用

err = -EBADF;

}

} while (err == -EINTR);

if (err >= NO_ERROR) {

if (bwr.write_consumed > 0) {

if (bwr.write_consumed < mOut.dataSize())

// 如果已经写入驱动的数据长度小于mOut中的数据长度

// 说明还没发送完,把已经写入驱动的数据移除掉

// 剩下的数据等待下次发送

mOut.remove(0, bwr.write_consumed);

else {

// 数据已经全部写入驱动,复位mOut

mOut.setDataSize(0);

// 做一些指针的清理工作

processPostWriteDerefs();

}

}

if (bwr.read_consumed > 0) {

// 说明从驱动中读到了数据,设置好mInt对象

mIn.setDataSize(bwr.read_consumed);

mIn.setDataPosition(0);

}

return NO_ERROR;

}

return err;

}

  • 准备发送到驱动中的数据保存在成员变量mOut

  • 从驱动中读取到的数据保存在成员变量mInt

  • 调用 talkWithDriver 时,如果 mInt 还有数据

  • 表示还没有处理完驱动发来的消息

  • 本次函数调用将不会从驱动中读取数据

  • ioctl 函数

  • 使用的命令是BINDER_WRITE_READ

  • 需要binder_write_read结构体作为参数

  • 驱动篇再看

executeCommand 函数

executeCommand 函数是一个大的switch语句,处理从驱动传递过来的消息。

我们前面遇到了一些消息,大概包括:

  • BR_SPAWN_LOOP:驱动通知启动新线程的消息

  • BR_DEAD_BINDER:驱动通知Binder服务死亡的消息

  • BR_FINISHED:驱动通知线程退出的消息

  • BR_ERROR,BR_OK,BR_NOOP:驱动简单的回复消息

  • BR_RELEASE,BR_INCREFS,BR_DECREFS:驱动通知增加和减少Binder对象跨进程的引用计数

  • BR_TRANSACTION,:驱动通知进行Binder调用的消息

重点是BR_TRANSACTION,代码定义如下:

case BR_TRANSACTION:

{

binder_transaction_data tr;

result = mIn.read(&tr, sizeof(tr));

if (result != NO_ERROR) break; // 数据异常直接退出

Parcel buffer;

//用从驱动接收的数据设置Parcel对象Buffer

buffer.ipcSetDataReference(

reinterpret_cast<const uint8_t*>(tr.data.ptr.buffer),

tr.data_size,

reinterpret_cast<const binder_size_t*>(tr.data.ptr.offsets),

tr.offsets_size/sizeof(binder_size_t), freeBuffer, this);

const pid_t origPid = mCallingPid;

const uid_t origUid = mCallingUid;

const int32_t origStrictModePolicy = mStrictModePolicy;

const int32_t origTransactionBinderFlags = mLastTransactionBinderFlags;

// 从消息中取出调用者的进程ID和euid

mCallingPid = tr.sender_pid;

mCallingUid = tr.sender_euid;

mLastTransactionBinderFlags = tr.flags;

Parcel reply;

status_t error;

if (tr.target.ptr) {

// We only have a weak reference on the target object, so we must first try to

// safely acquire a strong reference before doing anything else with it.

if (reinterpret_castRefBase::weakref_type*(

tr.target.ptr)->attemptIncStrong(this)) {

// 如果ptr指针不为空,cookie保存的是BBinder的指针

// 调用cookie的transact函数

error = reinterpret_cast<BBinder*>(tr.cookie)->transact(tr.code, buffer,

&reply, tr.flags);

// 及时清除指针

reinterpret_cast<BBinder*>(tr.cookie)->decStrong(this);

} else {

error = UNKNOWN_TRANSACTION;

}

} else {

//如果tr.target.ptr为0 表示是ServiceManager

error = the_context_object->transact(tr.code, buffer, &reply, tr.flags);

}

// 如果是同步调用,则把reply对象发送回去,否则什么也不做

if ((tr.flags & TF_ONE_WAY) == 0) {

LOG_ONEWAY(“Sending reply to %d!”, mCallingPid);

if (error < NO_ERROR) reply.setError(error);

sendReply(reply, 0);

} else {

LOG_ONEWAY(“NOT sending reply to %d!”, mCallingPid);

}

mCallingPid = origPid;

mCallingUid = origUid;

mStrictModePolicy = origStrictModePolicy;

mLastTransactionBinderFlags = origTransactionBinderFlags;

}

break;

《960全网最全Android开发笔记》

《379页Android开发面试宝典》

《507页Android开发相关源码解析》

因为文件太多,全部展示会影响篇幅,暂时就先列举这些部分截图

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
不为空,cookie保存的是BBinder的指针

// 调用cookie的transact函数

error = reinterpret_cast<BBinder*>(tr.cookie)->transact(tr.code, buffer,

&reply, tr.flags);

// 及时清除指针

reinterpret_cast<BBinder*>(tr.cookie)->decStrong(this);

} else {

error = UNKNOWN_TRANSACTION;

}

} else {

//如果tr.target.ptr为0 表示是ServiceManager

error = the_context_object->transact(tr.code, buffer, &reply, tr.flags);

}

// 如果是同步调用,则把reply对象发送回去,否则什么也不做

if ((tr.flags & TF_ONE_WAY) == 0) {

LOG_ONEWAY(“Sending reply to %d!”, mCallingPid);

if (error < NO_ERROR) reply.setError(error);

sendReply(reply, 0);

} else {

LOG_ONEWAY(“NOT sending reply to %d!”, mCallingPid);

}

mCallingPid = origPid;

mCallingUid = origUid;

mStrictModePolicy = origStrictModePolicy;

mLastTransactionBinderFlags = origTransactionBinderFlags;

}

break;

《960全网最全Android开发笔记》

[外链图片转存中…(img-i2pHCsHo-1715335885588)]

《379页Android开发面试宝典》

[外链图片转存中…(img-ucQ73qo6-1715335885589)]

《507页Android开发相关源码解析》

[外链图片转存中…(img-dud60ICE-1715335885589)]

因为文件太多,全部展示会影响篇幅,暂时就先列举这些部分截图

《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值