Android 任务管理器开发实战指南

0 阅读1小时+

摘要:本文面向想动手做一个进程管理工具的新手开发者,从零开始还原一个可运行的 Android 进程管理器。全文按开发顺序拆成十一个环节:环境搭建与权限预规划、主界面布局与列表组件选型、核心变量与运行进程获取、应用信息封装与数据适配、点击事件与详情展示、进程终止的安全校验、文件属性解析、动态刷新策略、权限报错排查与版本兼容,最后给出整合全部模块的完整实战代码。每一步都结合真实编码中容易踩的坑,读完即可照着搭出能跑的项目。

关键词:进程管理;ActivityManager;RecyclerView;适配器复用;权限校验;进程终止;动态刷新;Android 兼容性

在开发系统工具类应用时,最让人头疼的往往不是核心算法的实现,而是如何优雅地处理进程管理、权限校验以及界面与数据的实时同步。很多开发者在尝试编写一个类似任务管理器的应用时,常常卡在“获取不到进程列表”或者“点击无响应”这些基础环节上。其实,问题的根源通常在于对系统 API 的调用时机把握不准,或是数据适配层的逻辑不够清晰。

如果你正在着手开发一款能够监控运行进程、查看应用详情并支持手动终止任务的桌面或移动端工具,那么本文的经验分享或许能帮你少走不少弯路。我们将跳过那些枯燥的理论定义,直接从环境搭建开始,一步步还原一个完整的进程管理工具的开发全过程。从界面的布局设计到核心变量的定义,再到如何处理棘手的权限报错,每一个环节都结合了实际编码中容易踩坑的细节。

这篇文章适合有一定编程基础,想要深入理解操作系统进程管理机制,或者希望提升自身工具类软件开发能力的开发者阅读。通过接下来的内容,你不仅能掌握 ListView 等组件的高效用法,还能学会如何封装应用程序信息、实现动态数据刷新,甚至优化程序的终止逻辑。让我们直接开始,把那些零散的知识点串联成一套可落地的解决方案。

① 开发环境搭建与项目初始化配置

工欲善其事,必先利其器。在开始编写任何代码之前,确保开发环境的整洁与规范是至关重要的第一步。本文全程以 Android Studio 作为开发平台,从新建工程到编译运行都在同一个 IDE 内完成,新手不必再折腾命令行工具链。

第一步:安装并新建工程。 从官网下载 Android Studio(建议 Hedgehog 及以上版本),安装时一路默认即可,首次启动会自动下载 SDK。打开后选择「New Project → Empty Views Activity」,Language 选 Java,Minimum SDK 选 API 24,包名填 com.example.processmanager,点击 Finish 等待 Gradle 同步完成。

第二步:确认 SDK 版本。 打开 app/build.gradle,检查 compileSdk、minSdk、targetSdk 三个值。不同版本的系统对进程信息的访问权限有着显著差异,通常建议将目标版本设定在较新的稳定版,以便利用更完善的 API 特性,同时保留对旧版本的兼容逻辑。本文实战部分统一使用 compileSdk 34、minSdk 24、targetSdk 34。

第三步:规划权限与分层。 项目初始化阶段,除了常规的依赖库引入外,还需要特别注意权限声明文件的配置。在 Android Studio 左侧的 Project 视图中展开 app/src/main,双击 AndroidManifest.xml 即可编辑。虽然此时还未编写具体逻辑,但必须在 manifest 文件中预先规划好所需的权限节点,例如读取应用列表、查看运行任务等权限。这不仅是代码运行的前提,也是后续排查权限问题的基准。

此外,建议采用 MVVM 或 MVP 架构模式进行项目分层,将 UI 逻辑、数据处理和业务控制分离开来。Android Studio 的包结构管理(右键 New → Package)能帮你快速完成分层,这样在后续面对复杂的进程数据流时,代码结构依然能保持清晰,避免陷入“面条代码”的泥潭。

② 主界面布局设计与 ListView 组件应用

界面是用户与系统交互的第一窗口,对于进程管理工具而言,清晰、直观的信息展示尤为重要。主界面通常采用垂直列表的形式来呈现当前运行的所有进程。在 Android Studio 中新建布局文件(右键 res/layout → New → Layout Resource File),设计时我们首选 ListView 或其现代替代品(如 RecyclerView),因为它们能高效地处理大量数据的滚动渲染。本文实战部分统一采用 RecyclerView,配合 Android Studio 的布局预览(Design / Split 视图)可以边改边看效果。

第一步:搭主界面骨架。 打开 res/layout/activity_main.xml,把默认的 ConstraintLayout 换成垂直的 LinearLayout,顶部放一个刷新按钮,下面放列表容器。列表容器高度设为 0dp 并加上 layout_weight="1",这样它会自动占满按钮以下的剩余空间:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.google.android.material.button.MaterialButton
        android:id="@+id/btnRefresh"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="12dp"
        android:text="刷新进程列表" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
</LinearLayout>

第二步:设计列表项。 每个列表项(Item)需要包含几个关键元素:应用图标、应用名称、进程 ID(PID)以及内存占用情况。新建 res/layout/item_process.xml,用横向 LinearLayout 排列:图标固定在左侧,名称和 PID 放在中间的竖排容器里,内存占用靠右对齐。名称加 ellipsize="end" 与 maxLines="1",避免长包名把布局撑破:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center_vertical"
    android:orientation="horizontal"
    android:padding="12dp">

    <ImageView
        android:id="@+id/ivIcon"
        android:layout_width="40dp"
        android:layout_height="40dp" />

    <LinearLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:layout_weight="1"
        android:orientation="vertical">

        <TextView
            android:id="@+id/tvName"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:ellipsize="end"
            android:maxLines="1"
            android:textSize="15sp"
            android:textStyle="bold" />

        <TextView
            android:id="@+id/tvPid"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="12sp" />
    </LinearLayout>

    <TextView
        android:id="@+id/tvMemory"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="13sp" />
</LinearLayout>

第三步:在 Activity 中绑定列表。 回到 MainActivity,先给 RecyclerView 设置布局管理器,再挂上适配器。适配器我们放到第 11 节完整实现,这里先写调用骨架,方便你边写边看效果:

RecyclerView recyclerView = findViewById(R.id.recyclerView);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
recyclerView.setAdapter(adapter);

第四步:理解视图复用。 无论用 ListView 还是 RecyclerView,核心都是复用机制。ListView 通过自定义适配器,在 getView 方法中判断 convertView 是否为空,为空才 inflate 新视图,否则直接复用;RecyclerView 则把这件事交给 onCreateViewHolder 与 onBindViewHolder 分工完成。理解这一点,后面写适配器时就不会在 onBindViewHolder 里重复创建视图,避免滑动卡顿。

最后补两个小细节:给列表加分割线(RecyclerView 用 DividerItemDecoration),给列表项加点击波纹(在 Item 根布局设置 android:background="?attr/selectableItemBackground"),操作手感会明显提升。如果数据量极大,还可以在 AndroidManifest.xml 的 <application> 上开启硬件加速,确保滑动过程中的帧率稳定。

③ 核心变量定义与运行进程获取逻辑

界面搭好之后,接下来要解决“数据从哪来”的问题。这一节我们先把运行时需要的核心变量定义清楚,再写获取运行进程的逻辑,最后把它放到后台线程里跑起来,避免卡住界面。

第一步:定义核心变量。 打开 MainActivity,在类里声明几个成员变量:进程列表的缓存集合、刷新标志位、以及用于线程通信的 Handler。列表缓存建议用线程安全的 CopyOnWriteArrayList,防止后台线程写入时和 UI 线程读取发生并发异常:

private final List<AppProcessInfo> processCache = new CopyOnWriteArrayList<>();
private volatile boolean isRefreshing = false;
private final Handler mainHandler = new Handler(Looper.getMainLooper());

这里 isRefreshing 用 volatile 修饰,保证后台线程能立刻看到主线程的修改,避免重复触发刷新。

第二步:获取运行进程。 核心 API 是 ActivityManager.getRunningAppProcesses()。在 Android Studio 里写这段代码时,IDE 会自动提示需要 ActivityManager 类型,直接 Alt+Enter 导入即可:

ActivityManager am = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) {
    return;
}
List<ActivityManager.RunningAppProcessInfo> running = am.getRunningAppProcesses();
if (running == null) {
    return;
}
for (ActivityManager.RunningAppProcessInfo info : running) {
    // info.processName、info.pid、info.uid 就是我们要的原始字段
}

需要提前知道一个坑:从 Android 5.0 开始,这个方法返回的列表被系统收紧了,通常只能拿到当前应用自身的进程以及部分可见的系统进程。所以代码里必须做空值判断,并在界面上如实告诉用户“数据可能不完整”,而不是让列表莫名其妙地空着。

第三步:放到后台线程执行。 获取进程、查询应用信息都属于耗时操作,直接写在主线程会触发 ANR。用 ExecutorService 包一层,结果再切回主线程更新 UI:

private final ExecutorService executor = Executors.newSingleThreadExecutor();

private void loadProcessesAsync() {
    if (isRefreshing) {
        return;
    }
    isRefreshing = true;
    executor.execute(() -> {
        List<AppProcessInfo> result = new ArrayList<>();
        ActivityManager am = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
        if (am != null) {
            List<ActivityManager.RunningAppProcessInfo> running = am.getRunningAppProcesses();
            if (running != null) {
                for (ActivityManager.RunningAppProcessInfo info : running) {
                    result.add(new AppProcessInfo(
                            info.processName, info.processName, info.pid, info.uid));
                }
            }
        }
        mainHandler.post(() -> {
            processCache.clear();
            processCache.addAll(result);
            isRefreshing = false;
            // 这里通知适配器刷新,适配器在第 11 节完整实现
        });
    });
}

第四步:高版本系统的兼容处理。 如果目标设备是 Android 10 及以上,getRunningAppProcesses() 的结果会更少。这时可以在方法里加一个版本分支,高版本改用 UsageStatsManager 查询应用使用情况,或者至少给用户一个提示。判断写法如下:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    // 高版本:可接入 UsageStatsManager,或提示数据受限
}

最后别忘了在 onDestroy 里关闭线程池,避免 Activity 销毁后线程还在跑:

@Override
protected void onDestroy() {
    super.onDestroy();
    executor.shutdownNow();
}

到这里,数据获取的骨架就有了。下一节我们把这些原始进程对象封装成 UI 真正需要的模型,并补上应用图标和名称的查询。

④ 应用程序信息封装与数据适配方法

上一节拿到的 RunningAppProcessInfo 只包含进程名、PID、UID 这些原始字段,直接丢给列表显示会很难看:没有应用名称、没有图标,内存数据也不直观。这一节我们分三步把它变成 UI 能直接用的模型:先定义数据模型类,再写封装与适配方法,最后把适配器绑定上去。

第一步:定义数据模型。 在 Android Studio 里右键 com.example.processmanager → New → Package,输入 model,再在 model 包下新建 AppProcessInfo.java。只保留 UI 真正需要的字段,避免把整个系统对象搬来搬去:

package com.example.processmanager.model;

import android.graphics.drawable.Drawable;

public class AppProcessInfo {
    public String packageName;
    public String processName;
    public int pid;
    public int uid;
    public long memoryBytes;
    public String label;
    public Drawable icon;
    public boolean isSystemApp;

    public AppProcessInfo(String packageName, String processName, int pid, int uid) {
        this.packageName = packageName;
        this.processName = processName;
        this.pid = pid;
        this.uid = uid;
    }
}

第二步:通过包名查应用信息。 图标和名称都要靠 PackageManager 查。这里有两个坑:一是子进程的 processName 形如 com.example.app:remote,冒号后面是进程后缀,查包名时要先截断;二是 getApplicationInfo 会抛 NameNotFoundException,必须捕获。为了不让列表滚动时反复查询,用一个 HashMap 做缓存:

private final Map<String, ApplicationInfo> appInfoCache = new HashMap<>();

private ApplicationInfo queryAppInfo(PackageManager pm, String processName) {
    String pkg = processName.contains(":")
            ? processName.substring(0, processName.indexOf(':'))
            : processName;
    if (appInfoCache.containsKey(pkg)) {
        return appInfoCache.get(pkg);
    }
    try {
        ApplicationInfo info = pm.getApplicationInfo(pkg, 0);
        appInfoCache.put(pkg, info);
        return info;
    } catch (PackageManager.NameNotFoundException e) {
        return null;
    }
}

第三步:把原始进程填充成模型。 遍历上一节拿到的进程列表,逐个补全包名、标签、图标和系统应用标记。查不到应用信息时用进程名兜底,保证列表不会出现空白项:

private List<AppProcessInfo> adapt(List<ActivityManager.RunningAppProcessInfo> running) {
    List<AppProcessInfo> result = new ArrayList<>();
    PackageManager pm = getPackageManager();
    for (ActivityManager.RunningAppProcessInfo info : running) {
        AppProcessInfo item = new AppProcessInfo(
                info.processName, info.processName, info.pid, info.uid);
        ApplicationInfo appInfo = queryAppInfo(pm, info.processName);
        if (appInfo != null) {
            item.packageName = appInfo.packageName;
            item.label = pm.getApplicationLabel(appInfo).toString();
            item.icon = pm.getApplicationIcon(appInfo);
            item.isSystemApp = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
        } else {
            item.label = info.processName;
        }
        result.add(item);
    }
    return result;
}

注意这段代码要在后台线程里调用,getApplicationIcon 会读 APK 资源,放在主线程循环几十次就会明显卡顿。

第四步:在适配器里绑定数据。 适配器负责把模型映射到 item_process.xml 的控件上,同时把整个模型对象塞进 itemView 的 Tag,后面点击事件直接取 Tag 就能拿到进程信息,不用再按下标回查列表:

@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
    AppProcessInfo item = data.get(position);
    holder.tvName.setText(item.label);
    holder.tvPid.setText("PID: " + item.pid + "  UID: " + item.uid);
    holder.tvMemory.setText(Formatters.formatBytes(item.memoryBytes));
    if (item.icon != null) {
        holder.ivIcon.setImageDrawable(item.icon);
    } else {
        holder.ivIcon.setImageResource(android.R.drawable.sym_def_app_icon);
    }
    holder.itemView.setTag(item);
}

到这里,原始进程数据就变成了带图标、带名称、带可读内存的列表项。下一节我们给列表加上点击响应,点一下就能看到进程详情。

⑤ 列表点击事件响应与详情展示实现

上一节我们在 onBindViewHolder 里把整个 AppProcessInfo 塞进了 itemView 的 Tag,这一节就顺着这个设计把点击事件接上:点一下列表项,弹出一个详情对话框,把包名、进程名、PID、UID、内存、是否系统应用都列出来,同时给后面的「终止进程」留好入口。

第一步:在适配器里暴露点击回调。 打开 ProcessAdapter.java,先定义一个回调接口,再在构造函数里把它传进来。这样适配器只负责「谁被点了」,具体弹什么、做什么由 Activity 决定,职责清晰:

public interface OnItemClick {
    void onClick(AppProcessInfo info);
}

private final OnItemClick listener;

public ProcessAdapter(OnItemClick listener) {
    this.listener = listener;
}

第二步:在 onBindViewHolder 里绑定点击。 注意点击监听要写在 onBindViewHolder 里而不是 onCreateViewHolder 里,因为 ViewHolder 会被复用,写在创建阶段会导致点击回调绑定到错误的数据上。取数据时直接读 Tag,避免用 getAdapterPosition() 在快速滑动时拿到过期下标:

holder.itemView.setTag(item);
holder.itemView.setOnClickListener(v -> {
    if (listener != null) {
        listener.onClick((AppProcessInfo) v.getTag());
    }
});

第三步:在 MainActivity 里实现详情弹窗。 回到 MainActivity,把 showDetail 方法作为回调传给适配器。用 AlertDialog 拼一段多行文本,字段之间用 \n 换行,内存用上一节的 Formatters.formatBytes 转成可读单位:

adapter = new ProcessAdapter(this::showDetail);

private void showDetail(AppProcessInfo info) {
    String message = "包名:" + info.packageName
            + "\n进程名:" + info.processName
            + "\nPID:" + info.pid
            + "\nUID:" + info.uid
            + "\n内存参考:" + Formatters.formatBytes(info.memoryBytes)
            + "\n系统应用:" + (info.isSystemApp ? "是" : "否");

    new AlertDialog.Builder(this)
            .setTitle(info.label)
            .setMessage(message)
            .setPositiveButton("终止进程", (d, w) -> killProcess(info))
            .setNegativeButton("关闭", null)
            .show();
}

第四步:给系统进程加一层提示。 用户点到系统关键进程时,最好在弹窗里就说明清楚,而不是等他点了「终止进程」才被拦截。可以在标题后面拼一个标记,或者在消息末尾追加一行提示:

String title = info.isSystemApp ? info.label + "(系统应用)" : info.label;
String tip = info.isSystemApp
        ? "\n\n提示:系统关键进程不建议终止,可能导致功能异常。"
        : "";

第五步:耗时信息放到子线程。 如果详情里还要读 APK 文件大小、安装时间这类需要 IO 的字段,不要在点击回调里直接读。先弹一个「加载中」的对话框,再丢到线程池里查,查完用 runOnUiThread 回填:

private void showDetailAsync(AppProcessInfo info) {
    AlertDialog loading = new AlertDialog.Builder(this)
            .setMessage("加载中…")
            .setCancelable(false)
            .show();
    executor.execute(() -> {
        long apkSize = readApkSize(info.packageName);
        runOnUiThread(() -> {
            loading.dismiss();
            showDetail(info, apkSize);
        });
    });
}

到这里,点击列表项就能看到进程详情,并且为下一节的终止逻辑留好了按钮入口。下一节我们重点讲终止进程时的权限判断与安全校验。

⑥ 指定进程终止功能的代码编写技巧

上一节我们在详情弹窗里留了一个「终止进程」按钮,这一节就把它真正实现出来。终止进程是这类工具最敏感也最容易翻车的功能:写得太随意,轻则点了没反应,重则把系统关键服务干掉导致设备异常。所以这一节我们按「先校验、再执行、后确认」的顺序,一步步把 killProcess 写扎实。

第一步:先想清楚能杀什么。 Android 上普通应用能做的其实很有限,ActivityManager.killBackgroundProcesses(packageName) 只能「请求」系统清理某个包的后台进程,系统有权拒绝,也可能过一会儿又把进程拉起来。真正意义上的强杀需要系统签名或 root 权限,普通应用拿不到。所以代码里要摆正预期:我们做的是「发起终止请求 + 结果反馈」,而不是「保证进程消失」。

第二步:维护一份保护名单。 在 MainActivity 里定义一个禁止操作的前缀列表,凡是包名以这些前缀开头的,一律拦截。用前缀而不是全等,是因为系统进程的包名往往带后缀,比如 com.android.phone 下面还有一堆子进程:

private static final List<String> PROTECTED_PREFIX = Arrays.asList(
        "android",
        "com.android.systemui",
        "com.android.phone",
        "com.android.settings",
        "com.google.android.gms",
        "com.google.android.gsf");

private boolean isProtected(String packageName) {
    if (packageName == null || packageName.isEmpty()) {
        return true;
    }
    for (String prefix : PROTECTED_PREFIX) {
        if (packageName.startsWith(prefix)) {
            return true;
        }
    }
    return false;
}

注意这里对 null 和空串也返回 true,宁可多拦一次,也不要因为包名没查到就放行。

第三步:写终止方法本体。 先做保护名单校验,再判断是不是自己的进程(杀自己会直接闪退),最后才调用系统 API。每一步失败都给用户一句明确的提示,而不是静默返回:

private void killProcess(AppProcessInfo info) {
    if (info == null) {
        return;
    }
    if (isProtected(info.packageName)) {
        Toast.makeText(this, "系统关键进程,已拦截", Toast.LENGTH_SHORT).show();
        return;
    }
    if (getPackageName().equals(info.packageName)) {
        Toast.makeText(this, "不能终止当前应用自身", Toast.LENGTH_SHORT).show();
        return;
    }

    ActivityManager am = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
    if (am == null) {
        Toast.makeText(this, "ActivityManager 不可用", Toast.LENGTH_SHORT).show();
        return;
    }

    try {
        am.killBackgroundProcesses(info.packageName);
        Toast.makeText(this, "已请求终止:" + info.label, Toast.LENGTH_SHORT).show();
    } catch (SecurityException e) {
        Toast.makeText(this, "缺少权限,终止失败", Toast.LENGTH_SHORT).show();
        Log.e("ProcessManager", "killBackgroundProcesses failed", e);
    }

    // 稍等片刻再刷新,给系统一点处理时间
    mainHandler.postDelayed(this::refresh, 800);
}

这里用 try-catch 包住 API 调用,是因为在部分定制 ROM 上,即使声明了 KILL_BACKGROUND_PROCESSES 权限,也可能抛 SecurityException。捕获后给用户提示并打日志,比让应用直接崩溃友好得多。

第四步:加一层二次确认。 终止操作不可撤销,最好在真正执行前再弹一次确认框,把应用名和包名都显示出来,避免用户手滑点错:

private void confirmKill(AppProcessInfo info) {
    new AlertDialog.Builder(this)
            .setTitle("确认终止")
            .setMessage("即将终止:" + info.label + "\n包名:" + info.packageName
                    + "\n\n该操作可能导致应用数据丢失,是否继续?")
            .setPositiveButton("确认终止", (d, w) -> killProcess(info))
            .setNegativeButton("取消", null)
            .show();
}

然后把上一节详情弹窗里的按钮回调从 killProcess(info) 改成 confirmKill(info) 即可。

第五步:验证是否真的被终止。 由于 killBackgroundProcesses 只是「建议」,可以延迟一段时间后重新拉一次进程列表,看目标包名是否还在。如果还在,就提示用户「系统已拒绝或进程被重新拉起」,让用户知道真实结果:

private void killAndVerify(AppProcessInfo info) {
    killProcess(info);
    mainHandler.postDelayed(() -> repository.loadProcesses(list -> runOnUiThread(() -> {
        boolean stillAlive = false;
        for (AppProcessInfo p : list) {
            if (info.packageName.equals(p.packageName)) {
                stillAlive = true;
                break;
            }
        }
        if (stillAlive) {
            Toast.makeText(this, "进程仍在运行,可能被系统重新拉起",
                    Toast.LENGTH_SHORT).show();
        }
        adapter.submit(list);
    })), 1500);
}

第六步:几个容易踩的坑。 一是不要在 onBindViewHolder 或点击回调里直接杀进程,一定要走确认流程;二是 killBackgroundProcesses 对前台进程基本无效,用户看到「已请求终止」但列表没变化是正常现象,文案上不要写成「已终止」;三是如果后续想支持批量清理,记得把保护名单校验抽成公共方法,别在每个调用点复制一遍。

到这里,终止功能就有了完整的校验链路:保护名单拦截 → 自身进程拦截 → 异常捕获 → 二次确认 → 结果验证。下一节我们讲怎么读取 APK 文件属性,把详情页的信息补得更完整。

⑦ 文件属性读取与程序详细信息解析

上一节我们把终止流程写扎实了,但详情弹窗里还只有包名、PID 这些「运行时」信息。用户点开一个应用,往往还想知道它到底占多大、什么时候装的、版本是多少——这些都要从 APK 文件本身去读。这一节我们分五步,把文件属性读取和详细信息解析补上。

第一步:先拿到 APK 的真实路径。 进程对象里没有文件路径,得靠 PackageManager 从 ApplicationInfo 上取。sourceDir 是 APK 的完整路径,publicSourceDir 在拆分 APK(App Bundle 安装)场景下更可靠,两个都取一下,优先用后者:

private String resolveApkPath(PackageManager pm, String packageName) {
    try {
        ApplicationInfo info = pm.getApplicationInfo(packageName, 0);
        if (info.publicSourceDir != null && !info.publicSourceDir.isEmpty()) {
            return info.publicSourceDir;
        }
        return info.sourceDir;
    } catch (PackageManager.NameNotFoundException e) {
        return null;
    }
}

注意这里可能返回 null:系统进程、部分预装应用没有可访问的 APK 路径,后面每一步都要判空,不能直接 new File(path)。

第二步:读文件大小与修改时间。 拿到路径后实例化 File,length() 取字节数,lastModified() 取时间戳。这两个方法本身很快,但为了统一,还是放在后台线程里调用:

private void readFileAttributes(String apkPath, FileAttrCallback callback) {
    if (apkPath == null) {
        callback.onResult(-1L, -1L);
        return;
    }
    File apk = new File(apkPath);
    if (!apk.exists() || !apk.canRead()) {
        callback.onResult(-1L, -1L);
        return;
    }
    callback.onResult(apk.length(), apk.lastModified());
}

public interface FileAttrCallback {
    void onResult(long sizeBytes, long lastModified);
}

返回 -1 表示「读不到」,UI 上显示成「未知」即可,不要显示成 0 B,否则用户会误以为文件是空的。

第三步:从 PackageInfo 补版本与安装时间。 文件大小只能说明 APK 多大,版本号和安装时间还得靠 PackageInfo。这里有个版本差异要注意:versionName 在 API 33 之后推荐用 getPackageInfo(pkg, PackageManager.PackageInfoFlags.of(0)),老写法虽然还能用但已标记废弃。为了兼容,我们做一层判断:

private PackageInfo queryPackageInfo(PackageManager pm, String packageName) {
    try {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            return pm.getPackageInfo(packageName,
                    PackageManager.PackageInfoFlags.of(0));
        }
        return pm.getPackageInfo(packageName, 0);
    } catch (PackageManager.NameNotFoundException e) {
        return null;
    }
}

拿到 PackageInfo 后,versionName、versionCode、firstInstallTime、lastUpdateTime 就都有了。其中 firstInstallTime 是首次安装时间,lastUpdateTime 是最近一次更新时间,两个都展示出来,用户能一眼看出这个应用是不是刚更新过。

第四步:把信息拼成详情文本。 把前面几步的结果汇总成一个方法,统一格式化。字节用第 11 节的 Formatters.formatBytes,时间用 Formatters.formatTime,读不到的字段统一显示「未知」:

private String buildDetailMessage(AppProcessInfo info) {
    PackageManager pm = getPackageManager();
    String apkPath = resolveApkPath(pm, info.packageName);
    long size = -1L;
    long modified = -1L;
    if (apkPath != null) {
        File apk = new File(apkPath);
        if (apk.exists() && apk.canRead()) {
            size = apk.length();
            modified = apk.lastModified();
        }
    }

    PackageInfo pkgInfo = queryPackageInfo(pm, info.packageName);
    String version = "未知";
    String installTime = "未知";
    String updateTime = "未知";
    if (pkgInfo != null) {
        long versionCode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
                ? pkgInfo.getLongVersionCode()
                : pkgInfo.versionCode;
        version = pkgInfo.versionName + " (" + versionCode + ")";
        // firstInstallTime 在部分定制 ROM 上会返回 0,为 0 时按「未知」处理
        installTime = pkgInfo.firstInstallTime <= 0
                ? "未知"
                : Formatters.formatTime(pkgInfo.firstInstallTime);
        updateTime = pkgInfo.lastUpdateTime <= 0
                ? "未知"
                : Formatters.formatTime(pkgInfo.lastUpdateTime);
    }

    return "包名:" + info.packageName
            + "\n进程名:" + info.processName
            + "\nPID:" + info.pid
            + "\nUID:" + info.uid
            + "\n版本:" + version
            + "\nAPK 大小:" + (size < 0 ? "未知" : Formatters.formatBytes(size))
            + "\nAPK 修改时间:" + (modified < 0 ? "未知" : Formatters.formatTime(modified))
            + "\n首次安装:" + installTime
            + "\n最近更新:" + updateTime
            + "\n系统应用:" + (info.isSystemApp ? "是" : "否");
}

getLongVersionCode() 是 API 28 才有的方法,如果 minSdk 低于 28,直接调用会在低版本设备上抛 NoSuchMethodError。所以上面用 Build.VERSION.SDK_INT >= Build.VERSION_CODES.P 做了一层判断,低版本退回 versionCode。本文实战的 minSdk 24,这层判断不能省。 第五步:接到详情弹窗上。 把上一节 showDetail 里的 message 拼接换成 buildDetailMessage(info),其余逻辑不动。因为读文件、查 PackageInfo 都有 IO 开销,点击回调里不要直接调,套一层「加载中」对话框:

private void showDetail(AppProcessInfo info) {
    AlertDialog loading = new AlertDialog.Builder(this)
            .setMessage("正在读取应用信息…")
            .setCancelable(false)
            .show();

    executor.execute(() -> {
        String message = buildDetailMessage(info);
        runOnUiThread(() -> {
            loading.dismiss();
            new AlertDialog.Builder(this)
                    .setTitle(info.label)
                    .setMessage(message)
                    .setPositiveButton("终止进程", (d, w) -> confirmKill(info))
                    .setNegativeButton("关闭", null)
                    .show();
        });
    });
}

几个容易踩的坑。 一是 sourceDir 在 App Bundle 安装的应用上可能指向拆分后的 base.apk,大小会比实际安装体积小,这是正常的,别当成 bug;二是 firstInstallTime 在部分定制 ROM 上会返回 0,展示前判断一下,为 0 就显示「未知」;三是不要试图去读 /proc/<pid>/ 下的文件,Android 10 之后普通应用基本没有权限,读了也是 Permission denied,白白浪费一次异常。

到这里,详情弹窗里的信息就完整了:运行时状态 + 文件属性 + 版本与安装时间。下一节我们讲怎么让这个列表自动刷新,而不是每次都手动点按钮。

⑧ 动态列表更新机制与数据刷新策略

上一节我们把详情弹窗的信息补全了,但列表还是「点一次刷新按钮才动一次」。进程状态是瞬息万变的,用户盯着一个静态列表,看到的其实是几秒前的快照。这一节我们分五步,把「手动刷新 → 定时刷新 → 增量更新 → 暂停开关 → 生命周期管理」这条链路搭起来,让列表真正动起来。

第一步:先把刷新入口统一。 前面几节里,刷新逻辑散落在按钮点击、终止后回调等地方,各自调一次 refresh()。先把它收敛成一个方法,所有刷新都走这里,后面加定时、加暂停都只改一处:

private void refresh() {
    if (isRefreshing) {
        return;
    }
    isRefreshing = true;
    repository.loadProcesses(list -> runOnUiThread(() -> {
        isRefreshing = false;
        adapter.submit(list);
    }));
}

isRefreshing 这个标志位在第 ③ 节就定义过,这里正好派上用场:定时器每 2 秒触发一次,如果上一次还没回来,直接跳过,避免请求堆积。

第二步:加一个定时刷新。 用 Handler.postDelayed 做循环最简单,不用引入额外依赖。定义一个间隔常量和一个 Runnable,在 onCreate 里启动,在 onDestroy 里移除:

private static final long REFRESH_INTERVAL_MS = 2000L;

private final Runnable autoRefreshTask = new Runnable() {
    @Override
    public void run() {
        if (!isPaused) {
            refresh();
        }
        mainHandler.postDelayed(this, REFRESH_INTERVAL_MS);
    }
};

// onCreate 里启动
mainHandler.postDelayed(autoRefreshTask, REFRESH_INTERVAL_MS);

// onDestroy 里务必移除,否则 Activity 销毁后 Handler 还在跑
mainHandler.removeCallbacks(autoRefreshTask);

这里有个新手常踩的坑:postDelayed 的 Runnable 里又调了一次 postDelayed(this, ...),形成自循环。如果忘了在 onDestroy 里 removeCallbacks,Activity 已经销毁但任务还在跑,轻则内存泄漏,重则回调里操作已销毁的 View 直接崩溃。

第三步:改成增量更新,别整表重刷。 现在 adapter.submit(list) 内部是 notifyDataSetChanged(),每 2 秒全表重绘一次,滑动时会明显闪烁,用户正在看的详情也会被打断。改成按包名做差集,只通知变化的项:

public void submitIncremental(List<AppProcessInfo> newList) {
    Map<String, AppProcessInfo> oldMap = new HashMap<>();
    for (AppProcessInfo item : data) {
        oldMap.put(item.packageName, item);
    }

    data.clear();
    data.addAll(newList);

    for (int i = 0; i < data.size(); i++) {
        AppProcessInfo now = data.get(i);
        AppProcessInfo old = oldMap.remove(now.packageName);
        if (old == null || old.memoryBytes != now.memoryBytes) {
            notifyItemChanged(i);
        }
    }
    // 剩下的 oldMap 就是已经消失的进程,整表结构变了,退回全量刷新
    if (!oldMap.isEmpty()) {
        notifyDataSetChanged();
    }
}

判断「有没有变化」时只比 memoryBytes 就够了,图标和名称基本不会变。如果连内存都没变,就不通知,UI 完全不动,闪烁自然消失。

第四步:加一个暂停开关。 用户点开详情、或者正在列表里找某个进程时,列表每 2 秒跳一下会非常烦。在顶部加一个开关,暂停时只停定时任务,手动刷新按钮仍然可用:

private boolean isPaused = false;

private void togglePause(boolean pause) {
    isPaused = pause;
    if (pause) {
        mainHandler.removeCallbacks(autoRefreshTask);
    } else {
        mainHandler.removeCallbacks(autoRefreshTask);
        mainHandler.postDelayed(autoRefreshTask, REFRESH_INTERVAL_MS);
    }
}

注意恢复时要先 removeCallbacks 再 postDelayed,否则可能同时存在两个循环任务,刷新频率翻倍。

第五步:把刷新和生命周期绑死。 应用切到后台还每 2 秒拉一次进程列表,既费电又没意义。在 onPause 里停、onResume 里恢复,是最省事的做法:

@Override
protected void onResume() {
    super.onResume();
    if (!isPaused) {
        mainHandler.removeCallbacks(autoRefreshTask);
        mainHandler.postDelayed(autoRefreshTask, REFRESH_INTERVAL_MS);
    }
}

@Override
protected void onPause() {
    super.onPause();
    mainHandler.removeCallbacks(autoRefreshTask);
}

几个容易踩的坑。 一是刷新间隔别设太短,2 秒是体验和功耗的平衡点,1 秒以下在高版本系统上很容易触发系统限流,反而拿不到数据;二是 notifyItemChanged 会触发一次局部重绑,如果列表项里有图片加载,记得加占位图,否则会闪一下白;三是终止进程后的那次刷新(第 ⑥ 节的 killAndVerify)建议单独延迟 1.5 秒再拉,别和定时任务撞在一起,否则可能拿到终止前的旧数据。

到这里,列表就能自己动起来了:定时拉取、增量更新、可暂停、跟随生命周期。下一节我们讲权限报错怎么排查,以及不同 Android 版本上的兼容处理。

⑨ 常见权限报错排查与兼容性处理

上一节我们把列表刷新做成了自动的,但很多读者跑起来第一件事就是撞上权限问题:要么列表空空如也,要么点「终止进程」直接抛 SecurityException 崩掉。这一节我们按「先看报错 → 再查声明 → 后做兼容」的顺序,把最常见的几类权限问题逐个拆开,每个都给出可复制的排查代码。

第一步:先学会读报错。 Android 的权限异常信息其实写得很清楚,关键看冒号后面那句。打开 Android Studio 底部的 Logcat,按 E/AndroidRuntime 过滤,常见的有这几种:

java.lang.SecurityException: Permission Denial: getRunningAppProcesses from pid=... 
    requires android.permission.REAL_GET_TASKS
java.lang.SecurityException: Permission Denial: killBackgroundProcesses() from pid=...
    requires android.permission.KILL_BACKGROUND_PROCESSES
java.lang.SecurityException: Package com.xxx does not have access to ...

看到 Permission Denial 加 requires xxx,就说明是权限没声明或没生效;看到 NameNotFoundException,那不是权限问题,是包名查不到,别混为一谈。

第二步:确认 Manifest 声明是否真的生效。 新手最容易犯的错是权限写在了 <application> 标签里面,或者拼错了名字。正确的写法是放在 <manifest> 下、<application> 之前:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES" />
    <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
        tools:ignore="QueryAllPackagesPermission" />

    <application ...>
        ...
    </application>
</manifest>

改完记得点 Android Studio 工具栏的「Sync Project with Gradle Files」,或者直接 Clean Project 再 Run。有时候改了 manifest 但装的是旧包,权限自然不生效。

第三步:区分「安装时权限」和「运行时权限」。 上面这两个权限都属于普通权限,安装时就自动授予,不需要弹框申请。真正需要动态申请的是 READ_EXTERNAL_STORAGE、POST_NOTIFICATIONS 这类危险权限。如果你在代码里对普通权限调了 requestPermissions,系统会直接忽略,别以为是申请失败了。判断一个权限要不要动态申请,可以这样写:

private boolean needRuntimeRequest(String permission) {
    return ContextCompat.checkSelfPermission(this, permission)
            != PackageManager.PERMISSION_GRANTED;
}

第四步:处理 Android 11 的包可见性限制。 这是最容易让人困惑的一类「假权限问题」:代码没报错,但 getApplicationInfo 一直抛 NameNotFoundException,列表里全是进程名没有应用名。原因是 Android 11(API 30)起,应用默认只能看到自己和一个白名单里的包。解决办法是在 manifest 里声明 <queries>,或者直接申请 QUERY_ALL_PACKAGES:

<queries>
    <intent>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent>
</queries>

<queries> 更精准、上架审核也更容易过;QUERY_ALL_PACKAGES 简单粗暴,但 Google Play 要求说明用途,本地调试用没问题。

第五步:给高版本系统做降级兜底。 前面第 ③ 节提过,Android 10 之后 getRunningAppProcesses() 返回的列表被大幅收紧。与其让用户看到空列表,不如在代码里判断版本,给出明确提示,并尝试用 UsageStatsManager 补一部分数据:

private boolean isProcessApiLimited() {
    return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
}

private void showLimitTipIfNeeded() {
    if (isProcessApiLimited()) {
        Toast.makeText(this,
                "当前系统版本对进程列表有限制,显示结果可能不完整",
                Toast.LENGTH_LONG).show();
    }
}

如果确实要接 UsageStatsManager,还需要引导用户去「设置 → 特殊应用权限 → 使用情况访问」手动授权,这个权限没有弹框,只能跳转:

private void gotoUsageAccessSettings() {
    Intent intent = new Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS);
    try {
        startActivity(intent);
    } catch (ActivityNotFoundException e) {
        Toast.makeText(this, "当前设备不支持该设置页", Toast.LENGTH_SHORT).show();
    }
}

第六步:把异常兜住,别让应用崩。 无论排查得多仔细,定制 ROM 上总会有意外。所有涉及权限的 API 调用都套一层 try-catch,捕获后给用户一句人话提示,同时打日志方便定位:

try {
    am.killBackgroundProcesses(packageName);
} catch (SecurityException e) {
    Log.e("ProcessManager", "kill failed, pkg=" + packageName, e);
    Toast.makeText(this, "系统拒绝了终止请求,请检查权限", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
    Log.e("ProcessManager", "kill failed unexpectedly", e);
    Toast.makeText(this, "终止失败:" + e.getClass().getSimpleName(),
            Toast.LENGTH_SHORT).show();
}

几个容易踩的坑。 一是 QUERY_ALL_PACKAGES 在 Google Play 上属于敏感权限,上架前务必确认是否真的需要,能用 <queries> 就别用它;二是模拟器和真机的表现可能不一样,尤其是国产 ROM,建议至少在一台真机上验证;三是 Settings.ACTION_USAGE_ACCESS_SETTINGS 在部分设备上会跳到列表页而不是本应用详情页,跳转后要提示用户手动找到本应用;四是不要用 try-catch 把异常吞掉却不打日志,出了问题连排查线索都没有。

到这里,权限相关的常见问题就覆盖得差不多了:会读报错、会查声明、会区分权限类型、会处理包可见性、会给高版本兜底、会把异常兜住。下一节我们讲性能优化和后续可以扩展的方向。

⑩ 性能优化建议与功能扩展思路

上一节我们把权限问题排查清楚了,功能上已经能跑通。但很多读者跑起来之后会发现:进程一多,列表滑动开始掉帧;图标反复加载,内存悄悄涨;定时刷新一开,手机明显发热。这一节我们分两部分:先把性能优化按「列表 → 图标 → 数据 → 刷新」四层逐个落地,再给出几个可以继续扩展的方向,每个都附上能直接抄的代码。

第一步:先把列表的刷新粒度降下来。 第 ⑧ 节我们已经用 notifyItemChanged 做了增量更新,但 submit 里仍然是 notifyDataSetChanged()。如果列表项超过一百个,全表重绘一次就是一次完整的 onBindViewHolder 循环。把 submit 也改成差集更新,只在结构真的变化时才退回全量:

public void submit(List<AppProcessInfo> list) {
    if (data.isEmpty()) {
        data.addAll(list);
        notifyItemRangeInserted(0, list.size());
        return;
    }
    submitIncremental(list);
}

另外给 RecyclerView 打开固定尺寸,省掉每次布局的测量开销:

recyclerView.setHasFixedSize(true);
recyclerView.setItemViewCacheSize(20);

setHasFixedSize(true) 告诉 RecyclerView「列表尺寸不受内容影响」,setItemViewCacheSize 把离屏缓存从默认 2 提到 20,滑动时能少做几次 inflate。

第二步:图标加载别在主线程解码。 现在 onBindViewHolder 里直接 setImageDrawable(item.icon),图标是 PackageManager 返回的 Drawable,第一次绘制时才真正解码 Bitmap,列表快速滑动时就会在主线程上做解码,掉帧就是这么来的。最省事的做法是引入 Glide,让它接管缓存与异步解码:

implementation 'com.github.bumptech.glide:glide:4.16.0'
Glide.with(holder.ivIcon)
        .load(item.icon)
        .placeholder(android.R.drawable.sym_def_app_icon)
        .into(holder.ivIcon);

如果不想引入第三方库,也可以自己做一个 LruCache<String, Bitmap>,在后台线程把 Drawable 转成 Bitmap 再回主线程设置。关键是:解码永远不在 onBindViewHolder 里同步做。

第三步:数据层做缓存与裁剪。 ProcessRepository 里每次刷新都会重新 getApplicationLabel、getApplicationIcon,这两个调用都要读 APK 资源。应用元数据基本不变,可以单独缓存一份,只对进程的 PID、内存这些易变字段做刷新:

private final Map<String, String> labelCache = new HashMap<>();
private final Map<String, Drawable> iconCache = new HashMap<>();

private String getLabel(PackageManager pm, ApplicationInfo info) {
    String pkg = info.packageName;
    String cached = labelCache.get(pkg);
    if (cached != null) {
        return cached;
    }
    String label = pm.getApplicationLabel(info).toString();
    labelCache.put(pkg, label);
    return label;
}

同时,如果 UI 上只显示名称、PID、内存三项,就别在模型里塞一堆用不到的字段。数据在后台线程组装完再回主线程,主线程只做 setText,这是最省的做法。

第四步:刷新策略按场景分级。 第 ⑧ 节的 2 秒定时刷新适合「用户正在看列表」的场景,但应用切到后台、或者用户正在看详情弹窗时,完全没必要刷。可以按场景分三档:

private static final long INTERVAL_FOREGROUND_MS = 2000L;
private static final long INTERVAL_IDLE_MS = 5000L;

private long currentInterval() {
    return isPaused ? Long.MAX_VALUE : INTERVAL_FOREGROUND_MS;
}

再配合 onPause / onResume 的启停(第 ⑧ 节已经写过),后台完全不刷、前台 2 秒一刷、暂停时只留手动按钮,功耗和体验都能兼顾。

第五步:功能扩展——排序与搜索。 进程一多,用户最想要的就是「按内存排序」和「搜应用名」。排序在数据层做,搜索在适配器里做过滤,两者都不需要改布局:

private void sortByMemory(List<AppProcessInfo> list) {
    list.sort((a, b) -> Long.compare(b.memoryBytes, a.memoryBytes));
}

public void filter(String keyword) {
    List<AppProcessInfo> filtered = new ArrayList<>();
    for (AppProcessInfo item : allData) {
        if (item.label != null && item.label.toLowerCase()
                .contains(keyword.toLowerCase())) {
            filtered.add(item);
        }
    }
    submit(filtered);
}

注意 filter 要基于一份完整的 allData 做过滤,而不是在已经过滤过的 data 上再过滤,否则关键词删掉后列表回不来。

第六步:功能扩展——一键清理与资源曲线。 一键清理本质是把第 ⑥ 节的 killProcess 循环调用一遍,但一定要跳过保护名单和自身进程,并且给用户一个「清理了 N 个」的结果反馈:

private void cleanAll(List<AppProcessInfo> list) {
    int count = 0;
    for (AppProcessInfo item : list) {
        if (!isProtected(item.packageName)
                && !getPackageName().equals(item.packageName)) {
            ActivityManager am =
                    (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
            if (am != null) {
                am.killBackgroundProcesses(item.packageName);
                count++;
            }
        }
    }
    Toast.makeText(this, "已请求清理 " + count + " 个进程",
            Toast.LENGTH_SHORT).show();
    refresh();
}

如果想做资源占用曲线,可以在每次刷新时把总内存记进一个 ArrayDeque,用自定义 View 画折线。这一步属于进阶,先把前面五步做扎实,再考虑它。

几个容易踩的坑。 一是 setHasFixedSize(true) 只在列表整体尺寸不随内容变化时才安全,如果列表项高度会动态变化(比如展开详情),别开它;二是 Glide 加载 Drawable 时要注意 Drawable 可能被复用,最好先转成 Bitmap 再交给 Glide;三是缓存 Drawable 会占内存,图标缓存建议用 LruCache 限制大小,别用无界的 HashMap;四是排序和过滤都会改变列表结构,记得在切换后把滚动位置重置到顶部,否则用户会看到列表「跳」到中间。

到这里,性能优化和扩展思路就讲完了:刷新粒度、图标异步、数据缓存、分级刷新,再加上排序、搜索、一键清理三个扩展方向。下一节我们把前面所有模块整合成一个完整可运行的项目。

⑪ 完整实战代码:一个可运行的进程管理工具

前面十节把每个环节拆开讲了一遍,但真正动手时,很多读者还是会卡在“这些片段怎么拼成一个能跑的项目”。这一节我们把所有代码整合起来,给出一个完整可运行的 Android 版本实现。你只需要在 Android Studio 中新建一个 Empty Views Activity 工程,然后按下面的路径把文件逐个放进去,点击工具栏的绿色 Run 按钮即可看到效果。

11.1 项目结构与依赖配置

动手写代码之前,先把「文件放哪里、依赖怎么配」这两件事定下来。很多新手跑不通项目,不是逻辑写错了,而是文件放错了目录、或者 Gradle 里少了一行依赖。这一小节我们分三步:先看目录结构,再改 build.gradle,最后同步验证。

第一步:对照目录结构建包。 在 Android Studio 左侧 Project 视图切到「Android」模式,展开 app/src/main,按下面的结构把包和文件建好。注意 model 和 util 是子包,右键 com.example.processmanager → New → Package 输入 model 即可,不要手动在磁盘上建文件夹,否则 IDE 可能识别不到:

app/
├── src/main/
│   ├── AndroidManifest.xml
│   ├── java/com/example/processmanager/
│   │   ├── MainActivity.java
│   │   ├── ProcessAdapter.java
│   │   ├── ProcessRepository.java
│   │   ├── model/AppProcessInfo.java
│   │   └── util/Formatters.java
│   └── res/layout/
│       ├── activity_main.xml
│       └── item_process.xml

第二步:配置 app/build.gradle。 打开 app/build.gradle(注意是 app 模块下的那个,不是项目根目录的),确认 namespace、compileSdk、minSdk、targetSdk 四个值,再补上三个依赖。namespace 必须和你的包名一致,否则 R 类会找不到:

android {
    namespace 'com.example.processmanager'
    compileSdk 34

    defaultConfig {
        applicationId "com.example.processmanager"
        minSdk 24
        targetSdk 34
        versionCode 1
        versionName "1.0"
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'androidx.recyclerview:recyclerview:1.3.2'
    implementation 'com.google.android.material:material:1.11.0'
}

三个依赖各管一件事:appcompat 提供 AppCompatActivity,recyclerview 提供列表控件,material 提供 MaterialButton 和 Material3 主题。少任何一个,后面布局里的控件都会标红。

第三步:同步并验证。 改完 Gradle 文件后,Android Studio 右上角会出现「Sync Now」提示,点一下等进度条走完。如果没弹提示,点工具栏的大象图标(Sync Project with Gradle Files)手动同步。同步成功后,Build 窗口会显示 BUILD SUCCESSFUL,此时 R.layout.activity_main 这类引用才能正常解析。

几个容易踩的坑。 一是 compileSdk 34 需要本地已安装 Android 14 的 SDK Platform,没装的话在 SDK Manager 里勾选下载,否则同步会报「failed to find target」;二是 JavaVersion.VERSION_17 要求 JDK 17,Android Studio Hedgehog 自带,如果用的是老版本 IDE,改成 VERSION_11 或 VERSION_1_8 也能跑;三是依赖版本号不要随手写「+」,比如 recyclerview:1.+,这样每次同步都可能拉到新版本,导致别人能跑你跑不了,固定版本号最稳。

到这里,项目骨架和依赖就配好了。下一小节我们往 AndroidManifest.xml 里补权限声明,这是后面所有进程操作能跑起来的前提。

11.2 权限声明

上一小节把项目骨架和依赖配好了,这一小节我们往 AndroidManifest.xml 里补权限。这一步看着简单,但新手最容易在这里翻车:权限写错位置、拼错名字、或者漏了 tools 命名空间导致编译直接报错。我们分三步走:先打开文件,再写权限,最后检查是否生效。

第一步:找到并打开 Manifest。 在 Android Studio 左侧 Project 视图切到「Android」模式,展开 app → manifests,双击 AndroidManifest.xml。注意是 app/src/main/AndroidManifest.xml 这一个,不要和 build 目录下自动生成的那个搞混。

第二步:在 <manifest> 下、<application> 之前声明权限。 顺序很重要:<uses-permission> 必须写在 <application> 标签外面,写在里面系统会直接忽略。同时因为用到了 tools:ignore,要在根标签补上 xmlns:tools 命名空间,否则会报「unbound prefix」:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES" />
    <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
        tools:ignore="QueryAllPackagesPermission" />

    <application
        android:allowBackup="true"
        android:label="进程管理器"
        android:theme="@style/Theme.Material3.DayNight">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

这两条权限各管一件事:KILL_BACKGROUND_PROCESSES 是第 ⑥ 节调用 killBackgroundProcesses 的前提,没有它一调用就抛 SecurityException;QUERY_ALL_PACKAGES 是第 ④ 节用 PackageManager 查应用名称和图标的前提,没有它 Android 11 及以上会一直抛 NameNotFoundException,列表里全是进程名没有应用名。

第三步:同步并验证权限是否生效。 改完点右上角「Sync Now」,或者点工具栏大象图标手动同步。想确认权限真的打进了安装包,可以在 Android Studio 底部打开 Terminal,执行:

./gradlew :app:processDebugMainManifest

命令跑完后打开 app/build/intermediates/merged_manifests/debug/AndroidManifest.xml,搜索 KILL_BACKGROUND_PROCESSES,能看到就说明合并成功。如果搜不到,多半是写错了标签名或者放错了位置。

几个容易踩的坑。 一是 QUERY_ALL_PACKAGES 属于 Google Play 的敏感权限,上架时必须填写用途说明,本地调试无所谓,但正式发布前建议换成第 ⑨ 节讲过的 <queries> 精准声明;二是这两条都是普通权限,安装时自动授予,不需要在代码里调 requestPermissions,调了系统也会忽略;三是改完 Manifest 一定要重新 Run 一次,只点「Apply Changes」有时不会重新合并 Manifest,导致权限看起来「没生效」;四是 tools:ignore 只是压掉 Lint 警告,不会改变权限本身的行为,别把它当成「申请权限」的开关。

到这里,权限就声明好了。下一小节我们写两个布局文件,把主界面和列表项的样子搭出来。

11.3 布局文件

上一小节把权限声明好了,这一小节我们写两个布局文件:一个主界面 activity_main.xml,一个列表项 item_process.xml。这两个文件在第 ② 节已经出现过一次,这里给出的是完整可运行的最终版本,直接复制覆盖即可。我们分三步:先建文件,再写主界面,最后写列表项。

第一步:新建两个布局文件。 在 Android Studio 左侧 Project 视图切到「Android」模式,展开 app → res → layout,右键 layout → New → Layout Resource File,Root element 填 LinearLayout,分别建出 activity_main.xml 和 item_process.xml。注意文件名必须全小写、用下划线分隔,写成 ActivityMain.xml 会在 R.layout 里找不到。

第二步:写主界面 activity_main.xml。 整体是垂直的 LinearLayout,顶部一个刷新按钮,下面一个 RecyclerView。关键在列表容器的高度:必须写 0dp 加 layout_weight="1",它才会自动占满按钮以下的剩余空间;如果写成 wrap_content,列表只会显示一行高:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.google.android.material.button.MaterialButton
        android:id="@+id/btnRefresh"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="12dp"
        android:text="刷新进程列表" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
</LinearLayout>

这里用 MaterialButton 而不是普通 Button,是因为第 11.1 小节已经引入了 material 依赖,用它能直接拿到 Material3 的圆角和配色,不用再自己写 background。

第三步:写列表项 item_process.xml。 每个列表项包含图标、名称、PID 与内存占用四块内容。用横向 LinearLayout 排列:图标固定在左侧,名称和 PID 放在中间的竖排容器里,内存占用靠右对齐。中间容器宽度写 0dp 加 layout_weight="1",把剩余空间全吃掉,右侧内存才会被顶到最右边:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center_vertical"
    android:orientation="horizontal"
    android:padding="12dp">

    <ImageView
        android:id="@+id/ivIcon"
        android:layout_width="40dp"
        android:layout_height="40dp" />

    <LinearLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:layout_weight="1"
        android:orientation="vertical">

        <TextView
            android:id="@+id/tvName"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:ellipsize="end"
            android:maxLines="1"
            android:textSize="15sp"
            android:textStyle="bold" />

        <TextView
            android:id="@+id/tvPid"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="12sp" />
    </LinearLayout>

    <TextView
        android:id="@+id/tvMemory"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="13sp" />
</LinearLayout>

名称那一行加了 ellipsize="end" 和 maxLines="1",是为了防止长包名把布局撑破——进程名动辄三四十个字符,不加这两行,列表项会被拉成两行甚至三行,滑动时高度参差不齐。

第四步:在预览里检查效果。 打开 activity_main.xml,点右上角切到「Design」视图,能看到按钮在上、列表在下就对了。再打开 item_process.xml,Design 视图里会显示一个空白的列表项,因为 TextView 还没填文字,这是正常的,运行后由适配器动态赋值。

几个容易踩的坑。 一是 RecyclerView 的标签必须写全 androidx.recyclerview.widget.RecyclerView,写成 RecyclerView 会报「unexpected element」;二是 MaterialButton 需要主题继承 Material3,如果 themes.xml 里还是 Theme.AppCompat,按钮会变成普通样式甚至崩溃,检查一下 android:theme="@style/Theme.Material3.DayNight" 是否生效;三是两个布局里的控件 id 要和后面 ProcessAdapter 里 findViewById 用的名字完全一致,ivIcon、tvName、tvPid、tvMemory 一个字母都不能差,否则运行时会拿到 null 直接空指针;四是改完布局记得重新 Run,只点「Apply Changes」有时不会重新编译资源,界面看起来「没变化」。

到这里,两个布局文件就写好了。下一小节我们写数据模型和工具类,把进程信息封装成 UI 能直接用的结构。

11.4 数据模型与工具类

上一小节把界面搭好了,这一小节我们写两个「不碰界面」的类:一个是数据模型 AppProcessInfo,负责把进程信息装进一个干净的对象里;另一个是工具类 Formatters,负责把字节数、时间戳这类原始数字转成人能看懂的文字。它们都不依赖 Activity,写完之后整个项目的数据结构就统一了。

第一步:新建两个包。 在 Android Studio 左侧 Project 视图切到「Android」模式,右键 com.example.processmanager → New → Package,分别建出 model 和 util 两个包。分层的好处是后面写仓库、适配器时,import 一眼就能看出这个类属于哪一层,不会所有类都堆在根包下。

第二步:写数据模型 model/AppProcessInfo.java。 只保留 UI 真正关心的字段,不要把整个 RunningAppProcessInfo 搬来搬去。字段全部用 public 是为了让仓库和适配器直接读写,省掉一堆 getter/setter;如果你所在团队规范要求封装,改成私有字段加 getter 即可,逻辑不变:

package com.example.processmanager.model;

import android.graphics.drawable.Drawable;

public class AppProcessInfo {
    public String packageName;
    public String processName;
    public int pid;
    public int uid;
    public long memoryBytes;
    public String label;
    public Drawable icon;
    public boolean isSystemApp;

    public AppProcessInfo(String packageName, String processName, int pid, int uid) {
        this.packageName = packageName;
        this.processName = processName;
        this.pid = pid;
        this.uid = uid;
    }
}

这里 icon 用 Drawable 而不是资源 id,是因为图标来自 PackageManager.getApplicationIcon,拿到的是已经解码好的 Drawable,直接塞进 ImageView 即可,不用再走一次资源查找。

第三步:写工具类 util/Formatters.java。 进程内存动辄几十万字节,直接显示 123456789 用户根本读不出来,所以要按 B / KB / MB / GB 逐级换算。构造函数写成 private,是为了防止有人 new Formatters()——工具类只提供静态方法,不需要实例:

package com.example.processmanager.util;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

public final class Formatters {

    private Formatters() {
    }

    public static String formatBytes(long bytes) {
        if (bytes < 1024) {
            return bytes + " B";
        }
        double kb = bytes / 1024.0;
        if (kb < 1024) {
            return String.format(Locale.getDefault(), "%.1f KB", kb);
        }
        double mb = kb / 1024.0;
        if (mb < 1024) {
            return String.format(Locale.getDefault(), "%.1f MB", mb);
        }
        return String.format(Locale.getDefault(), "%.2f GB", mb / 1024.0);
    }

    public static String formatTime(long timestamp) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault());
        return sdf.format(new Date(timestamp));
    }
}

formatTime 这一小节暂时用不上,但下一小节读 APK 安装时间时会用到,先一起写好,省得回头再补。String.format 里显式传 Locale.getDefault(),是为了避免在部分语言环境下小数点变成逗号,导致显示成 1,5 MB。

第四步:验证一下。 在 MainActivity 里临时写一行 Log.d("fmt", Formatters.formatBytes(1536 * 1024));,运行后看 Logcat 输出 1.5 MB 就说明工具类没问题,验证完把这行删掉即可。

几个容易踩的坑。 一是包名必须和 package 声明完全一致,model 包下的文件第一行就得是 package com.example.processmanager.model;,少一层或多一层都会编译报错;二是 AppProcessInfo 的构造函数只接收四个参数,memoryBytes、label、icon 这些是后面仓库里逐个赋值的,别指望构造时就全填好;三是 formatBytes 传入负数会走进第一个分支返回 -1 B,正常进程内存不会是负数,但如果从系统 API 拿到异常值,建议在调用处先做一次 Math.max(0, bytes) 兜底;四是工具类不要写成单例再到处传,静态方法直接调最省事。

到这里,数据模型和工具类就准备好了。下一小节我们写 ProcessRepository,把获取进程、查应用信息、算内存这些活儿都收进一个类里。

11.5 进程数据仓库

上一小节把数据模型和工具类准备好了,这一小节我们写 ProcessRepository——它是整个项目里唯一「知道数据从哪来」的类。界面层只管调 loadProcesses 拿结果,至于怎么取进程、怎么查应用信息、怎么算内存,全部收进这个类里。这样以后想换数据源(比如接 UsageStatsManager),只改这一个文件,UI 和适配器一行都不用动。

第一步:新建仓库类。 在 Android Studio 里右键 com.example.processmanager → New → Java Class,输入 ProcessRepository。它不继承任何组件,只持有一个 Context,注意构造函数里存的是 getApplicationContext(),避免仓库长期持有 Activity 造成内存泄漏:

public class ProcessRepository {

    public interface Callback {
        void onLoaded(List<AppProcessInfo> list);
    }

    private final Context context;
    private final ExecutorService executor = Executors.newSingleThreadExecutor();
    private final Map<String, ApplicationInfo> appInfoCache = new HashMap<>();

    public ProcessRepository(Context context) {
        this.context = context.getApplicationContext();
    }
}

第二步:把耗时活儿丢到后台线程。 loadProcesses 一进来就 executor.execute,所有取进程、查包名的操作都在子线程跑,跑完通过 Callback 把结果交回去。回调里不碰 UI,切主线程的事交给调用方(MainActivity 里用 runOnUiThread),这样仓库和界面彻底解耦:

public void loadProcesses(Callback callback) {
    executor.execute(() -> {
        List<AppProcessInfo> result = new ArrayList<>();
        ActivityManager am =
                (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
        PackageManager pm = context.getPackageManager();

        if (am == null) {
            callback.onLoaded(result);
            return;
        }

        List<ActivityManager.RunningAppProcessInfo> running = am.getRunningAppProcesses();
        if (running == null) {
            callback.onLoaded(result);
            return;
        }

        for (ActivityManager.RunningAppProcessInfo info : running) {
            AppProcessInfo item = new AppProcessInfo(
                    info.processName, info.processName, info.pid, info.uid);
            item.memoryBytes = readMemory(am, info.pid);

            ApplicationInfo appInfo = queryAppInfo(pm, info.processName);
            if (appInfo != null) {
                item.packageName = appInfo.packageName;
                item.label = pm.getApplicationLabel(appInfo).toString();
                item.icon = pm.getApplicationIcon(appInfo);
                item.isSystemApp =
                        (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
            } else {
                item.label = info.processName;
            }
            result.add(item);
        }

        result.sort((a, b) -> Long.compare(b.memoryBytes, a.memoryBytes));
        callback.onLoaded(result);
    });
}

注意最后那行 sort:按内存从大到小排,用户打开列表第一眼看到的就是「最占内存的那几个」,比按系统返回顺序直观得多。排序放在子线程做,主线程只负责渲染。

第三步:查应用信息时先截断子进程名。 子进程的 processName 形如 com.example.app:remote,冒号后面是进程后缀,直接拿去 getApplicationInfo 会抛 NameNotFoundException。所以先截断到冒号前,再用 HashMap 缓存,避免同一个包被反复查询:

private ApplicationInfo queryAppInfo(PackageManager pm, String processName) {
    String pkg = processName.contains(":")
            ? processName.substring(0, processName.indexOf(':'))
            : processName;
    if (appInfoCache.containsKey(pkg)) {
        return appInfoCache.get(pkg);
    }
    try {
        ApplicationInfo info = pm.getApplicationInfo(pkg, 0);
        appInfoCache.put(pkg, info);
        return info;
    } catch (PackageManager.NameNotFoundException e) {
        return null;
    }
}

第四步:内存读取做个版本兜底。 单进程的精确内存要借助 Debug.MemoryInfo,实现起来比较绕,这里先用系统可用内存作为参考值,同时用版本判断兜住低版本系统:

private long readMemory(ActivityManager am, int pid) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
        return 0L;
    }
    ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
    am.getMemoryInfo(memoryInfo);
    // 简化处理:单进程内存需借助 Debug.MemoryInfo,此处返回系统可用内存作为参考
    return memoryInfo.availMem;
}

第五步:提供关闭方法。 仓库自己开的线程池,就得自己负责关。加一个 shutdown,在 MainActivity 的 onDestroy 里调用,避免 Activity 销毁后线程还在后台跑:

public void shutdown() {
    executor.shutdownNow();
}

几个容易踩的坑。 一是 Callback 是在子线程回调的,千万别在里面直接 adapter.submit 或弹 Toast,一定要先切主线程,否则会抛 CalledFromWrongThreadException;二是 appInfoCache 用的是普通 HashMap,因为只在单线程的 executor 里读写,不需要换成 ConcurrentHashMap,但如果你以后改成多线程加载,记得同步处理;三是 getRunningAppProcesses() 在高版本返回的列表本就不完整,仓库里不要试图「补齐」,如实把拿到的结果交出去,界面层再提示用户即可;四是 readMemory 返回的是系统可用内存,不是单进程内存,详情页文案里要写「内存参考」而不是「内存占用」,避免误导。

到这里,数据仓库就写完了。下一小节我们写 ProcessAdapter,把仓库交出来的列表渲染到界面上。

11.6 列表适配器

上一小节仓库把数据准备好了,这一小节我们写 ProcessAdapter,把列表渲染到界面上。它只做三件事:复用视图、把模型字段填进控件、把点击事件抛给 Activity。写完之后,仓库交出来的 List<AppProcessInfo> 就能直接显示成带图标、带名称、带内存的列表项。

第一步:新建适配器类。 在 Android Studio 里右键 com.example.processmanager → New → Java Class,输入 ProcessAdapter,继承 RecyclerView.Adapter<ProcessAdapter.VH>。IDE 会立刻标红提示要实现三个抽象方法,按 Alt+Enter 选择「Implement methods」即可自动生成 onCreateViewHolder、onBindViewHolder、getItemCount 的骨架。

第二步:定义回调与数据容器。 适配器不关心「点了之后弹什么」,只负责把被点的对象交出去,所以先定义一个 OnItemClick 接口。数据用 ArrayList 持有,对外只暴露 submit 方法,避免外部直接改内部集合导致下标错乱:

public interface OnItemClick {
    void onClick(AppProcessInfo info);
}

private final List<AppProcessInfo> data = new ArrayList<>();
private final OnItemClick listener;

public ProcessAdapter(OnItemClick listener) {
    this.listener = listener;
}

public void submit(List<AppProcessInfo> list) {
    data.clear();
    data.addAll(list);
    notifyDataSetChanged();
}

第三步:写 ViewHolder。 把 item_process.xml 里的四个控件一次性 findViewById 出来缓存住,onBindViewHolder 里就不用反复查找。注意 ViewHolder 的构造函数要接收 itemView 并传给父类,否则 itemView 为 null,后面设 Tag 会直接空指针:

static class VH extends RecyclerView.ViewHolder {
    ImageView ivIcon;
    TextView tvName;
    TextView tvPid;
    TextView tvMemory;

    VH(@NonNull View itemView) {
        super(itemView);
        ivIcon = itemView.findViewById(R.id.ivIcon);
        tvName = itemView.findViewById(R.id.tvName);
        tvPid = itemView.findViewById(R.id.tvPid);
        tvMemory = itemView.findViewById(R.id.tvMemory);
    }
}

第四步:创建与绑定视图。 onCreateViewHolder 只负责 inflate 布局,onBindViewHolder 只负责填数据,两者分工明确。图标为空时用系统默认图标兜底,避免出现空白方块;点击监听写在 onBindViewHolder 里,因为 ViewHolder 会被复用,写在创建阶段会把回调绑到错误的数据上:

@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    View view = LayoutInflater.from(parent.getContext())
            .inflate(R.layout.item_process, parent, false);
    return new VH(view);
}

@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
    AppProcessInfo item = data.get(position);
    holder.tvName.setText(item.label);
    holder.tvPid.setText("PID: " + item.pid + "  UID: " + item.uid);
    holder.tvMemory.setText(Formatters.formatBytes(item.memoryBytes));
    if (item.icon != null) {
        holder.ivIcon.setImageDrawable(item.icon);
    } else {
        holder.ivIcon.setImageResource(android.R.drawable.sym_def_app_icon);
    }
    holder.itemView.setTag(item);
    holder.itemView.setOnClickListener(v -> {
        if (listener != null) {
            listener.onClick((AppProcessInfo) v.getTag());
        }
    });
}

@Override
public int getItemCount() {
    return data.size();
}

第五步:完整代码。 把上面几段拼起来,就是可以直接放进项目的 ProcessAdapter.java:

package com.example.processmanager;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

import com.example.processmanager.model.AppProcessInfo;
import com.example.processmanager.util.Formatters;

import java.util.ArrayList;
import java.util.List;

public class ProcessAdapter extends RecyclerView.Adapter<ProcessAdapter.VH> {

    public interface OnItemClick {
        void onClick(AppProcessInfo info);
    }

    private final List<AppProcessInfo> data = new ArrayList<>();
    private final OnItemClick listener;

    public ProcessAdapter(OnItemClick listener) {
        this.listener = listener;
    }

    public void submit(List<AppProcessInfo> list) {
        data.clear();
        data.addAll(list);
        notifyDataSetChanged();
    }

    @NonNull
    @Override
    public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.item_process, parent, false);
        return new VH(view);
    }

    @Override
    public void onBindViewHolder(@NonNull VH holder, int position) {
        AppProcessInfo item = data.get(position);
        holder.tvName.setText(item.label);
        holder.tvPid.setText("PID: " + item.pid + "  UID: " + item.uid);
        holder.tvMemory.setText(Formatters.formatBytes(item.memoryBytes));
        if (item.icon != null) {
            holder.ivIcon.setImageDrawable(item.icon);
        } else {
            holder.ivIcon.setImageResource(android.R.drawable.sym_def_app_icon);
        }
        holder.itemView.setTag(item);
        holder.itemView.setOnClickListener(v -> {
            if (listener != null) {
                listener.onClick((AppProcessInfo) v.getTag());
            }
        });
    }

    @Override
    public int getItemCount() {
        return data.size();
    }

    static class VH extends RecyclerView.ViewHolder {
        ImageView ivIcon;
        TextView tvName;
        TextView tvPid;
        TextView tvMemory;

        VH(@NonNull View itemView) {
            super(itemView);
            ivIcon = itemView.findViewById(R.id.ivIcon);
            tvName = itemView.findViewById(R.id.tvName);
            tvPid = itemView.findViewById(R.id.tvPid);
            tvMemory = itemView.findViewById(R.id.tvMemory);
        }
    }
}

几个容易踩的坑。 一是 submit 里必须先 clear 再 addAll,如果直接 data = list 会把外部集合的引用接过来,仓库下次清空时列表会跟着变空;二是 notifyDataSetChanged 会整表重绘,进程数量不多时够用,如果以后要做局部更新,可以换成 DiffUtil;三是点击回调里取的是 v.getTag() 而不是 getAdapterPosition(),因为快速滑动时下标可能已经过期,取 Tag 拿到的永远是当前这一项绑定的对象;四是 Formatters.formatBytes 要记得 import com.example.processmanager.util.Formatters,漏了会编译报错。

到这里,适配器就写完了。下一小节我们写 MainActivity,把仓库、适配器和终止逻辑串成一个能跑起来的完整界面。

11.7 主界面与终止逻辑

上一小节适配器写完了,这一小节我们写 MainActivity,把仓库、适配器和终止逻辑串成一个能跑起来的完整界面。它负责四件事:初始化列表、触发刷新、弹出详情、执行终止。写完之后,整个项目就闭环了。

第一步:新建 Activity 并声明成员变量。 在 Android Studio 里右键 com.example.processmanager → New → Java Class,输入 MainActivity,继承 AppCompatActivity。成员变量只留三个:仓库、适配器、保护名单。保护名单用 static final,因为它对所有实例都一样,没必要每个 Activity 都建一份:

public class MainActivity extends AppCompatActivity {

    private static final List<String> PROTECTED_PREFIX = Arrays.asList(
            "android", "com.android.systemui", "com.android.phone",
            "com.android.settings", "com.google.android.gms");

    private ProcessRepository repository;
    private ProcessAdapter adapter;
}

第二步:在 onCreate 里把三件套接起来。 顺序很重要:先建仓库,再建适配器(把 showDetail 作为回调传进去),最后给 RecyclerView 设布局管理器和适配器。刷新按钮的点击事件直接调 refresh(),末尾再主动刷一次,这样一进界面就有数据,不用等用户点按钮:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    repository = new ProcessRepository(this);
    adapter = new ProcessAdapter(this::showDetail);

    RecyclerView recyclerView = findViewById(R.id.recyclerView);
    recyclerView.setLayoutManager(new LinearLayoutManager(this));
    recyclerView.setAdapter(adapter);

    MaterialButton btnRefresh = findViewById(R.id.btnRefresh);
    btnRefresh.setOnClickListener(v -> refresh());

    refresh();
}

第三步:写刷新方法,注意切主线程。 仓库的 Callback 是在子线程回调的,所以 adapter.submit 必须包在 runOnUiThread 里,否则会抛 CalledFromWrongThreadException。这里顺手弹一个 Toast 告诉用户加载了多少条,比列表默默变化更有反馈感:

private void refresh() {
    repository.loadProcesses(list -> runOnUiThread(() -> {
        adapter.submit(list);
        Toast.makeText(this, "已加载 " + list.size() + " 个进程",
                Toast.LENGTH_SHORT).show();
    }));
}

第四步:写详情弹窗。 字段之间用 \n 换行,内存用 Formatters.formatBytes 转成可读单位。注意文案写的是「内存参考」而不是「内存占用」,因为仓库里返回的其实是系统可用内存,写「占用」会误导用户:

private void showDetail(AppProcessInfo info) {
    String message = "包名:" + info.packageName
            + "\n进程名:" + info.processName
            + "\nPID:" + info.pid
            + "\nUID:" + info.uid
            + "\n内存参考:" + Formatters.formatBytes(info.memoryBytes)
            + "\n系统应用:" + (info.isSystemApp ? "是" : "否");

    new AlertDialog.Builder(this)
            .setTitle(info.label)
            .setMessage(message)
            .setPositiveButton("终止进程", (d, w) -> killProcess(info))
            .setNegativeButton("关闭", null)
            .show();
}

第五步:写终止逻辑,先校验再执行。 顺序是:保护名单拦截 → 自身进程拦截 → 取 ActivityManager → 调 API → 刷新列表。每一步失败都给一句明确提示,而不是静默返回。杀自己会直接闪退,所以 getPackageName().equals(info.packageName) 这一层判断不能省:

private void killProcess(AppProcessInfo info) {
    if (info == null) {
        return;
    }
    if (isProtected(info.packageName)) {
        Toast.makeText(this, "系统关键进程,已拦截", Toast.LENGTH_SHORT).show();
        return;
    }
    if (getPackageName().equals(info.packageName)) {
        Toast.makeText(this, "不能终止当前应用自身", Toast.LENGTH_SHORT).show();
        return;
    }

    ActivityManager am =
            (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
    if (am == null) {
        Toast.makeText(this, "ActivityManager 不可用", Toast.LENGTH_SHORT).show();
        return;
    }

    try {
        am.killBackgroundProcesses(info.packageName);
        Toast.makeText(this, "已请求终止:" + info.label, Toast.LENGTH_SHORT).show();
    } catch (SecurityException e) {
        Toast.makeText(this, "缺少权限,终止失败", Toast.LENGTH_SHORT).show();
    }
    refresh();
}

第六步:写保护名单判断。 用前缀匹配而不是全等,因为系统进程的包名往往带后缀。对 null 和空串也返回 true,宁可多拦一次,也不要因为包名没查到就放行:

private boolean isProtected(String packageName) {
    if (packageName == null || packageName.isEmpty()) {
        return true;
    }
    for (String prefix : PROTECTED_PREFIX) {
        if (packageName.startsWith(prefix)) {
            return true;
        }
    }
    return false;
}

第七步:在 onDestroy 里关掉仓库。 仓库自己开的线程池,就得自己负责关,否则 Activity 销毁后线程还在后台跑:

@Override
protected void onDestroy() {
    super.onDestroy();
    repository.shutdown();
}

完整代码。 把上面几段拼起来,就是可以直接放进项目的 MainActivity.java:

package com.example.processmanager;

import android.app.ActivityManager;
import android.app.AlertDialog;
import android.content.Context;
import android.os.Bundle;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import com.example.processmanager.model.AppProcessInfo;
import com.example.processmanager.util.Formatters;
import com.google.android.material.button.MaterialButton;

import java.util.Arrays;
import java.util.List;

public class MainActivity extends AppCompatActivity {

    private static final List<String> PROTECTED_PREFIX = Arrays.asList(
            "android", "com.android.systemui", "com.android.phone",
            "com.android.settings", "com.google.android.gms");

    private ProcessRepository repository;
    private ProcessAdapter adapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        repository = new ProcessRepository(this);
        adapter = new ProcessAdapter(this::showDetail);

        RecyclerView recyclerView = findViewById(R.id.recyclerView);
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
        recyclerView.setAdapter(adapter);

        MaterialButton btnRefresh = findViewById(R.id.btnRefresh);
        btnRefresh.setOnClickListener(v -> refresh());

        refresh();
    }

    private void refresh() {
        repository.loadProcesses(list -> runOnUiThread(() -> {
            adapter.submit(list);
            Toast.makeText(this, "已加载 " + list.size() + " 个进程",
                    Toast.LENGTH_SHORT).show();
        }));
    }

    private void showDetail(AppProcessInfo info) {
        String message = "包名:" + info.packageName
                + "\n进程名:" + info.processName
                + "\nPID:" + info.pid
                + "\nUID:" + info.uid
                + "\n内存参考:" + Formatters.formatBytes(info.memoryBytes)
                + "\n系统应用:" + (info.isSystemApp ? "是" : "否");

        new AlertDialog.Builder(this)
                .setTitle(info.label)
                .setMessage(message)
                .setPositiveButton("终止进程", (d, w) -> killProcess(info))
                .setNegativeButton("关闭", null)
                .show();
    }

    private void killProcess(AppProcessInfo info) {
        if (info == null) {
            return;
        }
        if (isProtected(info.packageName)) {
            Toast.makeText(this, "系统关键进程,已拦截", Toast.LENGTH_SHORT).show();
            return;
        }
        if (getPackageName().equals(info.packageName)) {
            Toast.makeText(this, "不能终止当前应用自身", Toast.LENGTH_SHORT).show();
            return;
        }

        ActivityManager am =
                (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
        if (am == null) {
            Toast.makeText(this, "ActivityManager 不可用", Toast.LENGTH_SHORT).show();
            return;
        }

        try {
            am.killBackgroundProcesses(info.packageName);
            Toast.makeText(this, "已请求终止:" + info.label, Toast.LENGTH_SHORT).show();
        } catch (SecurityException e) {
            Toast.makeText(this, "缺少权限,终止失败", Toast.LENGTH_SHORT).show();
        }
        refresh();
    }

    private boolean isProtected(String packageName) {
        if (packageName == null || packageName.isEmpty()) {
            return true;
        }
        for (String prefix : PROTECTED_PREFIX) {
            if (packageName.startsWith(prefix)) {
                return true;
            }
        }
        return false;
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        repository.shutdown();
    }
}

几个容易踩的坑。 一是 refresh() 里一定要 runOnUiThread,仓库回调在子线程,直接 submit 会崩;二是终止后立刻调 refresh() 只是「重新拉一次列表」,killBackgroundProcesses 本身是异步的,进程可能过一会儿才消失,别指望刷新完就立刻看不到它;三是 PROTECTED_PREFIX 里别漏了 com.android.settings,设置进程被杀会导致系统界面异常;四是 onDestroy 里只关仓库,不要顺手把 adapter 置空,RecyclerView 自己会处理;五是如果 Toast 在 killProcess 里连续弹两次(一次终止、一次刷新),可以把刷新的 Toast 去掉,只保留终止提示。

到这里,主界面和终止逻辑就写完了。下一小节我们跑起来验证一下整个项目。

11.8 运行与验证

代码写完了,接下来最重要的一步是把它真正跑起来。这一小节我们按「编译 → 授权 → 刷新 → 详情 → 终止」的顺序走一遍完整流程,每一步都告诉你该看到什么现象、看不到时该往哪里查。

第一步:编译并安装。 把前面几个小节的 AppProcessInfo、Formatters、ProcessRepository、ProcessAdapter、MainActivity 以及两个布局文件放进对应目录后,点击工具栏的 Run(或按 Shift+F10)。Android Studio 会先执行 Gradle 构建,Build 窗口出现 BUILD SUCCESSFUL 后自动把 APK 装到模拟器或真机上。如果卡在 Build 阶段报红,先看第一条错误:多数情况是包名路径与 build.gradle 里的 namespace 不一致,或者 import 漏了 com.example.processmanager.util.Formatters。

第二步:确认权限已生效。 首次启动时,应用会请求应用列表访问权限,弹窗点「允许」即可。如果没弹窗、列表又是空的,去「设置 → 应用 → 进程管理器 → 权限」里手动确认权限已开启。这一步很关键,权限没给,后面所有数据都是空的。

第三步:点击刷新,观察列表。 授权后点击顶部的「刷新进程列表」按钮,正常情况下会弹出一句「已加载 N 个进程」的 Toast,列表随即出现带图标、名称、PID 和内存参考的条目。如果 Toast 显示 0 个进程,先别怀疑代码,回到第 ③ 节确认 getRunningAppProcesses() 是否返回了 null,以及是否在高版本系统上运行。

第四步:点开详情,核对字段。 点击任意一条列表项,会弹出详情对话框,里面依次是包名、进程名、PID、UID、内存参考和是否系统应用。重点核对两处:一是包名是否已经去掉了 :remote 这类进程后缀;二是系统应用的标题后面是否带上了「(系统应用)」标记。字段对不上,说明第 ④ 节的 queryAppInfo 截断逻辑或第 ⑤ 节的标题拼接没生效。

第五步:测试终止流程。 在详情弹窗里点「终止进程」,分三种情况验证:

  • 点到系统关键进程(如「设置」),应立刻提示「系统关键进程,已拦截」,列表不变;
  • 点到本应用自身,应提示「不能终止当前应用自身」;
  • 点到普通第三方应用,应提示「已请求终止:xxx」,随后列表自动刷新。

三种情况都能对上,说明第 ⑥ 节的校验链路和第 11.7 节的 killProcess 都接对了。

第六步:验证终止结果。 killBackgroundProcesses 只是「请求」系统清理,系统有权拒绝,也可能过一会儿又把进程拉起来。所以终止后刷新列表,目标进程可能还在,这是正常现象,不是代码 bug。想看得更清楚,可以连续点两次刷新,间隔几秒,观察目标进程是否消失。

第七步:常见现象对照表。 把跑起来后最容易遇到的几种情况整理成一张表,方便你快速定位:

现象可能原因排查方向
列表一直为空权限未授予 / 高版本系统限制检查权限开关,确认 getRunningAppProcesses() 返回值
应用名显示成包名queryAppInfo 查不到应用信息检查进程名截断逻辑与 NameNotFoundException 分支
图标全是默认图标getApplicationIcon 返回空确认在后台线程调用,且 ApplicationInfo 不为空
点击列表无反应点击监听未绑定或 Tag 为空检查 onBindViewHolder 里的 setOnClickListener
终止后提示权限失败未声明 KILL_BACKGROUND_PROCESSES回到第 11.2 节补权限声明
滑动明显卡顿主线程做了 IO 或重复 inflate确认数据加载在 executor 中,视图在 onCreateViewHolder 中创建

第八步:换数据源的扩展思路。 getRunningAppProcesses() 在高版本系统上返回的列表并不完整,这是系统策略限制,不是代码问题。如果你需要更全面的数据,可以在此基础上接入 UsageStatsManager,把获取逻辑替换掉 ProcessRepository.loadProcesses 中的实现即可,其余 UI 与适配层代码无需改动。这正是前面强调分层设计带来的好处:换数据源,不动界面。

到这里,整个项目就从零跑通了:环境搭好、界面能显示、数据能加载、详情能弹出、终止有校验、结果可验证。接下来你可以在这个骨架上继续加功能,比如按内存排序、搜索过滤、定时自动刷新,都是在这套结构上小步迭代就能完成的事。

⑫ 完整实战代码整合

前面十一节把每个模块拆开讲了一遍,但真正动手时,最容易卡住的不是某一段逻辑,而是「这几个类怎么拼在一起、谁调谁」。这一节我们把 MainActivity、ProcessAdapter、AppProcessInfo、Formatters 四个类的完整代码集中放出来,每个类一个独立代码块,关键逻辑都加了注释。你只需要按第 11.1 小节的目录结构建好包,把四个文件分别放进去,再配上第 11.2 节的权限声明和第 11.3 节的两个布局文件,就能直接 Run 起来。

四个类的职责先理清楚,后面看代码就不会迷路:

  • AppProcessInfo:纯数据模型,只装字段,不碰任何 Android 组件逻辑;
  • Formatters:纯工具类,负责把字节数、时间戳转成人能看懂的文字;
  • ProcessAdapter:只负责「把模型渲染成列表项」和「把点击事件抛出去」,不关心点了之后做什么;
  • MainActivity:总调度,负责初始化、刷新、弹详情、终止进程,是唯一持有 Context 的地方。

12.1 AppProcessInfo:数据模型

这个类没有任何依赖,只保留 UI 真正要显示的字段。字段用 public 是为了让适配器和 Activity 直接读写,省掉一堆 getter/setter;如果团队规范要求封装,改成私有字段加 getter 即可,逻辑完全不变。

package com.example.processmanager.model;

import android.graphics.drawable.Drawable;

/**
 * 进程信息数据模型。
 * 只保留 UI 需要展示的字段,避免把系统的 RunningAppProcessInfo 到处传递。
 */
public class AppProcessInfo {

    /** 应用包名,子进程会截断到冒号前,例如 com.example.app:remote -> com.example.app */
    public String packageName;
    /** 进程名,保留原始值,详情页需要展示 */
    public String processName;
    /** 进程 ID */
    public int pid;
    /** 用户 ID */
    public int uid;
    /** 内存参考值(字节),读不到时为 0 */
    public long memoryBytes;
    /** 应用显示名称,查不到应用信息时用进程名兜底 */
    public String label;
    /** 应用图标,可能为 null,适配器里要用默认图标兜底 */
    public Drawable icon;
    /** 是否为系统应用,用于详情页提示与终止拦截 */
    public boolean isSystemApp;

    public AppProcessInfo(String packageName, String processName, int pid, int uid) {
        this.packageName = packageName;
        this.processName = processName;
        this.pid = pid;
        this.uid = uid;
    }
}

12.2 Formatters:格式化工具类

工具类只提供静态方法,构造函数写成 private 防止被 new。formatBytes 按 B / KB / MB / GB 逐级换算,formatTime 把时间戳转成 yyyy-MM-dd HH:mm。两处都显式传 Locale.getDefault(),避免某些语言环境下小数点变成逗号,显示成 1,5 MB。

package com.example.processmanager.util;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

/**
 * 格式化工具类:字节数、时间戳转可读文本。
 * 纯静态方法,不需要实例化。
 */
public final class Formatters {

    private Formatters() {
        // 工具类禁止实例化
    }

    /**
     * 把字节数转成 B / KB / MB / GB。
     * 传入负数时返回 "0 B",避免界面出现 "-1 B" 这种奇怪的值。
     */
    public static String formatBytes(long bytes) {
        if (bytes <= 0) {
            return "0 B";
        }
        if (bytes < 1024) {
            return bytes + " B";
        }
        double kb = bytes / 1024.0;
        if (kb < 1024) {
            return String.format(Locale.getDefault(), "%.1f KB", kb);
        }
        double mb = kb / 1024.0;
        if (mb < 1024) {
            return String.format(Locale.getDefault(), "%.1f MB", mb);
        }
        return String.format(Locale.getDefault(), "%.2f GB", mb / 1024.0);
    }

    /**
     * 把时间戳转成 yyyy-MM-dd HH:mm。
     * 时间戳小于等于 0 时返回「未知」,兼容部分 ROM 返回 0 的情况。
     */
    public static String formatTime(long timestamp) {
        if (timestamp <= 0) {
            return "未知";
        }
        SimpleDateFormat sdf =
                new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault());
        return sdf.format(new Date(timestamp));
    }
}

12.3 ProcessAdapter:列表适配器

适配器只做三件事:复用视图、把模型字段填进控件、把点击事件抛给 Activity。两个关键点:一是点击监听必须写在 onBindViewHolder 里,因为 ViewHolder 会被复用,写在 onCreateViewHolder 会把回调绑到错误的数据上;二是取数据用 v.getTag() 而不是 getAdapterPosition(),快速滑动时下标可能已经过期。

package com.example.processmanager;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

import com.example.processmanager.model.AppProcessInfo;
import com.example.processmanager.util.Formatters;

import java.util.ArrayList;
import java.util.List;

/**
 * 进程列表适配器。
 * 只负责渲染与事件抛出,具体弹窗、终止逻辑由 MainActivity 决定。
 */
public class ProcessAdapter extends RecyclerView.Adapter<ProcessAdapter.VH> {

    /** 点击回调,把被点的进程对象交给 Activity */
    public interface OnItemClick {
        void onClick(AppProcessInfo info);
    }

    private final List<AppProcessInfo> data = new ArrayList<>();
    private final OnItemClick listener;

    public ProcessAdapter(OnItemClick listener) {
        this.listener = listener;
    }

    /**
     * 提交新数据。
     * 先 clear 再 addAll,避免直接持有外部集合引用导致数据被意外清空。
     */
    public void submit(List<AppProcessInfo> list) {
        data.clear();
        if (list != null) {
            data.addAll(list);
        }
        notifyDataSetChanged();
    }

    @NonNull
    @Override
    public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        // 只在创建时 inflate,复用交给 RecyclerView 的缓存池
        View view = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.item_process, parent, false);
        return new VH(view);
    }

    @Override
    public void onBindViewHolder(@NonNull VH holder, int position) {
        AppProcessInfo item = data.get(position);

        holder.tvName.setText(item.label);
        holder.tvPid.setText("PID: " + item.pid + "  UID: " + item.uid);
        holder.tvMemory.setText(Formatters.formatBytes(item.memoryBytes));

        // 图标可能为 null(系统进程查不到),用系统默认图标兜底
        if (item.icon != null) {
            holder.ivIcon.setImageDrawable(item.icon);
        } else {
            holder.ivIcon.setImageResource(android.R.drawable.sym_def_app_icon);
        }

        // 把整个模型塞进 Tag,点击时直接取,避免按下标回查
        holder.itemView.setTag(item);
        holder.itemView.setOnClickListener(v -> {
            if (listener != null) {
                listener.onClick((AppProcessInfo) v.getTag());
            }
        });
    }

    @Override
    public int getItemCount() {
        return data.size();
    }

    /** ViewHolder 一次性缓存四个控件,避免 onBindViewHolder 里反复 findViewById */
    static class VH extends RecyclerView.ViewHolder {
        ImageView ivIcon;
        TextView tvName;
        TextView tvPid;
        TextView tvMemory;

        VH(@NonNull View itemView) {
            super(itemView);
            ivIcon = itemView.findViewById(R.id.ivIcon);
            tvName = itemView.findViewById(R.id.tvName);
            tvPid = itemView.findViewById(R.id.tvPid);
            tvMemory = itemView.findViewById(R.id.tvMemory);
        }
    }
}

12.4 MainActivity:主界面与终止逻辑

MainActivity 是整个项目的总调度,负责四件事:初始化列表、触发刷新、弹出详情、执行终止。三个关键点:一是仓库回调在子线程,adapter.submit 必须包在 runOnUiThread 里;二是终止前必须走「保护名单 → 自身进程 → 异常捕获」三层校验;三是 onDestroy 里要关掉仓库的线程池。

package com.example.processmanager;

import android.app.ActivityManager;
import android.app.AlertDialog;
import android.content.Context;
import android.os.Bundle;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import com.example.processmanager.model.AppProcessInfo;
import com.example.processmanager.util.Formatters;
import com.google.android.material.button.MaterialButton;

import java.util.Arrays;
import java.util.List;

/**
 * 主界面:初始化列表、刷新数据、展示详情、终止进程。
 */
public class MainActivity extends AppCompatActivity {

    /** 系统关键进程前缀,命中即拦截,宁可多拦也不要误杀 */
    private static final List<String> PROTECTED_PREFIX = Arrays.asList(
            "android",
            "com.android.systemui",
            "com.android.phone",
            "com.android.settings",
            "com.google.android.gms",
            "com.google.android.gsf");

    private ProcessRepository repository;
    private ProcessAdapter adapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 1. 建仓库(内部持有 ApplicationContext,不会泄漏 Activity)
        repository = new ProcessRepository(this);
        // 2. 建适配器,把 showDetail 作为点击回调传进去
        adapter = new ProcessAdapter(this::showDetail);

        // 3. 绑定列表
        RecyclerView recyclerView = findViewById(R.id.recyclerView);
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
        recyclerView.setAdapter(adapter);

        MaterialButton btnRefresh = findViewById(R.id.btnRefresh);
        btnRefresh.setOnClickListener(v -> refresh());

        // 4. 进界面先刷一次,不用等用户点按钮
        refresh();
    }

    /**
     * 刷新列表。
     * 仓库回调在子线程,必须切回主线程再操作 UI。
     */
    private void refresh() {
        repository.loadProcesses(list -> runOnUiThread(() -> {
            adapter.submit(list);
            Toast.makeText(this, "已加载 " + list.size() + " 个进程",
                    Toast.LENGTH_SHORT).show();
        }));
    }

    /**
     * 详情弹窗。
     * 文案写「内存参考」而不是「内存占用」,因为仓库返回的是系统可用内存。
     */
    private void showDetail(AppProcessInfo info) {
        String message = "包名:" + info.packageName
                + "\n进程名:" + info.processName
                + "\nPID:" + info.pid
                + "\nUID:" + info.uid
                + "\n内存参考:" + Formatters.formatBytes(info.memoryBytes)
                + "\n系统应用:" + (info.isSystemApp ? "是" : "否");

        new AlertDialog.Builder(this)
                .setTitle(info.label)
                .setMessage(message)
                .setPositiveButton("终止进程", (d, w) -> killProcess(info))
                .setNegativeButton("关闭", null)
                .show();
    }

    /**
     * 终止进程。
     * 校验顺序:保护名单 -> 自身进程 -> 取 ActivityManager -> 调 API -> 刷新。
     * 每一步失败都给明确提示,不静默返回。
     */
    private void killProcess(AppProcessInfo info) {
        if (info == null) {
            return;
        }
        if (isProtected(info.packageName)) {
            Toast.makeText(this, "系统关键进程,已拦截", Toast.LENGTH_SHORT).show();
            return;
        }
        // 杀自己会直接闪退,这层判断不能省
        if (getPackageName().equals(info.packageName)) {
            Toast.makeText(this, "不能终止当前应用自身", Toast.LENGTH_SHORT).show();
            return;
        }

        ActivityManager am =
                (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
        if (am == null) {
            Toast.makeText(this, "ActivityManager 不可用", Toast.LENGTH_SHORT).show();
            return;
        }

        try {
            // 只是「请求」系统清理,系统有权拒绝,文案不要写成「已终止」
            am.killBackgroundProcesses(info.packageName);
            Toast.makeText(this, "已请求终止:" + info.label,
                    Toast.LENGTH_SHORT).show();
        } catch (SecurityException e) {
            // 部分定制 ROM 即使声明了权限也可能抛这个异常
            Toast.makeText(this, "缺少权限,终止失败", Toast.LENGTH_SHORT).show();
        }

        // 稍等片刻再刷新,给系统一点处理时间
        refresh();
    }

    /**
     * 保护名单判断。
     * 用前缀匹配而不是全等,因为系统进程包名往往带后缀。
     * null 和空串也返回 true,宁可多拦一次。
     */
    private boolean isProtected(String packageName) {
        if (packageName == null || packageName.isEmpty()) {
            return true;
        }
        for (String prefix : PROTECTED_PREFIX) {
            if (packageName.startsWith(prefix)) {
                return true;
            }
        }
        return false;
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 仓库自己开的线程池,自己负责关,避免 Activity 销毁后线程还在跑
        repository.shutdown();
    }
}

12.5 四个类的调用关系

把四个类放一起看,调用链其实很短:MainActivity.onCreate 建仓库和适配器 → refresh() 调 repository.loadProcesses → 仓库在子线程组装 List<AppProcessInfo> 并回调 → 主线程 adapter.submit → 适配器 onBindViewHolder 用 Formatters 格式化后渲染 → 用户点击 → 回调 showDetail → 点「终止进程」→ killProcess 校验后调系统 API → 再 refresh()。

mermaid diagram

12.6 整合时的注意事项

四个类拼起来之后,最容易出问题的不是逻辑本身,而是几个衔接点,这里集中提醒一下:

  • 包名与 import 必须对齐:AppProcessInfo 在 model 包、Formatters 在 util 包,ProcessAdapter 和 MainActivity 在根包。漏了 import com.example.processmanager.util.Formatters 会直接编译报错。
  • 控件 id 必须一致:适配器里 findViewById 用的 ivIcon、tvName、tvPid、tvMemory,必须和 item_process.xml 里写的完全一致,差一个字母运行时就是空指针。
  • 线程切换不能漏:仓库回调在子线程,adapter.submit 和 Toast 都要在 runOnUiThread 里;getApplicationIcon 这类读 APK 的操作也必须在子线程。
  • ProcessRepository 不能少:本节只整合了四个类,但 MainActivity 依赖第 11.5 小节的 ProcessRepository,记得一并放进项目,否则编译不过。
  • 权限先声明再运行:KILL_BACKGROUND_PROCESSES 和 QUERY_ALL_PACKAGES 要在 AndroidManifest.xml 里声明,否则终止会抛 SecurityException、应用名会显示成包名。

到这里,四个类的完整代码就整合完了。把这几个文件放进项目,配上布局和权限,点 Run 就能看到一个能刷新、能看详情、能终止进程的完整工具。后续想加排序、搜索、定时刷新,都可以在这套结构上小步迭代,不用动整体骨架。