摘要:本文面向想动手做一个进程管理工具的新手开发者,从零开始还原一个可运行的 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",这样它会自动占满按钮以下的剩余空间:
xml
<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",避免长包名把布局撑破:
xml
<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 节完整实现,这里先写调用骨架,方便你边写边看效果:
java
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 线程读取发生并发异常:
java
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 导入即可:
java
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:
java
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 查询应用使用情况,或者至少给用户一个提示。判断写法如下:
java
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 高版本:可接入 UsageStatsManager,或提示数据受限
}
最后别忘了在 onDestroy 里关闭线程池,避免 Activity 销毁后线程还在跑:
java
@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 真正需要的字段,避免把整个系统对象搬来搬去:
java
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 做缓存:
java
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;
}
}
第三步:把原始进程填充成模型。 遍历上一节拿到的进程列表,逐个补全包名、标签、图标和系统应用标记。查不到应用信息时用进程名兜底,保证列表不会出现空白项:
java
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 就能拿到进程信息,不用再按下标回查列表:
java
@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 决定,职责清晰:
java
public interface OnItemClick {
void onClick(AppProcessInfo info);
}
private final OnItemClick listener;
public ProcessAdapter(OnItemClick listener) {
this.listener = listener;
}
第二步:在 onBindViewHolder 里绑定点击。 注意点击监听要写在 onBindViewHolder 里而不是 onCreateViewHolder 里,因为 ViewHolder 会被复用,写在创建阶段会导致点击回调绑定到错误的数据上。取数据时直接读 Tag,避免用 getAdapterPosition() 在快速滑动时拿到过期下标:
java
holder.itemView.setTag(item);
holder.itemView.setOnClickListener(v -> {
if (listener != null) {
listener.onClick((AppProcessInfo) v.getTag());
}
});
第三步:在 MainActivity 里实现详情弹窗。 回到 MainActivity,把 showDetail 方法作为回调传给适配器。用 AlertDialog 拼一段多行文本,字段之间用 \n 换行,内存用上一节的 Formatters.formatBytes 转成可读单位:
java
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();
}
第四步:给系统进程加一层提示。 用户点到系统关键进程时,最好在弹窗里就说明清楚,而不是等他点了「终止进程」才被拦截。可以在标题后面拼一个标记,或者在消息末尾追加一行提示:
java
String title = info.isSystemApp ? info.label + "(系统应用)" : info.label;
String tip = info.isSystemApp
? "\n\n提示:系统关键进程不建议终止,可能导致功能异常。"
: "";
第五步:耗时信息放到子线程。 如果详情里还要读 APK 文件大小、安装时间这类需要 IO 的字段,不要在点击回调里直接读。先弹一个「加载中」的对话框,再丢到线程池里查,查完用 runOnUiThread 回填:
java
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 下面还有一堆子进程:
java
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。每一步失败都给用户一句明确的提示,而不是静默返回:
java
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。捕获后给用户提示并打日志,比让应用直接崩溃友好得多。
第四步:加一层二次确认。 终止操作不可撤销,最好在真正执行前再弹一次确认框,把应用名和包名都显示出来,避免用户手滑点错:
java
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 只是「建议」,可以延迟一段时间后重新拉一次进程列表,看目标包名是否还在。如果还在,就提示用户「系统已拒绝或进程被重新拉起」,让用户知道真实结果:
java
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 安装)场景下更可靠,两个都取一下,优先用后者:
java
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() 取时间戳。这两个方法本身很快,但为了统一,还是放在后台线程里调用:
java
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)),老写法虽然还能用但已标记废弃。为了兼容,我们做一层判断:
java
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,读不到的字段统一显示「未知」:
java
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 开销,点击回调里不要直接调,套一层「加载中」对话框:
java
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()。先把它收敛成一个方法,所有刷新都走这里,后面加定时、加暂停都只改一处:
java
private void refresh() {
if (isRefreshing) {
return;
}
isRefreshing = true;
repository.loadProcesses(list -> runOnUiThread(() -> {
isRefreshing = false;
adapter.submit(list);
}));
}
isRefreshing 这个标志位在第 ③ 节就定义过,这里正好派上用场:定时器每 2 秒触发一次,如果上一次还没回来,直接跳过,避免请求堆积。
第二步:加一个定时刷新。 用 Handler.postDelayed 做循环最简单,不用引入额外依赖。定义一个间隔常量和一个 Runnable,在 onCreate 里启动,在 onDestroy 里移除:
java
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 秒全表重绘一次,滑动时会明显闪烁,用户正在看的详情也会被打断。改成按包名做差集,只通知变化的项:
java
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 秒跳一下会非常烦。在顶部加一个开关,暂停时只停定时任务,手动刷新按钮仍然可用:
java
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 里恢复,是最省事的做法:
java
@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 过滤,常见的有这几种:
text
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> 之前:
xml
<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,系统会直接忽略,别以为是申请失败了。判断一个权限要不要动态申请,可以这样写:
java
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:
xml
<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 补一部分数据:
java
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,还需要引导用户去「设置 → 特殊应用权限 → 使用情况访问」手动授权,这个权限没有弹框,只能跳转:
java
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,捕获后给用户一句人话提示,同时打日志方便定位:
java
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 也改成差集更新,只在结构真的变化时才退回全量:
java
public void submit(List<AppProcessInfo> list) {
if (data.isEmpty()) {
data.addAll(list);
notifyItemRangeInserted(0, list.size());
return;
}
submitIncremental(list);
}
另外给 RecyclerView 打开固定尺寸,省掉每次布局的测量开销:
java
recyclerView.setHasFixedSize(true);
recyclerView.setItemViewCacheSize(20);
setHasFixedSize(true) 告诉 RecyclerView「列表尺寸不受内容影响」,setItemViewCacheSize 把离屏缓存从默认 2 提到 20,滑动时能少做几次 inflate。
第二步:图标加载别在主线程解码。 现在 onBindViewHolder 里直接 setImageDrawable(item.icon),图标是 PackageManager 返回的 Drawable,第一次绘制时才真正解码 Bitmap,列表快速滑动时就会在主线程上做解码,掉帧就是这么来的。最省事的做法是引入 Glide,让它接管缓存与异步解码:
groovy
implementation 'com.github.bumptech.glide:glide:4.16.0'
java
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、内存这些易变字段做刷新:
java
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 秒定时刷新适合「用户正在看列表」的场景,但应用切到后台、或者用户正在看详情弹窗时,完全没必要刷。可以按场景分三档:
java
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 秒一刷、暂停时只留手动按钮,功耗和体验都能兼顾。
第五步:功能扩展------排序与搜索。 进程一多,用户最想要的就是「按内存排序」和「搜应用名」。排序在数据层做,搜索在适配器里做过滤,两者都不需要改布局:
java
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 个」的结果反馈:
java
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 可能识别不到:
text
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 类会找不到:
groovy
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」:
xml
<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,执行:
bash
./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
<?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
<?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 即可,逻辑不变:
java
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()------工具类只提供静态方法,不需要实例:
java
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 造成内存泄漏:
java
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),这样仓库和界面彻底解耦:
java
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 缓存,避免同一个包被反复查询:
java
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,实现起来比较绕,这里先用系统可用内存作为参考值,同时用版本判断兜住低版本系统:
java
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 销毁后线程还在后台跑:
java
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 方法,避免外部直接改内部集合导致下标错乱:
java
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 会直接空指针:
java
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 会被复用,写在创建阶段会把回调绑到错误的数据上:
java
@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:
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 都建一份:
java
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(),末尾再主动刷一次,这样一进界面就有数据,不用等用户点按钮:
java
@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 告诉用户加载了多少条,比列表默默变化更有反馈感:
java
private void refresh() {
repository.loadProcesses(list -> runOnUiThread(() -> {
adapter.submit(list);
Toast.makeText(this, "已加载 " + list.size() + " 个进程",
Toast.LENGTH_SHORT).show();
}));
}
第四步:写详情弹窗。 字段之间用 \n 换行,内存用 Formatters.formatBytes 转成可读单位。注意文案写的是「内存参考」而不是「内存占用」,因为仓库里返回的其实是系统可用内存,写「占用」会误导用户:
java
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) 这一层判断不能省:
java
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,宁可多拦一次,也不要因为包名没查到就放行:
java
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 销毁后线程还在后台跑:
java
@Override
protected void onDestroy() {
super.onDestroy();
repository.shutdown();
}
完整代码。 把上面几段拼起来,就是可以直接放进项目的 MainActivity.java:
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 即可,逻辑完全不变。
java
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。
java
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(),快速滑动时下标可能已经过期。
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;
/**
* 进程列表适配器。
* 只负责渲染与事件抛出,具体弹窗、终止逻辑由 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 里要关掉仓库的线程池。
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",
"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()。

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 就能看到一个能刷新、能看详情、能终止进程的完整工具。后续想加排序、搜索、定时刷新,都可以在这套结构上小步迭代,不用动整体骨架。