手里只有一块开发板、一台装了 x86 模拟器的机器,或者干脆就是一台连着 USB 的样机时,调试 OpenHarmony 应用最省事的办法从来不是点界面,而是敲命令。Openharmony hdc 启动应用、关闭应用这件事,说白了就是用 hdc 这根"数据线",把aa start和aa force-stop两个动作精确地打到设备上。它看起来只是两行命令,但真到项目里用起来,你会发现坑全在细节里:多设备时命令打错了板子、aa start返回成功界面却没动、force-stop 之后应用三四秒又自己爬起来、设备冷重启后应用死活不自启、渲染花屏时不知道该从应用层查还是从系统层查。这些问题靠 DevEco 点按钮是查不出来的,只有命令行能给你确定的答案。这篇内容我按"环境准备—启动—关闭—脚本化—排错—经验"的顺序写,适合刚接触 OpenHarmony 的应用开发者、做系统集成的工程师,以及需要把启停动作塞进自动化测试流水线的人。整个过程不需要你有多深的系统底层功底,能看懂命令行输出就够了。
1. 从零把 hdc 这根线接上
1.1 先搞清楚 hdc 在整套调试链路里的位置
hdc 全称 HarmonyOS Device Connector,是 OpenHarmony 体系里的设备连接器,本质是一个客户端加一个服务端的组合:你在终端敲的hdc xxx是客户端,后台跑着的是 hdc server,真正跟设备通信的是 server 和设备上的 hdcd 守护进程。理解这一点很重要,因为后面绝大多数"命令没反应""设备列表是空的"这类问题,都出在 server 和设备之间的这一段,而不是你的命令写错了。它跟安卓体系里的 adb 定位几乎一样,hdc list targets对应adb devices,hdc shell对应adb shell,hdc install对应adb install。如果你以前写过 adb 脚本,迁移成本主要在于两条命令的名字不一样:启动应用不再用am start,而是aa start;关闭应用也不是am force-stop,而是aa force-stop。
日常使用中,hdc 承担三件事:一是"看见设备",二是"往设备里塞东西或者从设备里掏东西"(安装 hap、拉日志、拉截图),三是"在设备上执行一条命令"(启停应用、清数据、改系统参数)。启停应用属于第三类,走的是hdc shell通道,最终落到设备上的 Ability Manager 服务去执行。清楚这个链路之后,遇到问题就能分层判断:设备都没连上,谈启停毫无意义;shell 能进但 aa 命令报错,那就是应用配置或 Ability 名称的问题;aa 命令成功但界面没变化,那就是应用自己的启动模式或生命周期逻辑在作怪。
1.2 Linux 下把 hdc 拿下来并配好
Linux 版 hdc 不需要单独去找安装包,它就在你下载的 SDK 里,路径通常是toolchains/hdc(有的版本是toolchains/linux/hdc)。把它拷到一个你习惯的目录,加执行权限,再挂进 PATH 就完事了。这里有个很常见的低级问题:从压缩包里解出来的二进制默认没有 x 权限,直接敲hdc list targets会提示没有权限或者 command not found,很多人第一反应是去重装 SDK,其实是白折腾。执行chmod +x hdc,然后./hdc -h能打出帮助信息,就说明二进制本身是好的。
第二类问题是 USB 访问权限。Linux 下非 root 用户默认没有直接访问 USB 设备的权限,表现是hdc list targets输出[Empty],但设备明明插着。解决办法是写一条 udev 规则,把设备节点的属主或权限放开,然后重新加载规则、重新插拔一次设备。这一步做完,设备列表立刻就出来了。这里要注意,udev 规则里的 vendor id 和 product id 要从lsusb的实际输出里抄,别照着别人的博客抄,不同板子的 id 经常不一样,抄错了规则写了也白写。
第三件事是环境变量。HDC_SERVER_PORT值得单独说一句,因为当你要同时接多台设备、或者 CI 上要并发跑多个任务的,默认端口只有一个 server,多个进程会互相抢。给每个任务分配一个独立端口,就能互不干扰:启动前 export 一下端口号,再hdc start,两台设备两条流水线各跑各的,实测下来很稳。至于 server 的启停,hdc kill是关掉 server,hdc start是拉起来,hdc start -r是重启。遇到设备状态显示异常、明明连着却报 offline 的时候,先hdc kill再hdc start,比在那反复插拔线快得多。
1.3 一个容易忽略的前提:应用得先装进去
启动应用之前,应用得先在设备上。命令行安装的写法是先把 hap 推到设备上,再用设备端的包管理命令安装,而不是像 adb 那样一步到位。常见流程是hdc file send xxx.hap /data/local/tmp/,然后hdc shell bm install -p /data/local/tmp/xxx.hap,覆盖安装加-r。装完之后用hdc shell bm dump -a能看到已安装的所有包名,用hdc shell bm dump -n 你的包名能看到这个包的详细信息,包括模块名和 Ability 名——这两个名字正是后面启停命令要填的参数,很多人启动报错就是因为包名对了但模块名或 Ability 名填错了。先把这两个名字从bm dump里抄下来,后面能省掉大量试错时间。
多设备的时候还有个细节:设备列表里的 connectKey 就是设备的身份证。hdc list targets -v能看到更详细的信息,包括设备类型和连接方式。如果你的机器上同时插着一块开发板和一台模拟器,任何一条不带-s的命令都可能打到你不想要的那台上去,调试到一半发现日志对不上,就是这个问题。
2. 启动应用:aa start 的参数该怎么填
2.1 最小可用命令长什么样
启动应用的核心命令是hdc shell aa start,最少要给它两个信息:Ability 名和包名。典型写法是hdc shell aa start -a EntryAbility -b com.example.demo。这里-a是 Ability 的名字,注意是代码里module.json5里声明的那个名字,不是类名加上一串路径;-b是包名,也就是 bundle name。这两个参数缺一个都不行,只给包名会报输入参数不合法,只给 Ability 名会找不到目标。
如果这个应用有多个模块(比如一个 entry 加若干个 feature 模块),而你要启动的 Ability 不在 entry 模块里,那就还得补上模块名-m。完整一点的形式是hdc shell aa start -a XxxAbility -b com.example.demo -m feature。我在项目里踩过这个坑:feature 模块里的页面用手动点能正常打开,用命令启动一直报找不到 Ability,查了半小时才发现是少了-m。所以当你确认包名和 Ability 名都没写错、命令仍然报错的时候,第一件事就是补-m。
至于-U这个参数,是用来指定用户的。设备上默认的普通用户 ID 通常是 100,多用户场景下如果应用只给某个用户安装过,那启动时就得指定对应的用户 ID,否则会出现"命令执行成功但界面没反应"。这个坑在小设备上不容易碰到,在支持多用户的平板上很常见。我的习惯是:单用户设备不加,一旦涉及多用户测试,就把-U显式写出来,反正写出来也不会错。
2.2 带调试模式、带启动参数的进阶写法
调试阶段强烈建议加上-D,它会把 Ability 以调试模式启动。这个模式的价值在于,你从命令行启动的应用可以被调试器正常附加,日志级别和生命周期回调的行为也更接近 IDE 里点调试按钮的效果。如果你在排查启动阶段就崩溃的问题,没有-D经常会看到应用起来一瞬间就没了,加上之后才能在日志里拿到完整的调用栈。
需要给 Ability 传自定义参数的时候,用--pi传整型、--pb传布尔值这一套参数。比如hdc shell aa start -a EntryAbility -b com.example.demo --pi index 3,应用侧在onCreate里就能读到这个 index 是 3。这个能力在做自动化测试时特别好用:你想直接跳到某个深层页面,与其在脚本里模拟一连串点击,不如给 Ability 传个参数让它自己跳过去,稳定性高出一个档次。要注意的是参数类型必须匹配,给--pi传了字符串,行为是未定义的,我遇到过直接启动失败的情况。
启动模式也值得在这里提一句。Ability 的启动模式决定了重复执行同一条aa start会有什么结果:单实例模式下重复启动通常只是把已有实例拉到前台,多实例模式下则会再起一个。如果你发现反复执行启动命令之后任务栈里堆了一排同样的页面,那不是命令的问题,是配置的问题。先用hdc shell aa dump -l看一下当前的任务栈,再回头调module.json5,比瞎猜快。
2.3 一次启动到底发生了什么
把一条aa start敲下去,设备上大概经历这么几步:客户端把命令通过 server 发到设备上的 hdcd,hdcd 再转给系统的 Ability Manager 服务;Ability Manager 先去校验包名和 Ability 名是否存在,不存在就直接返回错误码,这时候应用进程根本不会被创建;校验通过后,它会检查目标应用进程在不在,不在就先通过应用孵化机制把进程拉起来;进程起来之后加载 Ability 的代码,走onCreate、onWindowStageCreate这一串回调,最后界面才显示出来。
理解这个顺序,排错的时候就有了抓手。命令返回得快、错误码是"ability 不存在",问题在第一步;命令返回慢、日志里能看到进程被拉起的记录,但界面迟迟不出现,问题在后面几步,要去看onWindowStageCreate里的日志。很多人一看到启动失败就去翻应用代码,其实错误码已经告诉你是名字写错了。
2.4 启动报错的常见码与含义
不同 SDK 版本的错误码号段会有些调整,所以下面这些只做方向性参考,具体以你本地版本为准。最常见的一类是"输入参数不合法",通常就是包名或 Ability 名拼错了、-m没给;第二类是"目标不存在",说明系统里确实没有这个包,或者包装在了别的用户下;第三类是权限类错误,某些 Ability 需要特定权限才能被外部拉起;第四类是"内部错误",这种一般是系统服务状态异常,先重启设备或者重启一下 server 再看。
这里有个经验:报错信息里如果出现了数字错误码,把整条命令和报错原封不动贴到日志工具里搜,往往比搜中文描述更准。因为错误码在系统源码和文档里是固定字符串,而中文描述在不同版本里被翻译得五花八门。另外,hdc shell aa help会打出当前版本支持的全部参数,这份输出永远比任何一篇博客都权威,参数拿不准的时候直接看它。
3. 关闭应用:force-stop 和 kill 不是一回事
3.1 aa force-stop 做了什么
关闭应用的正规命令是hdc shell aa force-stop 你的包名,注意这里只要包名,不要 Ability 名,也不需要加-b。它的行为是把整个应用进程连同它的任务栈一起收掉,等价于用户在多任务界面把应用划掉——但比划掉更彻底,因为它不会走正常的退场动画,属于强制终止。
有一个容易被误解的点:force-stop 只是把运行中的进程和任务清掉,不碰应用的数据和缓存。你登录过的账号、写进沙箱的文件,都还在。想让应用恢复到"刚装完"的状态,得用清理数据的能力,一般是通过设备的包管理命令清数据,形式和参数各版本略有差别,用bm help确认一下当前支持的选项。我见过不少人以为 force-stop 会把数据清掉,写自动化脚本时指望它来做"重置环境",结果前后两个用例互相污染,查了半天才发现数据一直在。这两件事要严格分开:进程生命周期归 aa,数据生命周期归 bm。
3.2 想更狠一点:直接杀进程
有时候 force-stop 不够用,比如应用起了常驻的服务进程,或者你要模拟极端场景下的进程被杀。这时候可以绕过 aa,直接用系统的杀进程命令。流程分两步:先用hdc shell pidof 你的包名拿到进程号,如果这个命令在你手上的版本里没有,就退回到hdc shell ps -ef加上 grep 去筛;拿到进程号之后执行hdc shell kill -9 进程号。
需要提前说明,这条路不是所有版本都通。有些版本对 shell 用户杀应用进程做了限制,会直接返回权限不足或者操作不允许,这属于正常的机制设计,不是你环境有问题。碰到这种情况就老老实实回退到 force-stop,它已经能满足绝大多数调试需求。另外,一个应用往往不止一个进程,尤其是有独立进程配置的模块,pidof只会给你主进程号,剩下的要自己从 ps 列表里看。杀主进程不一定能把兄弟进程一起带走,这一点在排查"明明杀干净了应用还能响应"的时候很关键。
3.3 关了又自己起来,问题出在哪
"我刚 force-stop,两三秒后应用又回来了"是我被问得最多的问题之一。原因通常有几种:一是应用里注册了某些系统事件的监听,比如网络变化、屏幕状态变化,事件一来就自己拉起了;二是有常驻任务或者定时任务在跑,任务被触发时会拉起承载它的进程;三是这个应用配置了开机自启,设备重启后由系统拉起,如果你同时在做重启测试,看起来就像是"杀不死"。
排查手段是把时间线拉出来。先用hdc shell hilog把日志持续输出到文件,再执行 force-stop,观察这几秒里到底是谁把进程拉起来的。日志里会出现拉起进程的记录,看到发起方是谁,基本就定位了。这一步比在应用代码里大海捞针高效得多。
3.4 设备重启后应用不自启这件事
有不少人会用重启设备来验证应用的自启能力,然后发现设备起来之后应用并没有跟着起来,尤其是设备重启后停在锁屏、用户还没解锁的情况下。这个现象多数时候是机制使然,不是配置写错了。应用要做到开机自启,前提是它声明了对开机完成事件的监听,并且被系统允许在启动阶段被拉起;而在用户未解锁的状态下,部分数据区域是不可用的,应用进程即使被调度也起不完整,表现出来就是没起来或者起来又退出。
所以做自启验证的时候,测试步骤要把"用户解锁"这一步显式写进去,别把两种状态下的表现混在一起判断。我的习惯是先把设备重启到完全可用状态,登录进去,再用hdc shell aa start手动确认应用能正常启动;确认没问题之后,再单独验证自启逻辑。两件事分开测,出问题的时候你才能确定到底是自启没生效,还是应用本身启动就有问题。
4. 把这套动作封装成脚本
4.1 脚本化的收益和设计思路
单次敲命令没什么技术含量,真正的效率提升来自把启停动作脚本化。脚本能带来三个直接收益:一是把包名、Ability 名、模块名这些容易写错的常量集中在一处维护;二是把"停止—启动—抓日志"这种固定组合变成一条命令;三是在 CI 里可以被稳定调用,不依赖人的记忆。设计上我一般分四层:参数解析(包名、Ability、设备 connectKey、是否带调试)、设备检查(没设备就早退,别让后续步骤报一堆莫名其妙的错)、动作执行(start、stop、restart)、辅助功能(抓日志、截图、看任务栈)。分层的好处是哪一层出问题一眼就能看出来。
4.2 一份可以直接抄的脚本
下面这份脚本我用了很久,改一改常量就能上项目。核心是把 hdc 的调用统一收口到一个变量里,这样多设备场景下只要传一个 connectKey 就能把命令定向到指定设备。
#!/bin/bash # oh-app-ctl.sh 用法: ./oh-app-ctl.sh start|stop|restart|log|shot [connectKey] set -u BUNDLE="com.example.demo" ABILITY="EntryAbility" MODULE="entry" USER_ID="100" CMD="hdc" if [ -n "${2:-}" ]; then CMD="hdc -s $2" fi ensure_device() { local n n=$($CMD list targets 2>/dev/null | grep -v '^\[Empty\]$' | grep -c . || true) if [ "$n" -eq 0 ]; then echo "没有可用设备,先检查线和授权状态" exit 1 fi } do_stop() { ensure_device $CMD shell aa force-stop "$BUNDLE" sleep 1 } do_start() { ensure_device $CMD shell aa start -a "$ABILITY" -b "$BUNDLE" -m "$MODULE" -U "$USER_ID" -D } case "${1:-}" in start) do_start ;; stop) do_stop ;; restart) do_stop; sleep 1; do_start ;; log) ensure_device; $CMD hilog > "hilog-$(date +%H%M%S).log" ;; shot) ensure_device $CMD shell snapshot_display -f /data/local/tmp/shot.jpeg $CMD file recv /data/local/tmp/shot.jpeg ./shot.jpeg ;; *) echo "用法: $0 start|stop|restart|log|shot [connectKey]" ;; esac几个细节解释一下。ensure_device那一段用 grep 过滤[Empty],是因为某些版本的 hdc 在没设备时也会输出这个占位行,直接判空会误判。restart里 stop 之后睡了 1 秒,是给系统回收进程留时间,不睡的话有时候新的启动请求会撞在旧的回收流程上,出现启动失败。-D默认打开,因为日常调试基本都需要。日志重定向用的文件名带时间戳,避免多次抓取互相覆盖。
4.3 把日志和截图串进调试闭环
脚本里那两个辅助功能看着不起眼,实际是排查问题的关键。当你遇到"启动命令成功但界面白屏"这种问题,光看命令行输出什么都看不出来,必须同时拿到日志和画面。日志告诉你应用走到了哪一步、有没有报错;截图告诉你设备上实际显示了什么。两者放在一起,判断范围立刻缩小。
这里有个小技巧:抓日志之前先清一次日志缓冲,能让后面的日志文件干净很多,不然你翻半天前面全是别的模块的输出。清缓存、执行动作、停止抓取,这个顺序固定下来,形成肌肉记忆。截图文件我习惯先放在设备的临时目录,再用拉文件的命令取回来,比让 hdc 直接落到本地要稳,因为有些版本不支持直接输出到本地路径。
4.4 系统层的辅助命令
有些时候应用本身没问题,是系统的状态需要干预。比如设备息屏了,启动命令执行了但屏幕上没东西,这时候先把屏幕唤醒再启动,结果就完全不一样。再比如你要确认目标 Ability 到底有没有被系统识别,用hdc shell aa dump -a把 Ability 相关的信息 dump 出来看;要确认当前任务栈里堆了什么,用hdc shell aa dump -l。这两条命令的价值在于,它给你的是系统的真实视图,而不是你的预期。
还有一种情况是应用启动后界面渲染不正常,画面错乱或者有大片色块。这类现象不要一上来就怀疑应用代码,先做两步:force-stop 之后重新冷启动一次,看能不能复现;能复现再抓日志和截图。很多所谓的渲染问题本质上是进程被异常终止之后 Surface 没被正确重建,重启一次就正常了。把"重启一次看是否还复现"作为排查渲染问题的第一步,能过滤掉相当一部分误报。
5. 高频问题速查
5.1 设备连接类问题对照表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
hdc list targets输出[Empty] | 线材、接口、驱动或 Linux 权限 | 换线换口;Linux 下补 udev 规则后重新插拔 |
| 设备显示未授权 | 设备端没确认调试授权 | 在设备上确认;必要时重启 server 后重连 |
| 设备显示 offline | server 与设备状态不同步 | 先hdc kill再hdc start,然后重连 |
| 命令打到了错误的设备 | 多设备未指定 connectKey | 所有命令统一加-s,脚本里做成参数 |
| 一台机器跑两条流水线互相抢 | 共用了同一个 server 端口 | 给每条流水线分配独立的HDC_SERVER_PORT |
这张表里的每一条我都实际遇到过。其中"打到错误的设备"最隐蔽,因为命令不会报错,只是结果不对,然后你就会去怀疑应用,白白浪费半天。多设备环境下养成统一带-s的习惯,能彻底杜绝这一类问题。
5.2 启停动作类问题对照表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
aa start报目标不存在 | 包名、Ability 名或模块名有误 | 用bm dump -n 包名核对真实名字,补-m |
| 命令成功但界面没变化 | 目标 Ability 已在栈顶或被其他应用挡住 | 先 force-stop 再启动;用aa dump -l看栈 |
| 多用户设备上启动无反应 | 未指定用户 ID | 命令里显式加-U,确认应用装在该用户下 |
| force-stop 后进程立刻复活 | 事件监听、常驻任务或自启机制拉起 | 抓 hilog 看拉起方,从源头改配置 |
kill -9提示无权限 | 当前版本限制了 shell 杀进程 | 回退使用 force-stop |
| 清数据后配置丢失导致启动异常 | 数据被清但首次启动逻辑未覆盖 | 启动前确认初始化流程,或改用缓存清理 |
| 重启设备后应用不自启 | 未解锁状态下数据区不可用 | 解锁后再验证;自启与手动启动分开测 |
5.3 几个我踩过的坑
第一个坑是把-a和-b写反。这两个参数一个名字一个包名,看起来差别很大,但赶时间的时候真的会写错,而且报错信息不会直接告诉你"你写反了",只会说输入不合法。我的对策是在脚本里把这两个值定义成清晰的常量名,命令里直接引用变量,不再手敲。
第二个坑是以为重启 server 会断开设备。其实重启之后设备通常还会重新出现在列表里,不需要你重新插拔。以前我不知道这一点,每次都拔线重插,折腾了很长一段时间。
第三个坑是在自动化流程里没有等待时间。force-stop 之后立刻 start,偶尔会失败,加上一秒的间隔之后就没再出现过。这种偶发问题在单次手动操作时几乎碰不到,一进 CI 就暴露,而且因为没有规律,很容易被当成环境不稳定糊过去。凡是"偶尔失败"的动作,都要怀疑是不是缺少必要的等待。
6. 长期使用后的一些体会
真正让这套东西用起来顺手的,不是把命令背下来,而是把变量和流程固化下来。包名、Ability 名、模块名、目标用户,这四个值在任何一次会话里都是固定的,把它们写进脚本顶部的常量区,后面所有命令都引用它们,能避免绝大部分拼写错误。这件事听起来很基础,但我在不止一个团队里见过有人每次手敲包名,然后花时间排查一个根本不存在的"系统问题"。
另一个体会是关于验证顺序的。遇到问题的时候,我的固定顺序是:先确认设备在不在、对不对;再确认包装没装、名字对不对;然后才是启动动作本身;最后才是看界面和日志。这个顺序之所以有效,是因为它从外到内逐层收敛,每一层都能给出确定的结论,不会让你在应用代码里找一个其实出在连接层的毛病。反过来,一上来就怀疑应用代码,是最容易迷路的方向。
最后一个小习惯分享给你:把hdc shell aa dump -a和hdc shell bm dump -a这两条命令的输出各存一份到本地文件,改完应用之后对比一下差异,很多"感觉不对劲"的地方立刻就能看出来。设备上的状态是会变的,而你脑子里的记忆不会,用文件做基准比用脑子做基准可靠得多。