做 Android 一体机、广告机、门禁面板这类“桌面应用”的兄弟应该都有同感:设备一通电,系统启动完成,应用就得自己出现在桌面上,这是硬需求。用户不管你是不是 UniApp 写的,也不会体贴你“要不要先点一下图标”。一旦落到 UniApp 技术栈,很多人第一反应是去插件市场找个“开机自启动”的原生插件,但引入一个插件的成本和风险往往让人犹豫。其实这个需求可以走“无原生插件版”——不开发 uni-app 插件,直接改离线打包的宿主工程,用几十行原生代码就能让 UniApp 应用在 Android 开机后自动拉起。下面把整套思路、代码和踩坑点一次说清。
1. 自启动绕不开原生层,但真的不等于要写原生插件
先说结论:Android 的开机自启动,本质上是系统在开机完成后发出一条BOOT_COMPLETED广播,只有注册了对应BroadcastReceiver的应用能收到这条广播并做出响应。广播接收器是 Android 原生组件,UniApp 的 JS 层和 Vue 层是接触不到它的,所以这层原生代码无论如何都绕不开。
绕不开原生层,不代表一定要在 uni-app 体系里开发一个“原生插件”。这里要分清楚两件事:
- 原生插件:通常指打包成独立 AAR/SDK、通过 uni-app 插件市场发布、在
manifest.json里声明引用的那种模块,开发流程长,还要跟 DCloud 的插件规范对齐。 - 宿主工程直接改:UniApp 的离线打包,会给你一个完整的 Android Studio 工程,UniApp 的资源只是这个工程里的一部分。你在这个 Android 工程的
AndroidManifest.xml里加权限、加 Receiver,在java目录写一个自启动类,本质上是“应用外壳”的开发,不属于 uni-app 插件。
对很多做行业应用的团队来说,第二种方式更可控。不需要去适配插件市场那套生命周期,也不需要担心第三方插件和未来 SDK 版本冲突,代码量很小,出了毛病也容易排查。
判断一个项目能不能用这个方案,主要看三点:
- 你是否愿意走离线打包流程。云打包方式下,HBuilderX 打的 APK 是官方封装好的,你塞不进自定义 Receiver,只能依赖插件。想要自由改壳,就必须用离线 SDK。
- 你是否能接受
Android Studio相关的工具链。哪怕只是改一个配置、写一个类,也得会用 Android Studio 打开工程、编译出 APK。 - 你的应用是不是长期维护的产品。如果是一次性交付的项目,你也希望它稳定跑在设备上,离线打包后所有原生能力都在自己手里,后续加蓝牙、串口、NFC 都不受插件市场限制。
这套“无原生插件”的做法,本质上就是教会你打开 UniApp 的 Android 原生壳,在壳上做应用级定制。下面从工程准备开始,一步步来。
2. 动手前,先把离线打包工程搭对
2.1 拿到离线 Android SDK 并申请 AppKey
UniApp 离线打包必须要从 DCloud 官网下载对应版本的 Android 离线 SDK 包。这个 SDK 通常是 ZIP 压缩包,解压出来就是一个 Android Studio 工程,里面预置了lib.5plus.base-release.aar、uniapp-v8-release.aar这些核心库。
注意,每个 HBuilderX 版本对应的离线 SDK 版本不完全一样,一定要用跟你本机 HBuilderX 版本匹配的 SDK。版本差了,轻则控制台刷警告,重则编译时找不到某个类或方法,非常难受。
还需要一个 AppKey。在 HBuilderX 里打开你的 uni-app 项目,“发行 → 原生 App-离线打包 → 申请 AppKey”,填上包名和签名信息,会生成一个离线打包 Key。这个 Key 要写进离线工程里的assets/data/dcloud_control.xml,否则 App 一启动就提示“AppKey 校验失败”,直接白屏。
2.2 搞清楚离线工程的资源结构
拿到 SDK 后,把 uni-app 项目用 HBuilderX 发行,选择“原生 App 离线打包”,会生成一份 uni-app 资源包(在unpackage/resources/__UNI__xxxxx目录下)。这份资源要整个拷贝到离线工程:
assets/ apps/ __UNI__xxxxx/ www/ app-service.js ... data/ dcloud_control.xmldcloud_control.xml里面至少要有:
<?xml version="1.0" encoding="utf-8"?> <hbuilder> <apps> <app appid="__UNI__xxxxx" appver="1.0.0"> <appkey>你的离线AppKey</appkey> </app> </apps> </hbuilder>很多第一次做离线打包的同学,卡在“应用启动后一直白屏”这个坎上,十有八九是appid对不上,或者资源没放到正确路径。打包结束后用adb shell看一下assets目录,确认www下的入口文件真的在,是最靠谱的检查方式。
2.3 确认主 Activity 的继承关系
离线 SDK 模板里通常会有一个主入口 Activity,标准的写法是继承 DCloud 的PandoraEntry:
package com.example.desktop; import io.dcloud.PandoraEntry; public class MainActivity extends PandoraEntry { }这就是 UniApp 应用在 Android 侧的宿主窗口,启动它 = 启动 uni-app 页面,JS 层的onLaunch、onShow都会正常触发。后面自启动的接收器,目标也是把它拉起来。
这一步做扎实了,你的 UniApp 就能以“原生壳 + uni-app 资源”的方式跑起来。在这个壳上做开机自启动,才是真正的“无原生插件版”。
3. 核心改动:在 Android 宿主壳里塞一个开机接收器
3.1 注册权限和 Receiver
在离线工程的AndroidManifest.xml里做两件事。第一,加权限:
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />RECEIVE_BOOT_COMPLETED是收听开机广播的必备权限。后两个是为了兜底方案准备的,后面细说。
第二,在<application>节点下注册 Receiver。这里有个容易踩的坑:Android 12 开始,targetSdkVersion31 以上的应用,四大组件凡是带有intent-filter的,都必须显式标注android:exported。否则安装到 Android 12 以上设备会直接崩溃,报SecurityException。
<receiver android:name=".BootReceiver" android:exported="true"> <intent-filter android:priority="999"> <action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" /> <action android:name="android.intent.action.QUICKBOOT_POWERON" /> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" /> </intent-filter> </receiver>为什么要把各种 Action 都写上?不同厂商对开机广播的处理并不统一。QUICKBOOT_POWERON主要是小米等品牌在“快速启动”模式下会发的,如果漏了,用户开着快速启动关机再开机,自启动可能就失灵了。LOCKED_BOOT_COMPLETED则是 Android 7.0 以后引入的概念,设备加密锁屏未解锁前,系统只会发这条,不会发BOOT_COMPLETED。对于有锁屏密码的一体机,这条很重要。
3.2 写一个最直接的 BootReceiver
接收器的职责很简单:收到开机广播后,拉起MainActivity。
package com.example.desktop; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; public class BootReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (Intent.ACTION_BOOT_COMPLETED.equals(action) || "android.intent.action.QUICKBOOT_POWERON".equals(action) || "android.intent.action.LOCKED_BOOT_COMPLETED".equals(action)) { Intent mainIntent = new Intent(context, MainActivity.class); mainIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { context.startActivity(mainIntent); } catch (Exception e) { // 某些厂商 ROM 限制了后台启动 Activity,走兜底服务 Intent serviceIntent = new Intent(context, BootGuardService.class); context.startForegroundService(serviceIntent); } } } }这里要注意FLAG_ACTIVITY_NEW_TASK。因为广播接收器所在的上下文不是 Activity,而是应用上下文,没有这个 flag 的话,启动 Activity 会直接报Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag。
很多简单教程到这里就结束了,但真实设备上问题没那么简单。国产 ROM 普遍存在“后台弹界面限制”,尤其是华为、荣耀这些牌子,即便你收到了开机广播,直接startActivity也可能被系统拦截,页面起不来。所以我在代码里加了try-catch,一旦启动 Activity 失败,就转去启动一个前台服务做兜底。
3.3 用前台服务做兜底启动
开机广播拉起 Activity 被限制时,最稳的办法是:先启动一个前台服务(Foreground Service),再由服务去拉起 Activity,或者等服务内确认系统环境就绪后再拉。
package com.example.desktop; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.Service; import android.content.Intent; import android.os.Handler; import android.os.IBinder; public class BootGuardService extends Service { private static final String CHANNEL_ID = "boot_guard"; private final Handler handler = new Handler(); @Override public void onCreate() { super.onCreate(); NotificationChannel channel = new NotificationChannel( CHANNEL_ID, "开机引导", NotificationManager.IMPORTANCE_LOW ); getSystemService(NotificationManager.class).createNotificationChannel(channel); startForeground(1, new Notification.Builder(this, CHANNEL_ID) .setContentTitle("正在启动桌面应用") .setContentText("系统准备中") .setSmallIcon(android.R.drawable.ic_menu_compass) .build()); } @Override public int onStartCommand(Intent intent, int flags, int startId) { handler.postDelayed(() -> { Intent mainIntent = new Intent(this, MainActivity.class); mainIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(mainIntent); stopSelf(); }, 3000); return START_STICKY; } @Override public void onDestroy() { handler.removeCallbacksAndMessages(null); super.onDestroy(); } @Override public IBinder onBind(Intent intent) { return null; } }这个服务里的延迟 3 秒不是随意的。开机广播发出的时机是“系统启动完成后”,但部分 Android 设备在开机广播发出后的 1 到 2 秒内,系统窗口、SystemUI 还没完全就绪,立刻启动应用容易出现“启动一半被系统回收”的情况。延迟 3 秒,既不会让用户等太久,又能把早期不稳定的窗口期错过去。
Android 13/14 上,targetSdkVersion34 的应用启动前台服务,类型必须明确声明。所以在AndroidManifest.xml里给 Service 加上:
<service android:name=".BootGuardService" android:exported="false" android:foregroundServiceType="specialUse"> <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="boot_screen" /> </service>specialUse是 Android 14 提供的通用前台服务类型,适合“开机引导”这类不好归类的用途。如果不用这个类型,应用在 Android 14 设备上启动服务时会直接抛ForegroundServiceTypeNotAllowedException。
3.4 一条保险杠:开机后加一个定时闹钟
有些改版得很厉害的 ROM,可能在某种异常重启场景下把BOOT_COMPLETED广播吞了。如果设备重启后你始终没看到应用起来,可以再加一层保险——用AlarmManager注册一个开机延迟提醒。
思路是在 App 正常启动时,用AlarmManager.setExactAndAllowWhileIdle()设定一个 10 秒后触发的闹钟,PendingIntent 指向一个自启动 Receiver。这样即使BOOT_COMPLETED被某些诡异场景漏掉,应用也还有一次被闹钟拉起来的机会。
不过经验上,这一层只适合特定设备,不是通用方案。正常的BOOT_COMPLETED已经覆盖绝大多数场景,加了闹钟反而要处理 Android 12 的SCHEDULE_EXACT_ALARM权限,多一事不如少一事。你先跑通 Receiver 方案,遇到具体设备再按需加。
4. 桌面应用要的不只是自启:全屏、常亮、默认桌面
4.1 在 UniApp 层把全屏和常亮配好
自启动只是第一步,桌面应用通常是“全屏、横屏、永不锁屏”的状态。这些能力可以留在 uni-app 的 JS 层解决,不用在原生壳里写太多。
在App.vue的onLaunch里加一段条件编译:
onLaunch: function() { // #ifdef APP-PLUS // 全屏 plus.navigator.setFullscreen(true); // 锁定横屏 plus.screen.lockOrientation('landscape-primary'); // 保持屏幕常亮 plus.device.setWakelock(true); // #endif }plus.navigator.setFullscreen(true)是进入沉浸式全屏,状态栏会被隐藏。plus.screen.lockOrientation('landscape-primary')锁横屏,适配广告机、门禁面板这类设备很常见。plus.device.setWakelock(true)是让屏幕不自动熄灭。
如果你装了比较新的 HBuilderX 版本,有些 API 可能标记为废弃,但setFullscreen和lockOrientation目前还是可用的。如果项目内同时用了原生壳的FLAG_KEEP_SCREEN_ON,又用了plus.device.setWakelock,有可能出现“息屏策略冲突”的问题,二选一即可,我一般优先用 JS 层方案,因为改动不用动原生,方便后续在同一个 uni-app 代码里兼容 iOS。
4.2 把自己变成默认桌面(Launcher)
桌面应用还有个常见形态:设备开机后不显示系统桌面,直接进入你的应用,用户退都退不回去。这就是“把自己变成桌面”。
做法是在AndroidManifest.xml的主 Activity 里加一段 HOME 意图:
<activity android:name=".MainActivity" android:launchMode="singleTask" android:configChanges="orientation|screenSize|keyboardHidden" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.HOME" /> <category android:name="android.intent.category.DEFAULT" /> </intent-filter> </activity>加上之后,重启设备系统会让你选“默认桌面应用”,选中你的 App,它就是系统桌面了。这个做法对信息亭、导览机、自助终端非常实用。但要注意,此时你的应用也变成了“桌面”,如果设备上需要偶尔打开别的应用,得在应用里做一个“退出到系统桌面”的按钮,调用startActivity跳回系统默认桌面,否则用户会被锁死在你的应用里。
HOME 桌面模式的坑是:部分厂商对桌面类应用有额外的自启动限制,甚至要求你把应用预装到系统分区才能获得稳定的自启动权限。所以“开机自启动”和“桌面替代”这两件事分开配置,先保证自启动,再决定要不要开 HOME 模式。
4.3 锁任务模式,防止误退
对于公用终端,即使不是 HOME 模式,也不希望用户按返回键或主页键退出应用。Android 提供了“锁任务模式”(Lock Task Mode)。
如果你的设备有设备管理器权限,可以通过DevicePolicyManager调用setLockTaskPackages()。没有设备管理器权限时,简单一点的做法是在MainActivity里重写事件:
@Override public void onBackPressed() { // 桌面应用不响应返回键 return; }但主页键和最近任务键,普通应用是拦截不了的。行业项目普遍的做法是:有条件就做设备 Owner 授权,把应用设成锁任务模式;没条件就靠 HOME 模式 + OEM 的白名单。
从 UniApp 工程边界来看,这一块属于“按需开启”的高级功能,不是所有桌面项目都需要。如果只做开机自启和全屏,看到 4.1 就够了。
5. 真机验证自启动:看似简单,坑全在细节里
5.1 首次手动打开一次是硬条件
Android 从 3.1 开始引入“停止状态”概念:应用安装后,在用户主动启动它之前,系统不会把任何广播(包括开机广播)发给它。只有用户手动点过一次应用图标,它才脱离停止状态,以后才能收到BOOT_COMPLETED。
这对开发者意味着:在测试机上验证自启动前,必须先打开一次应用,然后“彻底关机再开机”,而不是热重启。很多人在 Android Studio 里点 Run 安装完应用,退到后台,然后adb reboot,结果没起来,就以为代码错了。其实纯粹是因为没有手动启动这一下。
如果需要通过命令模拟“用户打开”这个动作:
adb shell am start -a android.intent.action.MAIN -c android.intent.category.LAUNCHER -n com.example.desktop/.MainActivity这样执行一次后,应用才脱离停止状态。
5.2 重启测试时怎么观察日志
真机重启后,打开终端,先抓日志:
adb logcat -v time | grep -E "BootReceiver|AndroidRuntime|PandoraEntry|ActivityManager"正常流程下,你会看到 Receiver 的打印,或者至少看到ActivityManager输出类似START u0 {cmp=com.example.desktop/.MainActivity}的信息。然后应用进程被创建,PandoraEntry加载 WebView,uni-app 页面渲染。
如果啥都没有,先确认应用确实在设备上且没被停用:
adb shell pm list packages | grep com.example.desktop adb shell dumpsys package com.example.desktop | grep -i bootdumpsys package里能看到android.permission.RECEIVE_BOOT_COMPLETED: granted=true这样的授权信息。如果权限没授权,多半是AndroidManifest.xml没改对,或者 APK 没打进去,重新看一遍编译输出。
5.3 收到广播却起不来,多半是厂商限制
真实项目中,更常见的情况是:adb logcat里能看到BootReceiver被调用了,日志里也有应用进程的启动痕迹,但屏幕上就是没有界面。这就是前面说的“后台弹界面限制”。
Android 原生的后台启动限制有明确豁免:BOOT_COMPLETED后,应用从后台启动 Activity 是允许的,但国产厂商 ROM 不按原生长逻辑来。华为、荣耀部分机型上,哪怕有开机广播豁免,系统还是会拦截 Activity 启动,这时候BootGuardService的兜底就有用了——前台服务启动 Activity 的优先级比广播接收器高不少。
如果兜底服务还是被拦,那就得让用户到系统设置里打开“允许后台弹界面”权限。这个权限藏在厂商自己的设置页,后面第 6 节详细说。
下面是一张排查决策表,照着走比瞎猜有效率得多:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 重启后日志无 Receiver 痕迹 | 应用处于停止状态 | 手动打开一次应用再重启 |
| Receiver 有日志,但 Activity 没起来 | 后台弹界面限制 | 加前台服务兜底,检查厂商权限 |
| Activity 起来了但白屏 | AppKey 不匹配或资源未放好 | 检查 dcloud_control.xml 和 assets 目录 |
| 首次正常,后续偶尔失效 | 厂商杀后台、自启动管理被关闭 | 引导用户设置自启动白名单 |
| 模拟器正常,真机不行 | 厂商广播 Action 差异 | 注册 QUICKBOOT_POWERON 等厂商广播 |
6. 国产 ROM 的“隐形开关”:代码写得再好,开关不开也白搭
6.1 各大厂商自启动设置入口
Android 原生的BOOT_COMPLETED机制是开放的,但国内主流厂商为了省电和省流量,都做了“自启动管理”,默认禁止应用开机自启。这些开关藏在系统设置里,用户不手动打开,你的应用接收器就算写得再标准,也收不到广播。
这份整理是按我对当前主流 ROM 的了解总结的,不同版本菜单名称可能略有变化,但路径基本稳定:
| 厂商 | 主要设置路径 | 关键开关 |
|---|---|---|
| 小米/MIUI | 安全中心 → 应用管理 → 权限 → 自启动 | 列表中找到应用并开启 |
| 小米/MIUI | 设置 → 应用设置 → 应用管理 → 对应应用 → 省电策略 | 无限制 |
| 华为/EMUI | 设置 → 应用和服务 → 应用启动管理 | 关闭自动管理,手动允许自启动、关联启动、后台活动 |
| 荣耀/MagicUI | 设置 → 应用和服务 → 应用启动管理 | 同上 |
| OPPO/ColorOS | 设置 → 电池 → 应用省电管理 → 对应应用 | 允许后台运行 |
| vivo/OriginOS | 设置 → 应用与权限 → 权限管理 → 自启动 | 开启对应应用 |
| vivo/OriginOS | 设置 → 电池 → 后台耗电管理 | 允许后台高耗电 |
注意,华为设备的“应用启动管理”默认是“自动管理”,你必须手动把开关拨到“手动管理”,自启动、关联启动、后台活动三个子开关才出现。
6.2 开发调试期的“应用自启动引导页”
既然厂商开关绕不开,行业项目里通行的做法是:应用第一次启动时,检测当前设备厂商,弹出一个引导页,用图文告诉使用者“请按以下步骤允许自启动”。
UniApp 里可以用uni.getSystemInfoSync()拿到brand字段,判断是华为、小米还是 OPPO,然后展示对应的图片路径和文字说明。图片直接放在static目录下,走条件编译,真机上展示的是高清图。
这套引导页的开发成本不高,但对交付很重要。你的客户去现场装一百台设备,不可能每台都找你远程指导。引导页写得明白,运维人员照着操作就能搞定 80% 的问题。
6.3 后台活动限制:比自启动更隐蔽
聊完厂商自启动,还有一层“后台弹界面”权限。就算广播收到了、代码里也调用了startActivity,在华为部分机型上,系统会提示“不允许后台弹出界面”,Activity 依旧不出现。
这类权限通常在:
- 华为/荣耀:设置 → 应用和服务 → 应用管理 → 对应应用 → 权限 → 设置内“后台弹出界面”
- 小米:设置 → 应用设置 → 应用管理 → 对应应用 → 其他权限 → “后台弹出界面”
要不要在引导页里一并引导,取决于你的部署场景。如果是纯广告机,自己人布的场,可以在出厂前由部署人员手动配好;如果产品要交付给不懂技术的 B 端客户,一定要把这一步写进开机引导流程。
7. 项目落地后的几个经验提醒
7.1 自启动之后,首要任务是“别做重活”
应用被开机广播拉起时,系统刚启动完成,CPU 和 I/O 还在紧张状态。如果你在onLaunch阶段就去读大批量本地缓存、同步云端大资源、初始化多套 SDK,常见的结果是:首页半天出不来,甚至因为启动太慢被系统判定无响应。
我的做法是:开机自启动只进入一个“极简首页”,前端先渲染一个纯静态的欢迎页或引导页,真正的业务逻辑延迟到页面onReady之后,用setTimeout错峰执行。UI 可以先展示,数据慢慢加载。
7.2 进程被杀后的保活问题,别指望一个广播解决
很多项目到了后期会问:自启动之后,应用在运行中被人为划掉,或者被系统回收了,怎么办?坦白说,BOOT_COMPLETED只保证“开机”这个场景,不保证“被杀后再拉起”。
要想桌面应用长期在线,至少还要同时做三件事:
- 前台服务加
START_STICKY,保证服务被系统回收后能重建; - 请求厂商白名单(省电策略无限制);
- 在应用中做“心跳检测”,发现页面长时间无响应后自动重建。
这些是另外一整个工程量,不属于“无原生插件自启动”的范畴,但既然你是做桌面应用,提前知道边界在哪里还是很有必要的。
7.3 离线 SDK 版本升级要重新回归
离线打包的最大忌讳是:HBuilderX 升级后没有同步升级离线 SDK,编译依然能过,但运行期可能遇到各种奇怪的问题,比如io.dcloud.PandoraEntry找不到、uniapp-v8-release.aar和新资源格式不兼容。
升级 HBuilderX 之后,做自启动回归测试的优先级要排在前面,因为接收器直接操作的是宿主 Activity。测试步骤就三步:装 APK → 手动开一次 → 关机重启。这个流程固化在项目的验收清单里,比什么都可靠。
7.4 关于“无原生插件”的边界,跟同事要说清楚
最后聊一个团队协作层面的问题。你走了离线打包路线,意味着整个 Android 工程已经引入原生构建体系,虽然你没做 uni-app 插件,但后续维护人必须得懂一点 Android。在技术选型评审时,“无原生插件”指的是“不引入第三方 uni-app 插件、不写原生模块去对接 uni-app 生命周期”,而不是“项目完全不需要原生开发能力”。把这个边界跟甲方、团队讲清楚,后面验收时才不会扯皮。
我在多个项目里用的都是这一套方案:一个BootReceiver、一个BootGuardService、几个权限声明,加上 UniApp 层十来行全屏常亮代码,就能把开机自启动这件事稳定跑起来。整个原生侧的改动量,加起来不超两百行,比接任何额外部件都干净。如果手头的 UniApp 桌面项目正好卡在“自启动”这个问题上,照着这个离线壳改法走一遍,会比满世界找插件省心很多。