IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 移动开发 -> Android进阶笔记-5. IPC机制 & Binder 原理 -> 正文阅读

[移动开发]Android进阶笔记-5. IPC机制 & Binder 原理

IPC机制

一次进程间通信至少包含两个进程,由于进程隔离机制的存在,通信双方必然需要借助 IPC(进程间通信,inter-Process Communication)来实现;

Linux中的IPC机制种类

管道(pipe)
  • 继承自Unix,半双工通信方式(数据只能在一个方向上流动);
  • 原理:通信双方利用内存的共享文件来传递信息;
信号(sinal)
  • 异步通信方式,软件层对中断机制的一种模拟,例如内核通知用户空间进程发生了哪些系统事件
  • 不适用于信息交换,适用于进程中断控制;
信号量(semophore)
  • 是一个计数器,控制多个进程对共享资源的访问,常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源;
  • 主要作为进程间以及同一进程内不同线程之间的同步手段;
消息队列(Message)
  • 具有特定格式的消息链表,存放在内存中并由消息队列标识符标识,并且允许一个或多个进程向它写入与读取消息;
  • 信息会复制两次,因此对于频繁或者信息量大的通信不宜使用消息队列;
共享内存(Share Memory)
  • 多个进程读写一块内存空间,由需要访问的进程将其映射到自己的私有地址空间,不需要进行数据的拷贝,效率较高;
套接字(Socket)
  • 可用于不同机器之间的进程间通信

Android中的IPC机制

序列化
  • Serializable: java提供,空接口,开销大,大量的IO操作;
  • Parcelable: android提供,通过intent,binder传递,效率要更高,我们常用的Bundle就是其实现类;
Serializable与Parcelable区别
  • Serializable:Java 序列化接口,在硬盘上读写,读写过程中有大量临时变量的生成,内部执行大量的i/o操作,效率很低;
  • Parcelable:Android 序列化接口,效率高, 使用麻烦, 在内存中读写(AS有相关插件 一键生成所需方法),对象不能保存到磁盘中;
AIDL
服务端:
  • 创建要操作的实体类,实现 Parcelable 接口
  • 新建aidl文件夹,在其中创建接口aidl文件以及实体类的映射aidl文件
  • Make project
  • 服务service中实现AidlInterface.Stub对象, 并在onBind方法中返回
客户端:
  • copy服务端提供的aidl文件夹和实体类
  • Make project
  • ServiceConnection.onServiceConnected方法中调用AidlInterface.Stub.asInterface(IBinder)创建AidlInterface实例,并调用其方法
Bundle
  • 实现了Parcelable接口,所以它可以方便的在不同的进程间传输。
  • 四大组件中的Activity,Service,Receiver都支持使用Bundle传递数据,所以当我们启动不同进程的组件时,就可以用Intent+bundle进行数据传递
共享文件和SharedPreferences
  • 两个进程通过读写同一个文件来交换数据
  • 对象可以通过序列化和反序列化进行读写
  • SharedPreferences本质就是读写文件
Messenger(信使,轻量级的IPC方案)
  • 通过它可以在不同进程间传递Message对象
  • 底层其实就是AIDL, 只是进一步进行了封装, 以方便使用,从下面这个构造方法就可以看出
public Messenger(IBinder target) {
    mTarget = IMessenger.Stub.asInterface(target);
}
Socket
  • 分为 流式套接字 和 用户数据报套接字 两种, 分别对应于网络的传输控制层中的TCP和UDP协议
  • TCP: 面向连接的协议,提供稳定的双向通信功能,连接的建立需要经过三次握手才能完成,
    为了提供稳定的数据传输功能,其本身提供了超时重传机制
  • UDP: 无连接,提供不稳定的单向通信功能(也可以实现双向通信),具有更好的效率,但不能保证数据一定能
    正确传输,尤其是在网络拥塞的情况下
ContentProvider
  • Android中提供的专门用于不同应用间进行数据共享的方式,天生就适合进程间通信
  • 底层实现同样也是Binder,而且比AIDL要简单
  • 需要注意的: CRUD操作,防止SQL注入,权限控制
  • 系统中预制了许多ContentProvider,如通讯录,日程表信息等
  • query,insert,delete,update是运行在Binder线程中的, 存在多线程并发访问,方法内部要做好线程同步
    ,例如BookProvider中采用了SQLite,并且只有一个SQLiteDatabase的链接, 所以可以正确的应对多线程情况,
    因为SQLiteDatabase内部对数据库的操作是有同步处理的
  • onCreate运行在main线程(UI线程),不能进行耗时操作
Binder
  • 基于Binder的: AIDL,Messenger,ContentProvider
  • 从Android Framework角度讲,Binder是ServiceManager链接各种Manager(ActivityManager,WindowManager等)和ManagerService的桥梁
  • 从Android 应用层来说,Binder是客户端和服务端进行通信的媒介(bindService)
  • Binder主要用在Service中,包括普通的Service,AIDL和Messenger
  • 普通的Service中的Binder不涉及进程间通信,没有触及到Binder的核心

Binder 跨进程通信原理

为什么要使用Binder

1. 性能
  • 主要影响的因素是拷贝次数:
  1. 管道、消息队列、Socket的拷贝次书都是两次,性能不是很好;
  2. 共享内存不需要拷贝,性能最好;
  3. Binder拷贝1次,性能仅次于共享内存;
Linux 下传统的进程间通信原理与不足
  • 内核程序在内核空间分配内存并开辟一块内核缓存区,发送进程通过copy_from_user函数将数据拷贝到到内核空间的缓冲区中。同样的,接收进程在接收数据时在自己的用户空间开辟一块内存缓存区,然后内核程序调用 copy_to_user() 函数将数据从内核缓存区拷贝到接收进程。这样数据发送进程和数据接收进程完成了一次数据传输,也就是一次进程间通信;其有两点不足之处:
  1. 一次数据传递需要经历:用户空间 –> 内核缓存区 –> 用户空间,需要2次数据拷贝,效率不高。
  2. 接收数据的缓存区由数据接收进程提供,但是接收进程并不知道需要多大的空间来存放将要传递过来的数据,因此只能开辟尽可能大的内存空间或者先调用API接收消息头来获取消息体的大小,浪费了空间或者时间。
2. 稳定性
  1. Binder是基于C/S架构的,技术上已经很成熟,稳定;
  2. 共享内存没有分层,难以控制,并发同步访问临界资源时,可能还会产生死锁;
  • 从稳定性的角度讲,Binder是优于共享内存的。
3. 安全
  • Android是一个开源的系统,并且拥有开放性的平台,市场上应用来源很广,因此安全性对于Android 平台而言极其重要。
    传统的IPC接收方无法获得对方可靠的进程用户ID/进程ID(UID/PID),无法鉴别对方身份。Android 为每个安装好的APP分配了自己的UID,
    通过进程的UID来鉴别进程身份。另外,Android系统中的Server端会判断UID/PID是否满足访问权限,而对外只暴露Client端,加强了系统的安全性。
4. 语言
  • Linux是基于C语言,C语言是面向过程的,Android应用层和Java Framework是基于Java语言,Java语言是面向对象的。
  • Binder本身符合面向对象的思想,因此作为Android的通信机制更合适。

Android中Binder的来源

  • Binder是基于开源的OpenBinder实现的,OpenBinder最早并不是由Google公司开发的,而是Be Inc公司开发的,接着由Palm, Inc.公司负责开发。后来OpenBinder的作者Dianne Hackborn加入了Google公司,并负责Android平台的开发工作,顺便把这项技术也带进了Android。

Linux的动态内核可加载模块

  • 跨进程通信是需要内核空间做支持的, 传统的 IPC 机制如管道、Socket 都是内核的一部分,因此通过内核支持来实现进程间通信自然是没问题的。但是 Binder 并不是 Linux 系统内核的一部分,那怎么办呢?
  • 这就得益于 Linux 的动态内核可加载模块(Loadable Kernel Module,LKM)的机制;模块是具有独立功能的程序,它可以被单独编译,但是不能独立运行。它在运行时被链接到内核作为内核的一部分运行。这样,Android 系统就可以通过动态添加一个内核模块运行在内核空间,用户进程之间通过这个内核模块作为桥梁来实现通信。

Binder 驱动

  • 在 Android 系统中,这个运行在内核空间,负责各个用户进程通过 Binder 实现通信的内核模块就叫 Binder 驱动(Binder Dirver)。
  • Binder驱动是Android专用的,但底层的驱动架构与Linux驱动一样。binder驱动在以misc设备进行注册,作为虚拟字符设备,没有直接操作硬件,只是对设备内存的处理。主要是驱动设备的初始化(binder_init),打开 (binder_open),映射(binder_mmap),数据操作(binder_ioctl)。
  • Binder是基于内存映射来实现的,内存映射通常是用在有物理介质的文件系统上的,而 Binder 并不存在物理介质,因此 Binder 驱动使用 mmap() 并不是为了在物理介质和用户空间之间建立映射,而是用来在内核空间创建数据接收的缓存空间, 是为了跨进程传递数据。
内核空间(KernelSpace)和用户空间(UserSpace)
  • 为了保证内核的安全,不能让用户进程直接操作内核,操作系统从逻辑上将虚拟空间划分为用户空间和内核空间,即使用户的程序崩溃了,内核也不会受到影响, 内核空间的数据是可以进程间共享的;
进程隔离
  • 一个进程不能直接操作或者访问另一个进程
系统调用
  • 系统调用是用户空间访问内核空间的唯一方式,保证了所有的资源访问都是在内核的控制下进行的,避免了用户程序对系统资源的越权访问,提升了系统安全性和稳定性。
  • copy_from_user:将用户空间的数据拷贝到内核空间。
  • copy_to_user:将内核空间的数据拷贝到用户空间。
内存映射(Memory Map)
  • 由于应用程序不能直接操作设备硬件地址,所以操作系统提供了一种机制:内存映射,把设备地址映射到进程虚拟内存区
  • 例如用户空间读取磁盘文件,如果不采用内存映射,那么就需要在内核空间建立一个页缓存,页缓存去拷贝磁盘上的文件,然后用户空间拷贝页缓存的文件,这就需要两次拷贝。 如果采用内存映射,由于新建了虚拟内存区,那么磁盘文件和虚拟内存区域就可以直接映射,少了一次拷贝。
  • 在Linux中通过系统调用函数mmap来实现内存映射。将用户空间的一块内存区域映射到内核空间。映射关系建立后,用户对这块内存区域的修改可以直接反应到内核空间,反之亦然。内存映射能减少数据拷贝次数,实现用户空间和内核空间的高效互动。
Binder驱动通过内存映射进行跨进程通信的步骤
  1. Binder驱动在内核空间创建一个数据接收缓存区。
  2. 在内核空间开辟一块内核缓存区,建立内核缓存区和数据接收缓存区之间的映射关系,以及数据接收缓存区和接收进程用户空间地址的映射关系。
  3. 发送方进程通过copy_from_user()函数将数据拷贝 到内核中的内核缓存区,由于内核缓存区和接收进程的用户空间存在内存映射,
    因此也就相当于把数据发送到了接收进程的用户空间,这样便完成了一次进程间的通信,整个过程只使用了1次拷贝,效率更高。
  • 系统中并不是所有的进程通信都是采用了Binder,而是根据场景选择最合适的,比如Zygote进程与AMS通信使用的是Socket,Kill Process采用的是信号。

基于Binder通信的C/S架构

  • 系统在启动时,SystemServer进程启动后会创建Binder线程池,目的是通过Binder,使得在SystemServer进程中的服务(如AMS、PMS)可以和其他进程进行通信;
  • 我们常说的AMS、PMS都是基于Binder来实现的,再比如Client端的MediaPlayer和Server端的MeidaPlayerService不是运行在一个进程中的,同样需要Binder来实现通信。

img

  • Binder 是基于 C/S 架构的,其中 Client进程、Server进程、Service Manager进程 运行在用户空间,Binder驱动 运行在内核空间。Client、Server 和 ServiceManager 之间的交互通过系统调用 open、mmap 和 ioctl 来访问设备文件 /dev/binder(Binder驱动,在内核空间通过mmap实现跨进程通信),间接的实现跨进程通信。
  • Binder驱动,Service Manager进程 属于 Android基础架构(系统已经实现好了);
  • Client 进程 和 Server 进程 属于Android应用层(需要开发者自己实现)

Binder机制 在Android中的具体实现

  • Binder机制在 Android中的实现主要依靠 Binder类,其实现了IBinder 接口

Binder 通信过程

1. 启动ServiceManager
  • ServiceManager是init进程负责启动的,具体是在解析init.rc配置文件时启动的,init进程是在系统启动时启动的;
  • servicemanager的入口函数在service_manager.c中
int main(int argc, char** argv)
{
    //binder_state结构体用来存储binder的三个信息:
    //int fd; //binder设备的文件描述符
    //void *mapped; //binder设备文件映射到进程的地址空间
    //size_t mapsize; //内存映射后,系统分配的地址空间的大小,默认为128KB
    struct binder_state *bs;
    union selinux_callback cb;
    char *driver;

    if (argc > 1) {
        driver = argv[1];
    } else {
        driver = "/dev/binder";
    }

    //binder_open:打开binder设备文件,调用mmap函数进行内存映射,申请128k字节大小的内存空间
    bs = binder_open(driver, 128*1024);
    ...
    //binder_become_context_manager:将servicemanager注册成为Binder机制的上下文管理者,这个管理者在整个系统只有一个
    if (binder_become_context_manager(bs)) {
        ALOGE("cannot become context manager (%s)\n", strerror(errno));
        return -1;
    }
    ...
    if (getcon(&service_manager_context) != 0) {
        ALOGE("SELinux: Failed to acquire service_manager context. Aborting.\n");
        abort();
    }
    //servicemanager成功注册成为Binder机制的上下文管理者后,servicemanager就是Binder机制的“总管”了,
    //它需要在系统运行期间处理client端的请求,由于client端的请求不确定何时发送,因此需要通过无限循环来实现
    binder_loop(bs, svcmgr_handler);

    return 0;
}
  • 上面代码可以看出,servicemanager的入口函数主要做了三件事
  1. 打开binder设备文件,调用mmap函数进行内存映射,申请128k字节大小的内存空间
  2. 将servicemanager注册成为Binder机制的上下文管理者,这个管理者在整个系统只有一个
  3. servicemanager成功注册成为Binder机制的上下文管理者后,servicemanager就是Binder机制的“总管”了,它需要在系统运行期间通过无限循环处理client端的请求
用户态和内核态
  • 当一个进程在执行用户自己的代码时处于用户态,比如open函数,它运行在用户空间,当前的进程处于用户态。
  • 当一个进程因为系统调用进入内核代码中执行时就处于内核态,比如open函数通过系统调用(__open()函数),查找到了open函数在Kernel Binder对应的函数为binder_open,这时binder_open运行在内核空间,当前的进程由用户态切换到内核态。
2. 注册服务
  • Server通过Binder驱动向 ServiceManager 中注册 Binder(Server 中的 Binder 实体),表明可以对外提供服务。Binder 驱动为这个 Binder 创建位于内核中的实体节点以及 ServiceManager 对实体的引用,将名字以及新建的引用打包传给 ServiceManager,ServiceManger 将其填入查找表。
  • 例如MediaPlayerService是Server端,MediaPlayerService是系统多媒体服务的一种,系统多媒体服务是由一个叫做MediaServer的服务进程提供的;
  • 在Android系统启动时,MediaServer也被启动,入口函数如下
int main(int argc __unused, char **argv __unused)
{
    signal(SIGPIPE, SIG_IGN);
    //获取ProcessState实例,打开/dev/binder设备,设定Binder最大的支持线程数
    //并使用mmap为Binder驱动分配一个虚拟地址空间用来接收数据。
    sp<ProcessState> proc(ProcessState::self());
    //获取ServiceManager,其他进程就可以和当前的ServiceManager进行交互
    //ServiceManager中使用了Binder通信(BpBinder),它自身也是属于Binder体系的
    //这里得到的实例是BpServiceManager,它实现了IServiceManager,并且通过BpBinder来实现通信
    sp<IServiceManager> sm(defaultServiceManager());
    ALOGI("ServiceManager: %p", sm.get());
    InitializeIcuOrDie();
    //注册MediaPlayerService,
    //其中调用了defaultServiceManager()->addService将数据打包发送给BpBinder来进行处理
    //BpBinder新建一个IPCThreadState对象,并将通信的任务交给IPCThreadState
    //IPCThreadState的writeTransactionData函数用于将命令协议和数据写入到mOut中
    //IPCThreadState的waitForResponse函数主要做了两件事,
    //一件事是通过ioctl函数操作mOut和mIn来与Binder驱动进行数据交互,另一件事是处理各种命令协议。
    MediaPlayerService::instantiate();
    ResourceManagerService::instantiate();
    registerExtensions();
    //启动Binder线程池
    ProcessState::self()->startThreadPool();
    //当前线程加入到线程池
    IPCThreadState::self()->joinThreadPool();
}

- BpBinder,BBinder是Binder通信的“双子星”,都继承了IBinder;
- BpBinder是Client端与Server交互的代理类,而BBinder则代表了Server端。
- BpBinder和BBinder是一一对应的,BpBinder会通过handle来找到对应的BBinder
- BpBinder和BBinder负责Binder的通信,而IServiceManager用于处理ServiceManager的业务
  1. MediaServer通过Binder驱动,使用mmap接收数据;
  2. 获取一个IServiceManager的实例BpServiceManager,其并且通过BpBinder来实现通信;
  3. 通过defaultServiceManager注册MediaPlayerService,这里MediaPlayerService是Client端,用于请求添加系统服务。而Server端则是指的是ServiceManager,用于完成系统服务的添加,两端通过向Binder驱动发送命令协议来完成系统服务的添加;
3. 获取服务
  • Client进程通过Binder驱动 向 ServiceManager进程 获取相应的Service信息(Binder代理对象,具有和 Binder 一样的方法,但这些方法并没有 Server 进程中 Binder 对象那些方法的能力,这些方法只需要把把请求参数交给Binder驱动即可。对于 Client 进程来说和直接调用 Binder 中的方法是一样的)
  • 系统服务的注册流程中,在Kernel Binder中会调用do_add_service函数,其内部会将包含服务名和handle值的svcinfo保存到svclist列表中。同样的,在获取服务的流程中,find_svc函数中会遍历svclist列表,根据服务名查找对应服务是否已经注册,如果已经注册就会返回对应的svcinfo,如果没有注册就返回NULL。
  • 服务端ServiceManager处理请求:servicemanager的入口函数中调用了binder_loop函数,其在无限循环中不断的调用ioctl函数,它不断的使用BINDER_WRITE_READ指令查询Binder驱动中是否有新的请求;
  • 仍旧以MediaPlayerService为例,获取MediaPlayerService需要调用getMediaPlayerService函数
IMediaDeathNotifier::getMediaPlayerService()
{
    ALOGV("getMediaPlayerService");
    Mutex::Autolock _l(sServiceLock);
    if (sMediaPlayerService == 0) {
        sp<IServiceManager> sm = defaultServiceManager();//实例为BpServiceManager
        sp<IBinder> binder;
        do {
            //获取名为”media.player”的系统服务(MediaPlayerService),返回的值为BpBinder
            binder = sm->getService(String16("media.player"));
            if (binder != 0) {
                break;
            }
            //这个时候MediaPlayerService可能还没有向ServiceManager注册
            //所以休眠0.5s后继续调用getService,直到获取服务对应的为止
            usleep(500000); //4
        } while (true);

        if (sDeathNotifier == NULL) {
            sDeathNotifier = new DeathNotifier();
        }
        binder->linkToDeath(sDeathNotifier);
        //interface_cast通过BpBinder的handle来找到对应的服务,即BpMediaPlayerService。
        sMediaPlayerService = interface_cast<IMediaPlayerService>(binder);
    }
    ALOGE_IF(sMediaPlayerService == 0, "no media player service!?");
    return sMediaPlayerService;
}
  • 通过getMediaPlayerService获取名为”media.player”的系统服务(MediaPlayerService),其中调用interface_cast通过BpBinder的handle来找到对应的服务,即BpMediaPlayerService
4. 使用服务
  • Client进程 根据获取到的 Service信息(Binder代理对象BinderProxy),通过Binder驱动 建立与 该Service所在Server进程通信的链路,并开始使用服务
  • Binder和BinderProxy实现了IBinder接口,Binder是服务端的代表,而BinderProxy是客户端的代表; Parcel是一个数据包装器,它可以在进程间进行传递,Parcel既可以传递基本数据类型也可以传递Binder对象,Binder通信就是通过Parcel来进行客户端与服务端数据交互。
  • 例如之前的Android进阶笔记-3. Service 启动过程 & 绑定过程中讲过的ContextImpl的startService方法
private ComponentName startServiceCommon(Intent service, boolean requireForeground, UserHandle user) {
    try {
        ...
        //
        ComponentName cn = ActivityManager.getService().startService(
                mMainThread.getApplicationThread(), service,
                service.resolveTypeIfNeeded(getContentResolver()), requireForeground,
                getOpPackageName(), getAttributionTag(), user.getIdentifier());
        ...
        return cn;
    } catch (RemoteException e) {
        throw e.rethrowFromSystemServer();
    }
}
  • 上面代码调用ActivityManager.getService()获取到ActivityManagerService的代理类,并调用了startService方法,而startService方法的实现是在ActivityManagerService中
  • ActivityManager.getService()代码如下, 其中调用了ServiceManager.getService(Context.ACTIVITY_SERVICE)
@UnsupportedAppUsage
public static IActivityManager getService() {
    return IActivityManagerSingleton.get();
}

@UnsupportedAppUsage
private static final Singleton<IActivityManager> IActivityManagerSingleton =
    new Singleton<IActivityManager>() {
        @Override
        protected IActivityManager create() {
            final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
            final IActivityManager am = IActivityManager.Stub.asInterface(b);
            return am;
        }
    };
从不同角度看Binder定义
  1. 从IPC角度:Binder是Android中的一种跨进程通信方式,该通信方式在linux中没有,是Android独有;
  2. 从Android Driver层:Binder还可以理解为一种虚拟的物理设备,它的设备驱动是/dev/binder;
  3. 从Android Framework层:Binder是各种Manager(ActivityManager、WindowManager等)和相应xxxManagerService的桥梁;
  4. 从Android APP层:Binder是客户端和服务端进行通信的媒介,当bindService的时候,服务端会返回一个包含了服务端业务调用的 Binder对象,通过这个Binder对象,客户端就可以获取服务端提供的服务或者数据,这里的服务包括普通服务和基于AIDL的服务
  5. 从 Server 进程的角度,Binder 指的是 Server 中的 Binder 实体对象;
  6. 从 Client 进程的角度,Binder 指的是对 Binder 代理对象,是 Binder 实体对象的一个远程代理
  7. 从传输过程的角度,Binder 是一个可以跨进程传输的对象;Binder 驱动会对这个跨越进程边界的对象对一点点特殊处理,自动完成代理对象和本地对象之间的转换。

参考

我是今阳,如果想要进阶和了解更多的干货,欢迎关注微信公众号 “今阳说” 接收我的最新文章

  移动开发 最新文章
Vue3装载axios和element-ui
android adb cmd
【xcode】Xcode常用快捷键与技巧
Android开发中的线程池使用
Java 和 Android 的 Base64
Android 测试文字编码格式
微信小程序支付
安卓权限记录
知乎之自动养号
【Android Jetpack】DataStore
上一篇文章      下一篇文章      查看所有文章
加:2022-01-14 02:05:57  更:2022-01-14 02:07:13 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 10:45:54-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码