Android 进程通信——Binder

1. Linux 中的 IPC 机制

本文主要是对 Binder 的相关内容进行的总结,持续更新。

1.1 管道

管道的主要思想是在内存中创建一个共享文件 ,使通信双方可以利用这个共享文件来传递信息。 管道采用半双工通信方式,数据只能在一个方向上流动。 假设进程 1 往文件内写东西,那么进程 2 只能读取文件的内容。

场景: 在 Android 9 之前,Handler 使用管道(pipe)和 epoll 来实现消息唤醒和处理机制。在 Android 9 之后,Handler 不再使用管道,而是使用了 eventfd 结合 epoll 来实现更高效的消息唤醒和处理机制。

1.2 消息队列

消息队列是在内核中维护的一个消息链表,消息可以按照一定的顺序存储和读取。队列中的消息均由标识符进行标识,可以做到需要什么数据就读什么数据,不像管道,只能是读发送方写的数据。

发送方可以将消息发送到队列中而无需等待接收方处理,接收方可以在方便时读取消息。消息队列允许一个或多个进程向它读取或写入消息。

场景: Handler 是 Android 中用于线程间消息传递的机制,底层也是基于消息队列实现。

1.3 信号量

信号量是一个计数器,用来控制多个进程对共享资源的访问,它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

信号量会维护一个计数器,表示当前可用的资源数量。当进程请求资源时,计数器减少;当进程释放资源时,计数器增加。如果计数器为零,表示资源不可用,进程进入等待状态。

场景: 可以使用信号量机制完成对 ANR 的监控。

1.4 共享存储

允许多个进程直接读写一块内存空间。多个进程可以将同一文件映射到它们的地址空间从而实现共享内存。一般情况,写入数据将数据从用户空间拷贝到内核空间,读取数据时将数据从内核空间拷贝到用户空间,效率低。 共享内存是最快的IPC形式,不需要进行数据间的复制。

由于多个进程可以同时访问共享内存区,通常需要同步机制(如信号量、互斥锁)来避免数据竞争和不一致的问题。

场景: MMKV 的实现。MMKV 使用 mmap 将存储文件映射到内存中,这样可以直接在内存中读写数据,,实现进程间共享数据,避免了频繁的磁盘 I/O 操作。

1.5 套接字

与其他通信机制不同的是,它可以用于不同机器之间的进程通信。套接字是网络通信的基础,可以用于实现客户端和服务器之间的数据传输。例如,HTTP 客户端、WebSocket 客户端等都是基于套接字实现的。

2. Android 中的 IPC 机制

Android 中实现 IPC 的方式也有多种,比如 Binder 、 Messenger、 ContentProvider 等。虽然 Android 提供了多种 IPC 机制,但它们的底层实现大多依赖于 Binder。 Binder 是Android 系统的核心 IPC 机制,提供了高效的进程间通信。

2.1 Binder 简介

普通的 Linux IPC 通信,通信一次需要两次数据复制 ,效率不高(通过两个系统调用 copy_from_user 和 copy_to_user):

但是 Binder 只需要复制一次即可:

通过 mmap 的方式,将指定大小的 "物理内存" 映射到 "用户空间",也映射到 "内核空间" 。所以只需要数据发送方将数据拷贝一次到内核空间后,就可以通过映射的方式让数据接收方获取到数据。

Binder 驱动实际上是属于 Kernel 层的,为了更方便上层使用,又封装成了 Native 层和 Java 层。

2.2 Native Binder

为了更好地了解 Java Binder,我们先从 Native Binder 说起。首先要明确一点,Binder 是基于 C/S 架构的。

除了常规的服务端和客户端,基于 Binder 通信的 C/S 架构还包含了 ServiceManager ,用来管理系统的服务。Server 会事先将一些服务注册到 ServiceManager 中,当 Client 需要使用时,去 ServiceManager 查询对应的服务,即可建立进程通信链路。

下面我们通过一个典型的跨进程通信调用流程来进行说明。

2.2.1 ServiceManager 的启动

ServiceManager 是一个特殊的进程,负责管理系统中的所有 Binder 服务。它提供了服务注册、查询和获取的功能。

init 进程是 Android 系统的第一个用户空间进程,负责启动系统的各个组件。init 进程启动后,通过解析 init.rc 脚本文件来启动各种服务和守护进程(Zygote 进程、servicemanger 进程)。

c++ 复制代码
service servicemanager /system/bin/servicemanager
    class core
    user system
    group system
    // 说明servicemanager时系统中的关键服务,关键服务是不会退出的。
    critical

当 servicemanger 进程启动后,会调用 binder_open 来打开 Binder 设备,获取文件描述符。 并将 servicemanager 注册成为 Binder 机制的上下文管理者,这个管理者在整个系统中只有一个。

当 servicemanager 成为 Binder 机制的上下文管理者之后,它就是 Binder 机制的总管了,由于 Client 的请求不确定什么时候会发过来,所以用 while 循环。通过 binder_loop 循环等待和处理 Client 发来的请求。

2.2.2 Server 进程注册服务

(a)创建 Binder 对象

服务端进程首先需要创建一个 Binder 对象,然后把 Binder 对象注册到 ServiceManager 中,才能被客户端发现和访问。

c++ 复制代码
class MyBinder : public BBinder {
public:
    virtual status_t onTransact(uint32_t code, const Parcel& data, Parcel* reply, uint32_t flags = 0) {
        // 处理客户端请求
        return NO_ERROR;
    }
};

创建 Binder 对象使用的是 BBinder 。BpBinder 和 BpBinder 都继承自 IBinder 。

(a)BBinder 和 BpBinder

因为 C/S 架构 客户端和服务端分离的 特点, BpBinder 和 BBinder 分别位于客户端进程和服务端进程中:

  • BpBinder :代表远程服务的代理对象,位于客户端进程中。负责将客户端的请求打包并发送到服务端,同时接收服务端的响应并解包返回给客户端。
  • BBinder :代表实际服务对象,位于服务端进程中。负责接收来自客户端的请求,并将其分发给实际的服务实现,处理完请求后将结果返回给客户端。

(c)注册服务

Server 进程需要将创建的 Binder 对象注册到 ServiceManager 中,使其可以被客户端发现和访问。ServiceManager 继承自 IServiceManager,提供了以下接口:

  • getService:获取已经注册的系统服务。
  • addService:注册一个新的系统服务。
  • checkService:检查一个服务是否存在。
  • listServices:列出所有已注册的系统服务。
c++ 复制代码
sp<IServiceManager> sm = defaultServiceManager();
// 注册服务
sm->addService(String16("MyService"), new MyBinder());

IServiceManager 使用 BBinder 来表示本地的 Binder 对象。

2.2.3 Client 进程请求服务

(a)获取服务

客户端进程需要从 ServiceManager 获取服务端的 Binder 对象。

c++ 复制代码
sp<IServiceManager> sm = defaultServiceManager();
sp<IBinder> binder = sm->getService(String16("MyService"));

(b)发送请求

Client 进程通过获取到的 Binder 对象发送请求。请求通过 Parcel 对象进行封装。

c++ 复制代码
Parcel data, reply;
data.writeInterfaceToken(String16("MyService"));
data.writeInt32(42); // 发送数据
binder->transact(0, data, &reply); // 发送请求
int32_t result = reply.readInt32(); // 读取服务端返回的数据

2.2.4 总结

因为对于 Native Binder,我们没有必要太执着于源码细节,所以我这里只介绍了大概的原理框架。

2.3 Java Binder

Java Binder 实际上是对 Native Binder 做的上层封装,这一过程的核心同样是 ServiceManager 。不过这个 ServiceManager 是 Java 文件。

同样的,我们以 Activity 和 Service 通信作为例子看一下流程。

2.3.1 将 AMS 注册到 ServiceManager

ServiceManager 中保存了一系列服务,例如 AMS,WMS,PMS 等等。AMS 是位于 system_server 进程中的(是zygote fork出来的), ServiceManager 是在 servicemanager 进程中。ServiceManager 像一个路由表,客户端想要和服务端通信时,都需要从 ServiceManager 中获取服务。

首先,需要将 ActivityManagerService(AMS) 注册到 ServiceManager:

java 复制代码
// ActivityManagerService.java
public void setSystemProcess() {
    try {
        ServiceManager.addService(Context.ACTIVITY_SERVICE, this, /* allowIsolated= */ true, DUMP_FLAG_PRIORITY_CRITICAL | DUMP_FLAG_PRIORITY_NORMAL | DUMP_FLAG_PROTO);
        ...
        }
    } catch (PackageManager.NameNotFoundException e) {
        throw new RuntimeException(
                "Unable to find android system package", e);
    }
    ...
}

AMS 中提供了 setSystemProcess 来注册服务,里面调用 ServiceManager 的 addService :

java 复制代码
// ServiceManager.java
public static void addService(String name, IBinder service, boolean allowIsolated,
        int dumpPriority) {
    try {
        getIServiceManager().addService(name, service, allowIsolated, dumpPriority);
    } catch (RemoteException e) {
        Log.e(TAG, "error in addService", e);
    }
}

2.3.2 Activity 与 Service 通信流程

这张图能比较清晰的说明通信的流程,总共涉及到了 6 次 IPC。需要注意的是 ServiceManager 是在 servicemanager 进程中。AMS 是位于 system_server 进程中。

  • Activity 触发 bindService() 时,会先去 ServiceManager 中获取 AMS,并返回给 Activity。
  • Activity 有了 AMS 的 IBinder 后,就可以和 AMS 通信了,调用 AMS.bindService() 来绑定服务端。
  • 服务端位了能和 AMS 通信,也同样需要去ServiceManager 中获取 AMS。
  • 服务端获取到 AMS 的 IBinder 后,就可以和 AMS 通信,发布自己的 IBinder 给 AMS。
  • AMS 与客户端通信,转发服务端的 IBinder(代理)。

结合图一起分析,还是非常易懂的。

2.4 AIDL 的简单使用

AIDL是一个标准,封装了 Binder,简便使用。因为 AIDL 的使用比较简单,我们简略说一下。

(1)服务端在提供服务的时候需要创建一个 AIDL 文件,里面封装服务器接口。

java 复制代码
// IBookManager.aidl
interface IBookManager {
    List<Book> getBookList();
    void addBook(in Book book);
}

// Book.aidl
parcelable Book;

// Book.java
public class Book implements Parcelable {

    public int bookId;
    public String bookName;

    public Book(int bookId, String bookName) {
        this.bookId = bookId;
        this.bookName = bookName;
    }

    public Book() {
    }

    public static final Parcelable.Creator<Book> CREATOR = new Creator<Book>() {
        @Override
        public Book createFromParcel(Parcel parcel) {
            return new Book(parcel);
        }

        @Override
        public Book[] newArray(int i) {
            return new Book[i];
        }
    };

    private Book(Parcel in) {
        bookId = in.readInt();
        bookName = in.readString();
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(@NonNull Parcel parcel, int i) {
        parcel.writeInt(bookId);
        parcel.writeString(bookName);
    }

    @Override
    public String toString() {
        return "Book{" +
                "bookId=" + bookId +
                ", bookName='" + bookName + '\'' +
                '}';
    }
}

(2)构建后系统会在 build 目录下自动生成一个类,该类中最重要的一个实现就是 Stub 。

(3)创建服务端,利用前面生成的 Stub,快速的创建 Binder 对象:

java 复制代码
public class BookManagerService extends Service {

    private static final String TAG = "BookManagerService";

    private CopyOnWriteArrayList<Book> mBookList = new CopyOnWriteArrayList<>();

    private Binder mBinder = new IBookManager.Stub() {
        @Override
        public List<Book> getBookList() throws RemoteException {
            return mBookList;
        }

        @Override
        public void addBook(Book book) throws RemoteException {
            mBookList.add(book);
        }
    };

    @Override
    public void onCreate() {
        super.onCreate();
        mBookList.add(new Book(1, "Android"));
        mBookList.add(new Book(2, "iOS"));
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return mBinder;
    }
}

将该 Service 配置到另一个进程,模拟跨进程通信:

(4)客户端调用,通过 bindService() 绑定远程服务器,绑定成功后将 Binder 对象转换成 AIDL 接口,这样就可以通过该接口来访问远端服务器的方法了:

java 复制代码
public class BookManagerActivity extends Activity {

    private static final String TAG = "BookManagerActivity";

    private ServiceConnection serviceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
            IBookManager manager = IBookManager.Stub.asInterface(iBinder);
            try {
                List<Book> bookList = manager.getBookList();
                Log.i(TAG, "query book list: " + bookList.toString());
                manager.addBook(new Book(3, "Android 开发艺术探索"));
                List<Book> newBookList = manager.getBookList();
                Log.i(TAG, "query new book list: " + newBookList.toString());
            } catch (RemoteException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void onServiceDisconnected(ComponentName componentName) {

        }
    };

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_aidl);
        Intent intent = new Intent(this, BookManagerService.class);
        bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
    }

    @Override
    protected void onDestroy() {
        unbindService(serviceConnection);
        super.onDestroy();
    }

}

上面只是简单的介绍了 AIDL 的使用,为了更好地理解,我们需要明白系统为我们自动生成的文件中,是如何对 Binder 进行封装的。

2.4.1 Stub

可以打开系统自动生成的文件查看,Stub 是一个继承了 Binder 的抽象类。

java 复制代码
public static abstract class Stub extends android.os.Binder implements com.project.charpter2.aidl.IBookManager {
    @Override public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException
    {
      java.lang.String descriptor = DESCRIPTOR;
      switch (code)
      {
        case INTERFACE_TRANSACTION:
        {
          reply.writeString(descriptor);
          return true;
        }
        case TRANSACTION_getBookList:
        {
          data.enforceInterface(descriptor);
          // 实际请求的结果
          java.util.List<com.mickiezhang.learnproject.charpter2.aidl.Book> _result = this.getBookList();
          reply.writeNoException();
          reply.writeTypedList(_result);
          return true;
        }
        case TRANSACTION_addBook:
        {
          data.enforceInterface(descriptor);
          com.mickiezhang.learnproject.charpter2.aidl.Book _arg0;
          if ((0!=data.readInt())) {
            _arg0 = com.mickiezhang.learnproject.charpter2.aidl.Book.CREATOR.createFromParcel(data);
          }
          else {
            _arg0 = null;
          }
          this.addBook(_arg0);
          reply.writeNoException();
          return true;
        }
        default:
        {
          return super.onTransact(code, data, reply, flags);
        }
      }
    }
  }

Stub 类负责处理底层的 IPC 机制,将客户端的调用转换为服务端的具体方法调用。代码中 onTransact 方法就是用于处理来自客户端的请求,可以看到 TRANSACTION_getBookList ,TRANSACTION_addBook 都是我们自己定义的服务端接口,Stub 通过解析客户端的请求,然后将客户端的请求分发到服务端的具体实现中。

Stub 中还有另外一个重要的方法 asInterface() :

java 复制代码
public static com.mickiezhang.learnproject.charpter2.aidl.IBookManager asInterface(android.os.IBinder obj)
{
    if ((obj==null)) {
      return null;
    }
    android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
    if (((iin!=null)&&(iin instanceof com.mickiezhang.learnproject.charpter2.aidl.IBookManager))) {
      return ((com.mickiezhang.learnproject.charpter2.aidl.IBookManager)iin);
    }
    return new com.mickiezhang.learnproject.charpter2.aidl.IBookManager.Stub.Proxy(obj);
}

这个方法的返回是一个 Proxy 对象,这就引入了 AIDL 的第二个重要封装 Proxy。

2.4.2 Proxy

Proxy 实现了 AIDL 接口的类,它在客户端中使用,用于向服务端发送请求。

java 复制代码
private static class Proxy implements com.mickiezhang.learnproject.charpter2.aidl.IBookManager
{
    private android.os.IBinder mRemote;
    Proxy(android.os.IBinder remote)
    {
        mRemote = remote;
    }
    @Override public android.os.IBinder asBinder()
    {
        return mRemote;
    }
    public java.lang.String getInterfaceDescriptor()
    {
        return DESCRIPTOR;
    }
    @Override public java.util.List<com.mickiezhang.learnproject.charpter2.aidl.Book> getBookList() throws android.os.RemoteException
    {
        android.os.Parcel _data = android.os.Parcel.obtain();
        android.os.Parcel _reply = android.os.Parcel.obtain();
        java.util.List<com.mickiezhang.learnproject.charpter2.aidl.Book> _result;
        try {
            _data.writeInterfaceToken(DESCRIPTOR);
            // 调用 transact 处理客户端请求
            boolean _status = mRemote.transact(Stub.TRANSACTION_getBookList, _data, _reply, 0);
            if (!_status && getDefaultImpl() != null) {
                return getDefaultImpl().getBookList();
            }
            _reply.readException();
            _result = _reply.createTypedArrayList(com.mickiezhang.learnproject.charpter2.aidl.Book.CREATOR);
        }
        finally {
            _reply.recycle();
            _data.recycle();
        }
        return _result;
    }
    @Override public void addBook(com.mickiezhang.learnproject.charpter2.aidl.Book book) throws android.os.RemoteException
    {
				...
    }
    public static com.mickiezhang.learnproject.charpter2.aidl.IBookManager sDefaultImpl;
}

因为 Proxy 实现了 AIDL 接口,这里以 getBookList() 为例,在将客户端 调用 getBookList() 的请求参数写入 Parcel 对象后,通过 IBinder.transact() 方法将请求发送到服务端。然后在前面说过的 Stub 的 onTransact 中处理请求。

所以,客户端在和服务端通信时,客户端会通过 Stub.asInterface 获得 AIDL 接口的代理对象 Proxy ,接收来自客户端的请求。收到请求后,会利用 transact() 将客户端请求的内容传递至 Stub.onTransact() 中处理,处理后才会真正去调用服务端的方法。

所以 AIDL 的作用,其实就是为开发者生成两个类:Stub 和 Proxy。这两个类都是一些模板代码,Stub 通常用于服务服务端,Proxy 通常用于服务客户端。 主要目的除了减轻开发者的工作量以外,也是为了隐藏跨进程通信的各种细节,使得开发者只需要关注实现具体的服务逻辑,而无需关心底层通信的细节。

说到这里,你有没有感觉到 Stub 和 Proxy 与我们在 Nativie Binder 中提到的 BBinder 和 BpBinder 的作用是很相似的,实际上 Stub 和 Proxy 也就是对 Native 层的 BBinder 和 BpBinder 的上层封装。

3. Binder 传递数据的限制

前面我们一直有说到 Binder 相较于传统的 IPC 方式只需要一次拷贝即可,实现一次拷贝依靠的就是 mmap 机制,所以还是有必要提一下 mmap 的。

传统IO 例如 InputStream,OutputStream 很慢,需要两次拷贝。而 mmap 的优势就是通过映射的方式,只需要从磁盘到主存的一次数据拷贝过程,从而使文件的内容可以像普通内存一样进行读写操作。

当应用启动时,存在这样一个启动顺序:zygote → fork → app_main.cpp → onZygoteInit() → ProcessState

当应用进程启动时,Zygote 进程会 fork 一个子进程作为应用进程。在应用进程初始化时,会启动 ActivityThread,并在其中进行各种初始化操作。当应用需要访问系统服务或进行跨进程通信时,会通过 Binder 机制进行跨进程通信,在第一次需要与 Binder 驱动通信时,会触发 ProcessState 实例的创建,ProcessState 代表了进程的状态,每个进程只有一个ProcessState。

c++ 复制代码
// frameworks/native/libs/binder/ProcessState.cpp 
sp<ProcessState> ProcessState::self() {
    Mutex::Autolock _l(gProcessMutex); 
    if (gProcess != NULL) { 
        return gProcess; 
    } 
    // 1:初始化 
    gProcess = new ProcessState("/dev/binder"); 
    return gProcess; 
}

从注释 1 可以看出 ProcessState 的创建采用了单例模式,确保了每个进程只会有一个 ProcessState 实例,传入的参数为 "/dev/binder",那么看看构造函数。

c++ 复制代码
// frameworks/native/libs/binder/ProcessState.cpp
ProcessState::ProcessState(const char *driver)
    // 1: 
    : mDriverFD(open_driver(driver))
    , mVMStart(MAP_FAILED)
    , mManagesContexts(false)
    , mBinderContextCheckFunc(NULL)
    , mBinderContextUserData(NULL)
    , mThreadPoolStarted(false)
    , mThreadPoolSeq(1)
{
    if (mDriverFD >= 0) {
        // 2:
        // mmap the binder, providing a chunk of virtual address space to receive transactions.
        mVMStart = mmap(0, BINDER_VM_SIZE, PROT_READ, MAP_PRIVATE | MAP_NORESERVE, mDriverFD, 0);
        if (mVMStart == MAP_FAILED) {
            // *sigh*
            ALOGE("Using /dev/binder failed: unable to mmap transaction memory.\n");
            close(mDriverFD);
            mDriverFD = -1;
        }
    }
    LOG_ALWAYS_FATAL_IF(mDriverFD < 0, "Binder driver could not be opened.  Terminating.");
}

注释 1 处通过 open_driver 打开了 "/dev/binder" 设备,并返回 fd ,这样就可以通过 fd 操作内核的 Binder 驱动了,然后将 Binder 支持的最大线程数设置成了 15。

c++ 复制代码
// frameworks/native/libs/binder/ProcessState.cpp
static int open_driver()
{
    int fd = open("/dev/binder", O_RDWR);
    if (fd >= 0) {
        ...
        size_t maxThreads = 15;
        result = ioctl(fd, BINDER_SET_MAX_THREADS, &maxThreads);
        if (result == -1) {
            ALOGE("Binder ioctl to set max threads failed: %s", strerror(errno));
        }
    } else {
        ALOGW("Opening '/dev/binder' failed: %s\n", strerror(errno));
    }
    return fd;
}

注释 2 处使用 mmap() 函数实现了内存映射,将同一块物理内存分别映射到内核虚拟地址空间和用户虚拟内存空间。

c++ 复制代码
// frameworks/native/libs/binder/ProcessState.cpp
#define BINDER_VM_SIZE ((1*1024*1024) - (4096 *2))

注意 mmap() 申请的内存大小是 1M-8k 。

总的来说, ProcessState 有以下两个作用:

  • 打开 /dev/binder ,设定 Binder 支持的最大线程数。
  • 通过 mmap 分配了一块虚拟地址空间,用于实现内存映射。

场景: 例如设计一个日志系统:收集+存储+上传。引入mmap机制可以解决日志丢失问题。因为日志产生的速度太快了,而 stream 的方式存储到硬盘太慢,所以用 mmap 提升速度,减少拷贝流程。

Intent 是通过 Binder 来传递数据的。启动 Zygote 时,App 会 fork 一个进程。进程在初始化时,会初始化一个属于该进程的 Binder 驱动。在初始化的过程中会调用 mmap 接口向 Binder 驱动中申请内核空间的内存(1M-8k) 。 所以申请的内存是 1M - 8k,传递的数据最多也只能是这些,因此不能传递大数据。Binder 传输数据有两种方式,同步和异步,同步传输时,1m-8k,异步传输时,还要再除以2。

4. Binder 的优势

  1. 性能

Binder 的复制次数为一次,仅次于共享内存。

  1. 稳定性

Binder 基于 CS 架构,这个架构通常采用两层结构。在技术上已经很成熟了,稳定性也没有问题。共享内存比较难控制,需要加锁控制,稳定性不如 Binder。

  1. 安全性

传统的 IPC 接收方无法获得对方可靠的进程 UID/PID,无法鉴别对方身份。Android 为每个安装好的 App 分配了自己的UID,可以用来鉴别身份。

Binder 的一次拷贝是需要在内核空间申请一块内存,和用户空间共享的。那如果是100个App,就需要开辟100个共享内存,这样会很占用内存,Android8 之前就是这样子。Android8 之后,改成了需要用的时候去申请共享内存,用完了就释放。

相关推荐
半摆烂日常3 小时前
低代码平台API对接实践:接口鉴权与数据同步的完整实现
android·低代码·rxjava
事圆则缓3 小时前
volatile 使用场景全解析:它能保证什么,又解决不了什么
android
蒲公英内测分发4 小时前
智能投影仪参加海外展会,未上架的 Android 配套 App 怎么让客户限时试用?
android
ii_best4 小时前
按键精灵手机端开发安卓版实战:手写一个可最小化、可拖动的「运行日志悬浮窗」(附完整 源码 + 踩坑记录)
android·ios·智能手机·自动化·ai编程·按键精灵
终端安全笔记4 小时前
安卓做 MDM 管控,先分清三种注册入口
android·安全·智能手机
乌萨达5 小时前
手机模拟器安卓怎么用?电脑安装应用、玩手游与账号同步指南
android·智能手机·电脑·雷电模拟器
美狐美颜SDK开放平台6 小时前
Android与iOS直播APP美颜有什么区别?视频美颜SDK开发详解
android·ios·音视频·视频美颜sdk
春猿火7 小时前
某市-2026【网安·论道】misc-afterimage_note-wp
android
恋猫de小郭9 小时前
Shopify 回应为什么从 RN 回到原生,为什么不用 KMP ?
android·前端·flutter
fundroid10 小时前
Android AI 开发,真正拉开差距的是工程闭环
android·ai·大模型·agent