Android 应用内 APK 安装全方案:从静默安装到普通安装的详解

Android 应用内 APK 安装全方案:从静默安装到普通安装的详解

> 场景:TV 盒子、教育平板、企业 MDM 设备,需要在应用内完成 APK 升级或第三方应用推送安装。

> 已在 Android 4.4 ~ 12 的真机及模拟器上验证,可直接用于生产环境。

一、先搞清楚:你的应用能走哪条路?

安装方式 所需权限 用户感知 适用场景
静默安装 INSTALL_PACKAGES(系统级) 无弹窗,后台完成 系统签名应用、厂商预装、Root 设备
普通安装 REQUEST_INSTALL_PACKAGES(动态申请) 系统弹窗,需手动确认 所有第三方应用

核心结论:

  • 如果你不是系统应用,直接看第三节"普通安装"即可,静默安装那部分了解就行。
  • Android 9.0+ 对 pm install 限制更严,非系统 UID 会直接拒绝。

二、权限声明(AndroidManifest.xml)

xml

<

<

三、核心工具类(完整生产代码)

ini 复制代码
public class ApkInstallUtil {

    private static final String TAG = "ApkInstallUtil";

    /**
     * 统一入口:优先静默安装,无权限则降级为普通安装
     */
    public static void installApp(Context context, String filePath) {
        if (context == null || TextUtils.isEmpty(filePath)) {
            AppUtil.toast("安装参数异常");
            return;
        }
        try {
            // 只有系统应用才能拿到 INSTALL_PACKAGES 权限
            if (ContextCompat.checkSelfPermission(context, Manifest.permission.INSTALL_PACKAGES) 
                    == PackageManager.PERMISSION_GRANTED) {
                silentInstall(context, filePath);
            } else {
                normalInstall(context, filePath);
            }
        } catch (Exception e) {
            normalInstall(context, filePath);
        }
    }

    /*================== 静默安装区域(系统应用专用) ==================*/

    private static void silentInstall(Context context, String filePath) throws Exception {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            installApkByPm(filePath);
        } else {
            installApkBySession(context, filePath);
        }
    }

    /**
     * Android 5.0 之前:通过 pm install 命令安装
     */
    private static boolean installApkByPm(String apkPath) {
        String[] args = {"pm", "install", "-r", apkPath};
        ProcessBuilder processBuilder = new ProcessBuilder(args);
        Process process = null;
        BufferedReader successResult = null;
        BufferedReader errorResult = null;
        StringBuilder successMsg = new StringBuilder();
        StringBuilder errorMsg = new StringBuilder();
        try {
            process = processBuilder.start();
            successResult = new BufferedReader(new InputStreamReader(process.getInputStream()));
            errorResult = new BufferedReader(new InputStreamReader(process.getErrorStream()));
            String s;
            while ((s = successResult.readLine()) != null) {
                successMsg.append(s);
            }
            while ((s = errorResult.readLine()) != null) {
                errorMsg.append(s);
            }
            return process.waitFor() == 0 || successMsg.toString().contains("Success");
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        } finally {
            closeQuietly(successResult);
            closeQuietly(errorResult);
            if (process != null) {
                process.destroy();
            }
        }
        return false;
    }

    /**
     * Android 5.0+:通过 PackageInstaller API 安装
     */
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private static void installApkBySession(Context context, String apkFilePath) throws Exception {
        PackageManager packageManager = context.getPackageManager();
        File apkFile = new File(apkFilePath);
        PackageInstaller packageInstaller = packageManager.getPackageInstaller();
        if (packageInstaller == null) return;

        PackageInstaller.SessionParams sessionParams = 
                new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
        sessionParams.setSize(apkFile.length());
        int sessionId = createSession(packageInstaller, sessionParams);

        new Thread(() -> {
            Looper.prepare();
            packageInstaller.registerSessionCallback(new InstallSessionCallback(sessionId));
            if (sessionId != -1) {
                boolean copySuccess = copyInstallFile(packageInstaller, sessionId, apkFilePath);
                if (copySuccess) {
                    execInstallCommand(context, packageInstaller, sessionId);
                }
            }
            Looper.loop(); // 必须 loop,否则 SessionCallback 无法回调
        }).start();
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private static int createSession(PackageInstaller packageInstaller, 
                                     PackageInstaller.SessionParams sessionParams) {
        try {
            return packageInstaller.createSession(sessionParams);
        } catch (IOException e) {
            e.printStackTrace();
            return -1;
        }
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private static boolean copyInstallFile(PackageInstaller packageInstaller, 
                                           int sessionId, String apkFilePath) {
        InputStream in = null;
        OutputStream out = null;
        PackageInstaller.Session session = null;
        try {
            File apkFile = new File(apkFilePath);
            session = packageInstaller.openSession(sessionId);
            out = session.openWrite("base.apk", 0, apkFile.length());
            in = new FileInputStream(apkFile);
            int c;
            byte[] buffer = new byte[65536];
            while ((c = in.read(buffer)) != -1) {
                out.write(buffer, 0, c);
            }
            session.fsync(out);
            return true;
        } catch (IOException e) {
            e.printStackTrace();
            return false;
        } finally {
            closeQuietly(out);
            closeQuietly(in);
            closeQuietly(session);
        }
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private static void execInstallCommand(Context context, 
                                           PackageInstaller packageInstaller, int sessionId) {
        PackageInstaller.Session session = null;
        try {
            session = packageInstaller.openSession(sessionId);
            Intent intent = new Intent(context, InstallReceiver.class);
            
            // Android 12+ 必须加 FLAG_IMMUTABLE,否则抛 IllegalArgumentException
            int flags = PendingIntent.FLAG_UPDATE_CURRENT;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                flags |= PendingIntent.FLAG_IMMUTABLE;
            }
            PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 1, intent, flags);
            session.commit(pendingIntent.getIntentSender());
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            closeQuietly(session);
        }
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public static class InstallSessionCallback extends PackageInstaller.SessionCallback {
        private final int mSessionId;
        public InstallSessionCallback(int sessionId) {
            this.mSessionId = sessionId;
        }
        @Override
        public void onCreated(int sessionId) {}
        @Override
        public void onBadgingChanged(int sessionId) {}
        @Override
        public void onActiveChanged(int sessionId, boolean active) {}
        @Override
        public void onProgressChanged(int sessionId, float progress) {
            if (sessionId == mSessionId) {
                LogUtil.e(TAG, "安装进度: " + progress);
            }
        }
        @Override
        public void onFinished(int sessionId, boolean success) {
            if (mSessionId == sessionId) {
                LogUtil.e(TAG, success ? "静默安装成功" : "静默安装失败");
            }
        }
    }

    /*================== 普通安装区域(所有应用可用) ==================*/

    /**
     * 普通安装:系统弹窗,用户手动确认
     */
    public static void normalInstall(Context context, String filePath) {
        File file = new File(filePath);
        if (!file.exists()) {
            AppUtil.toast("安装包不存在");
            return;
        }

        // Android 8.0+ 必须先检查是否有"安装未知应用"权限
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            boolean canInstall = context.getPackageManager().canRequestPackageInstalls();
            if (!canInstall) {
                // 引导用户去设置页开启权限
                Intent settingIntent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
                        .setData(Uri.parse("package:" + context.getPackageName()));
                context.startActivity(settingIntent);
                return;
            }
        }

        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            // 7.0+ 必须使用 FileProvider,直接传 File URI 会抛 FileUriExposedException
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
            Uri contentUri = FileProvider.getUriForFile(context, 
                    BuildConfig.APPLICATION_ID + ".fileProvider", file);
            intent.setDataAndType(contentUri, "application/vnd.android.package-archive");
        } else {
            intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive");
        }

        // 注册安装完成监听,用于安装成功后删除安装包
        InstallCompleteReceiver receiver = new InstallCompleteReceiver(file);
        IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED);
        filter.addDataScheme("package");
        context.registerReceiver(receiver, filter);

        try {
            context.startActivity(intent);
        } catch (Throwable e) {
            AppUtil.toast("安装出错或无权限");
            LogUtil.e(TAG, "normalInstall error: " + e);
            // 启动失败时立即注销广播,防止内存泄漏
            try {
                context.unregisterReceiver(receiver);
            } catch (Exception ignored) {}
        }
    }

    /**
     * 安装完成广播:匹配包名后删除安装包,并注销自身
     */
    public static class InstallCompleteReceiver extends BroadcastReceiver {
        private final File apkFile;
        public InstallCompleteReceiver(File apkFile) {
            this.apkFile = apkFile;
        }
        @Override
        public void onReceive(Context context, Intent intent) {
            String packageName = intent.getData().getSchemeSpecificPart();
            String installingPkg = getPackageName(context, apkFile.getAbsolutePath());
            if (packageName != null && packageName.equals(installingPkg)) {
                try {
                    if (apkFile.exists()) apkFile.delete();
                } catch (Exception e) {
                    LogUtil.e(TAG, "删除安装包失败: " + e.getMessage());
                } finally {
                    try {
                        context.unregisterReceiver(this);
                    } catch (Exception ignored) {}
                }
            }
        }
    }

    /*================== 公共工具方法 ==================*/

    /**
     * 通过 APK 文件路径解析包名
     */
    public static String getPackageName(Context context, String filePath) {
        PackageManager pm = context.getPackageManager();
        PackageInfo info = pm.getPackageArchiveInfo(filePath, PackageManager.GET_ACTIVITIES);
        return info != null ? info.applicationInfo.packageName : null;
    }

    private static void closeQuietly(Closeable c) {
        if (c != null) {
            try {
                c.close();
            } catch (IOException ignored) {}
        }
    }
}

四、FileProvider 配置(普通安装必备,Android 7.0+)

很多开发者在这里踩坑,直接传 Uri.fromFile() 在 7.0+ 会直接崩溃。

AndroidManifest.xml:

ini 复制代码
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileProvider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

res/xml/file_paths.xml:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path name="external_files" path="." />
    <cache-path name="cache_files" path="." />
    <!-- 根据你实际存放 APK 的路径补充,例如: -->
    <files-path name="internal_files" path="." />
</paths>

五、兼容性验证记录

系统版本 测试环境 静默安装 普通安装 备注
Android 4.4 TV 盒子真机 pm install 需 Root 或系统签名
Android 7.1 TV 盒子真机 ✅ PackageInstaller
Android 10 教育平板真机 ❌ 非系统 UID 拒绝
Android 12 模拟器 PendingIntent 必须加 FLAG_IMMUTABLE

六、总结

  1. 先判断身份:不是系统应用就不要折腾静默安装,直接走普通安装。
  2. Android 7.0+ 必须用 FileProvider ,否则 FileUriExposedException 教你做人。
  3. Android 8.0+ 普通安装前必须检查 canRequestPackageInstalls() ,否则 startActivity 没反应。
  4. Android 12+ 的 PendingIntent 必须加 FLAG_IMMUTABLE,否则直接崩溃。
  5. PackageInstaller 的 SessionCallback 必须跑在带 Looper 的线程里,否则安装完成了也收不到回调。

okk 第一篇博客完成 撒花结束

相关推荐
掉鱼的猫1 小时前
Spring AI 2.0 GA 倒计时:先别急,来看看 Java AI 框架的另一条路
java·openai·agent
正儿八经的少年1 小时前
Spring Boot 两种激活配置方式的作用与区别
java·spring boot·后端
云烟成雨TD2 小时前
Spring AI Alibaba 1.x 系列【52】Interrupts 中断机制:节点执行前后静态中断
java·人工智能·spring
疯狂成瘾者2 小时前
Spring Boot 项目中的 SMTP 邮件验证码服务技术解析
java·spring boot·后端
y = xⁿ2 小时前
Java并发八股学习日记
java·开发语言·学习
xifangge20252 小时前
【深度排障】从 OS 底层寻址剖析 javac 不是内部或外部命令 核心报错:变量空间隔离与自动化部署终极范式
java·开发语言·jdk·自动化
肖恩想要年薪百万2 小时前
JSP中常用JSTL标签
java·开发语言·状态模式
程序员清风2 小时前
AI开发岗该如何准备面试?
java·后端·面试
笨拙的老猴子2 小时前
Spring AI 实战教程(七):Agent 智能体 —— 用电商购物助手学透自主规划与工具执行
java·人工智能·spring