24、多进程优化:进程间通信(Binder)、多进程注意事项、RemoteViews使用、进程保活策略

多进程,这个词听起来挺唬人的。很多开发者一听到「多进程」就觉得是系统底层的事,跟自己写的业务代码没关系。其实不然。我最早接触多进程,是因为一个图片加载的OOM问题——那时候App里图片太多,单进程内存吃紧,动不动就崩。后来我把图片加载扔到独立进程里,问题解决了,但新的麻烦也来了:进程间怎么通信?UI怎么更新?进程会不会被系统杀掉?

今天我们就来聊聊这些事。我会把Binder、多进程的坑、RemoteViews、保活策略这几个点串起来讲。你想想看,搞懂了这些,你就能在系统层面做一些「骚操作」了。

核心观点:多进程不是银弹。它解决内存问题的同时,会引入通信成本、状态同步、UI更新等一系列新问题。用之前,先想清楚值不值。

24.1 Binder:Android进程间通信的基石

Binder是什么?说白了,它就是Android系统里进程间通信的「快递员」。一个进程想调用另一个进程的方法,不能直接调,得通过Binder把请求打包发过去,对方处理完再把结果打包发回来。

我个人习惯把Binder理解成「远程方法调用(RPC)」。客户端和服务端各自跑在不同的进程里,Binder负责把参数序列化、跨进程传输、反序列化,最后调用目标方法。

24.1.1 Binder的工作流程

我画了一张图,帮你快速理解Binder的通信流程:

客户端进程 调用 proxy.method() 参数打包到 Parcel 通过 Binder 驱动发送 Binder 驱动 内核空间 拷贝数据到目标进程 唤醒目标线程 服务端进程 接收请求 调用 stub.method() 结果打包返回 Binder 通信流程 客户端 → Binder驱动 → 服务端 服务端 → Binder驱动 → 客户端 关键点 Binder 驱动只做一次内存拷贝(从客户端到内核空间) 服务端直接读取内核空间数据,无需二次拷贝

嗯,这里要注意:Binder只做一次内存拷贝,而传统的Socket或管道需要两次。这也是Binder性能优于其他IPC方式的原因之一。

24.1.2 AIDL的使用

写Binder通信,最标准的方式是用AIDL。我直接给一个最简单的例子:

// IMyService.aidl
interface IMyService {
    int add(int a, int b);
    String getData();
}

服务端实现:

public class MyService extends Service {
    private final IMyService.Stub binder = new IMyService.Stub() {
        @Override
        public int add(int a, int b) {
            return a + b;
        }

        @Override
        public String getData() {
            return "来自服务端的数据";
        }
    };

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

客户端绑定:

private IMyService myService;
private ServiceConnection connection = new ServiceConnection() {
    @Override
    public void onServiceConnected(ComponentName name, IBinder service) {
        myService = IMyService.Stub.asInterface(service);
        int result = myService.add(3, 5);
    }
};

个人经验:我在项目中遇到过一个问题——AIDL接口里传了一个大Bitmap,结果Binder缓冲区直接爆了。Binder的默认缓冲区大小是1MB,超过就会抛TransactionTooLargeException。所以,大对象别走Binder,用文件或ContentProvider传。

24.2 多进程注意事项

多进程不是把android:process一写就完事了。这里面的坑,我踩过不少。

24.2.1 Application会多次创建

每个进程都会创建自己的Application实例。也就是说,你的Application.onCreate()会被调用多次。如果你在Application里做了全局初始化(比如初始化SDK),那每个进程都会执行一次。

我曾经遇到一个案例:推送SDK在Application里初始化,结果多进程后每个进程都创建了推送连接,服务器端直接炸了。解决方案很简单:

public class MyApp extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        String processName = getProcessName();
        if ("com.example.myapp".equals(processName)) {
            // 只在主进程初始化
            initSDK();
        }
    }

    private String getProcessName() {
        int pid = android.os.Process.myPid();
        ActivityManager am = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
        for (ActivityManager.RunningAppProcessInfo info : am.getRunningAppProcesses()) {
            if (info.pid == pid) {
                return info.processName;
            }
        }
        return null;
    }
}

24.2.2 静态变量和单例失效

这个坑很多人会忽略。静态变量只在当前进程内有效。你在进程A里设置了一个静态变量,进程B是看不到的。说白了,每个进程都有自己的内存空间,静态变量不跨进程。

警告:千万不要用静态变量来做进程间的状态同步。我曾经见过有人用静态布尔值控制某个逻辑,结果多进程后逻辑完全乱套。正确的做法是用SharedPreferences(跨进程模式)、ContentProvider或Binder来同步状态。

24.2.3 文件共享的并发问题

多进程同时读写同一个文件,会出现并发问题。比如两个进程同时写SharedPreferences,数据可能会丢失。我记得有个项目,日志文件被多个进程同时写,结果日志内容互相覆盖,排查问题的时候根本看不出谁写了什么。

解决方案:用FileLock,或者干脆把文件操作集中到一个进程里。

24.3 RemoteViews使用

RemoteViews,说白了就是「远程视图」。它允许你在一个进程里创建View的布局和属性,然后在另一个进程里渲染出来。最常见的场景就是通知栏和桌面小部件。

24.3.1 RemoteViews的限制

RemoteViews不是万能的。它只支持有限的View类型和操作。我列个表:

支持的操作 说明
setTextViewText 设置文本
setImageViewResource 设置图片资源
setOnClickPendingIntent 设置点击事件
setProgressBar 设置进度条
setViewVisibility 设置可见性

不支持自定义View,不支持复杂的触摸事件。你想想看,RemoteViews是通过Binder传输的,如果支持自定义View,那得把整个View的代码和状态都序列化传过去,不现实。

24.3.2 通知栏中使用RemoteViews

我经常用RemoteViews做自定义通知栏。比如音乐App的播放控制:

RemoteViews remoteViews = new RemoteViews(getPackageName(), R.layout.notification_player);
remoteViews.setTextViewText(R.id.song_title, "当前歌曲名");
remoteViews.setImageViewResource(R.id.play_btn, R.drawable.ic_pause);

Intent intent = new Intent(this, PlayerService.class);
intent.setAction("ACTION_PAUSE");
PendingIntent pendingIntent = PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
remoteViews.setOnClickPendingIntent(R.id.play_btn, pendingIntent);

Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
        .setSmallIcon(R.drawable.ic_music)
        .setCustomContentView(remoteViews)
        .build();

startForeground(NOTIFICATION_ID, notification);

避坑指南:我曾经在RemoteViews里设置了一个很大的Bitmap,结果通知栏显示不出来。原因是Binder缓冲区有限。RemoteViews里的图片尽量用资源ID,不要传Bitmap对象。如果非要传,压缩到100KB以内。

24.4 进程保活策略

进程保活,这是个敏感话题。很多开发者想尽办法让App不被杀掉,但说实话,过度保活对用户是一种伤害。我个人的态度是:保活要有度,不要做流氓。

24.4.1 前台服务

最正统的保活方式就是前台服务。启动一个前台服务,系统会把它标记为「用户可见」,优先级很高,不容易被杀。但需要显示一个通知,用户能感知到。

public class KeepAliveService extends Service {
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
                .setContentTitle("正在运行")
                .setSmallIcon(R.drawable.ic_service)
                .build();
        startForeground(1, notification);
        return START_STICKY;
    }
}

24.4.2 双进程守护

这个方案在早期很流行。开两个进程,A进程和B进程互相监听。如果A进程被杀,B进程立即拉起A;反之亦然。但Android 8.0以后,系统对后台启动服务做了严格限制,双进程守护的效果大打折扣。

我记得有个项目用了双进程守护,结果在Android 10上完全失效。后来我改成了前台服务+JobScheduler的组合,效果还不错。

24.4.3 JobScheduler与WorkManager

这是Google推荐的方案。用JobScheduler或WorkManager来调度周期性任务,系统会在合适的时机唤醒你的进程。虽然不能保证实时性,但省电且合规。

PeriodicWorkRequest workRequest = new PeriodicWorkRequest.Builder(
        MyWorker.class, 15, TimeUnit.MINUTES)
        .build();
WorkManager.getInstance(this).enqueue(workRequest);

警告:不要用「1像素Activity」、「播放无声音频」等黑科技保活。这些手段在Android 10+基本失效,而且会被应用商店判定为违规。我见过有App因为保活太狠被下架的。合规保活才是长久之计。

24.5 本章小结

多进程优化,说白了就是一场权衡。你用多进程换来了更大的内存空间和更稳定的运行环境,但也要付出通信成本、状态同步复杂度和保活策略的代价。

我个人建议:

  • 能用单进程解决的,别上多进程
  • 必须用多进程时,优先考虑Binder通信
  • RemoteViews只做简单UI展示,别搞复杂交互
  • 保活策略以合规为前提,别走歪门邪道

嗯,今天就聊到这里。这些经验都是我在项目里一点点踩坑踩出来的,希望对你有帮助。


专注资料整理