news 2026/9/9 11:09:00

用GitHub Actions自动清理过期测试用例,拯救失控的CI

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用GitHub Actions自动清理过期测试用例,拯救失控的CI

如果你的测试套件已经跑过几千条用例,你迟早会意识到一件事:测试用例的数量只会增加,不会自己减少。我刚接手一个中型项目时就撞上了这个问题——CI 单次全量跑完要 40 多分钟,其中相当一部分时间花在那些早就没人关心的用例上:临时排查问题加的回归用例、需求下线后残留的功能用例、连续一个月失败却没人处理的“日常红”。我当时的想法很直接:与其每个月手动开一次清理会议,不如让 GitHub Actions 定期帮我把“该清理的测试用例”找出来,整理成报告和 PR,我再做最终审批。这个方案跑了快一个季度,效果比预期好,所以把完整思路和实现细节整理出来。

这篇文章不是纯理论,而是我实际在仓库里落地的方案:包含自动清理策略怎么设计、GitHub Actions 工作流怎么搭、哪些用例能删、哪些绝对不能碰,以及我踩过的几个比较隐蔽的坑。适合测试基础不错但被测项目正在膨胀、CI 时间逐渐失控的团队参考。

1. 测试用例为什么要“自动清理”:用例库正在悄悄腐烂

1.1 “随手加、永不删”带来的连锁反应

咱们先正视一个事实:绝大多数团队对测试用例的管理,只有加法没有减法。新功能来了,加用例;线上出了 bug,加回归用例;某个边界条件没覆盖,再加一条。用例库就是这样一点点膨胀起来的。

但用例不是文档,放着不动也有成本。最直接的是执行时间:被测代码没怎么变,测试套件却越来越慢。另一个更隐蔽的问题是“噪音用例”会稀释有效信号。当 CI 里出现一条连续三周失败但没人处理的用例,团队会逐渐养成“红着也能合代码”的习惯,这时候真正有价值的失败也会被淹没。

我在项目里见过最典型的“腐烂”形态有这么几种:

  • 需求已经下线的老功能,对应测试还在每天跑,断言的产品逻辑早就不存在了;
  • 某次临时排查问题时写的复现用例,问题定位完也没删,一直留在套件里;
  • 被注释掉一半的用例,看起来还在跑,实际上断言形同虚设;
  • 加了@pytest.mark.skip之后再也没人关注,跳过原因已经过时。

这些用例的共同点是:它们仍然出现在测试报告里,消耗 CI 资源,却不再对产品质量提供任何保障。换句话说,套件的规模和它提供的安全感早就脱节了。

1.2 手动清理为什么注定坚持不下去

可能有人会说,用例膨胀就人工清理呗,定期开个“用例治理”专项不就行了。我也试过。手动清理最大的问题不是不会做,而是不可持续。

第一,人工排查的工作量太大。一个几千条用例的仓库,你得逐个看测试函数、对应产品代码、最近有没有变更、CI 历史是否稳定、是否还被其他 fixture 引用。这个信息分散在测试文件、git 历史、CI 报告三个地方,靠肉眼整合效率极低。

第二,清理动作往往是一次性的。专项治理做完的那一周,用例数确实下来一点,但下一周新用例又加进来了,三个月后恢复原样。因为团队缺少一个持续运转的机制来拦截“新垃圾用例”进入。

第三,也是最现实的一点:删用例有心理负担。没人敢保证某条看着没用的用例不会在某个深夜成为救命稻草,“万一以后用得上”这种心态会让清理工作阻力巨大。所以我最后确定的原则是:不是靠人定期去删,而是用自动化手段持续发现、标记、归档,给人留足 review 和反悔的空间。

1.3 GitHub Actions 在这个场景里的独特优势

选 GitHub Actions 不是因为它功能最全,而是因为它和代码仓库、CI 历史天然在一起。清理测试用例需要的数据——提交记录、PR 信息、测试报告——本来就都在仓库里,工作流可以直接读取、分析、生成 PR,不需要额外引入一套平台。

具体来说,GitHub Actions 能帮我们完成三件事:

  • 按 schedule 定期触发扫描,不需要有人记得“该清理了”;
  • 读取 git 历史、测试文件、之前 CI 生成的 junit 报告,综合判断哪些用例可疑;
  • 自动生成一份带证据的候选清理清单,并以 PR 的形式提交给维护者审批。

整个过程里,机器只负责“识别和提议”,最终决定权始终在人手里。这个安全边界很重要,后面我会专门讲。

2. 自动清理不是“自动删除”:我采用的三层判定与状态流转

2.1 最初的设计原则:永不直接删除,只做可追溯的降级

动工之前我先给整个方案划了一条红线:测试代码也是代码资产,任何自动清理都不能直接把用例从 git 历史里抹掉。原因很实际——自动判定一定会出错,一旦误删,如果是产品代码还能靠 review 拦一下,但测试用例这种“看起来没用实际有用”的东西,误删概率更高。

所以我把“自动清理”的定义从“删除”改成了“降级 + 隔离”:自动化脚本只负责把可疑用例挑出来,加标记、归档到专门的目录,然后生成一份 PR。这个 PR 合入后,用例不会立刻消失,而是进入一个“隔离区”,继续观察一段时间。确认没问题之后,再由人决定是否真正删除。

我实际设定了一个带冷静期的时间窗口,默认隔离 30 天。30 天内没有因为隔离产生回归、没有人在 PR 里提出异议,这条用例才算完成它的生命周期。整个过程有案可查,即使出现误判也能快速恢复。

2.2 判定依据:物理死代码、逻辑死用例、行为异常

自动化脚本判定一条用例“该清理”不能靠猜,得有证据。我把证据分成三层,按优先级从高到低排列。

第一层是“物理死代码”。这种最硬,也最安全:测试引用的被测函数已经不存在了,或者被测功能对应的业务模块已经整个下线。判断方法有两种,一是跑静态扫描,检查测试文件里的 import 和函数调用是否还能在被测代码里找到对应定义;二是用git log --diff-filter=D查看被测文件的删除记录,再反向找出哪些测试还在引用这些已删除的符号。这一层的判定基本不会误伤,因为证据是代码层面的硬事实。

第二层是“逻辑死用例”。这种用例代码还能跑,但已经失去了测试意义。典型表现包括:加了@pytest.mark.skip且超过 30 天没移除、测试函数体里主要断言都被注释掉、专门为某次临时 bug 写的复现用例在问题修复后没有再被需要。逻辑死用例比物理死代码更难识别,因为它需要结合 git 历史看“这条用例最后一次有效修改是什么时候”以及“它当时的用途是什么”。

第三层是“行为异常用例”。这种最容易被忽略,但它对套件质量的伤害最大:测试还在跑,但已经不稳定了。我在分析脚本里加了执行历史统计,如果一个用例在最近 10 次 CI 运行里有 4 次以上失败,而且产品代码没有对应变更,它大概率是一条 flaky 用例。flaky 用例的正确处理方式不是简单删掉,而是隔离出来单独定位,否则它会持续污染 CI 信号。

这三层不互斥,一条用例可能同时命中“逻辑死”和“行为异常”,这时候清理优先级会提高。

2.3 候选用例的状态流转路径

为了让整个流程可预测,我给每条候选用例设置了四个状态:annotatedquarantineddeletedrestored

第一次扫描命中后,用例进入annotated状态:脚本会往测试函数上追加一个pytest.mark.stale(reason="...")标记,并在专门的说明文件里登记命中规则、最后提交时间、失败率等证据。这个状态不会改变测试行为,主要是“示众”加“留档”。

接着脚本会生成清理 PR。维护者 review 后合并,用例进入quarantined状态,实现方式是把相关的测试文件移动到仓库下的quarantine/目录,并从默认的测试收集路径中排除。这之后开始 30 天观察期。

观察期满且无人反对,用例才进入deleted状态。但“删除”也不是直接抹掉历史——合并删除 PR 时,旧文件会留在 git 历史里,同时我会在 git tag 上留一个归档点,需要的时候可以随时找回。如果观察期内出了问题,相关用例直接从 quarantine 目录恢复,状态回到正常。

这套状态机是整个工作流的核心骨架,后面的 YAML 配置和 Python 脚本,本质上都是为这四个状态服务的。

3. 落地实现:GitHub Actions 工作流 + 规则文件 + 扫描脚本

3.1 工作流触发方式与权限设计

实际写配置的时候,我建议把自动清理工作流放在.github/workflows/stale-test-cleanup.yml。触发方式我不会只用schedule,还会加上workflow_dispatch手动触发入口。

原因有两个。其一,GitHub Actions 的schedule事件本身不是绝对可靠:如果一个仓库连续 60 天没有任何活动,定时任务会被平台自动暂停。对这种“低频但很重要”的维护任务,必须留一个手动入口,否则某次自动暂停后很容易被遗忘。其二,清理逻辑改动后,需要在当前分支上立刻验证一次,手动触发比等下一次 cron 快得多。

权限方面遵循最小化原则。扫描不需要写仓库代码,所以contents只给read;但它要创建 PR,所以pull-requests要给write。整个工作流只使用仓库自带的GITHUB_TOKEN,不建议用个人 access token,否则权限范围不好控制。

这里是我的完整配置,可以直接抄:

name: test-case-cleanup on: schedule: - cron: "0 2 * * 1" workflow_dispatch: permissions: contents: read pull-requests: write jobs: scan-and-propose: runs-on: ubuntu-latest steps: - name: Checkout with full history uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install dependencies run: | pip install pyyaml pytest - name: Run stale test scanner run: | python scripts/scan_stale_tests.py \ --rules .github/cleanup-rules.yml \ --repo-root "${{ github.workspace }}" \ --output .cleanup/candidates.json - name: Generate cleanup PR body if: hashFiles('.cleanup/candidates.json') != '' run: | python scripts/generate_cleanup_report.py \ --input .cleanup/candidates.json \ --output .cleanup/PR_BODY.md - name: Create pull request if: hashFiles('.cleanup/PR_BODY.md') != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | BRANCH="cleanup/stale-tests-$(date +%Y%m%d%H%M)" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" git add -A git commit -m "chore(test): quarantine stale test cases" git push origin "$BRANCH" gh pr create \ --base main \ --head "$BRANCH" \ --title "chore(test): quarantine stale test cases" \ --body-file .cleanup/PR_BODY.md

注意checkout时必须设置fetch-depth: 0,否则只能拿到浅克隆,git blamegit log --diff-filter=D这些分析都会失效。这是我实际踩过的一个坑,后面会展开说。

3.2 规则文件怎么组织才不僵化

清理逻辑不能全部硬编码在脚本里,否则每次想调判定参数都要改代码。我把规则抽到了单独的.github/cleanup-rules.yml,扫描脚本启动时读取这个文件。

规则文件的组织方式如下:

# .github/cleanup-rules.yml rules: - id: stale-skip-over-30d enabled: true type: marker-based marker: skip stale_days: 30 action: quarantine - id: orphaned-test-after-deletion enabled: true type: orphan-based min_age_days: 60 action: quarantine - id: flaky-over-40pct enabled: false type: flaky-based failure_rate_threshold: 0.4 min_runs: 10 action: quarantine

每条规则都包含 id、类型、判定参数、动作。enabled: false的规则不是删除而是暂时关闭,方便后续调整。我建议把规则文件放在仓库里随代码一起 review,每次调参数都有 diff 记录,比在脚本里偷偷改一个魔法数字要透明得多。

三条规则对应刚才讲的三层判定:第一条处理“跳过超时”的逻辑死用例;第二条处理“产品代码被删除后残留”的物理死用例;第三条处理 flaky 行为异常用例。规则类型目前是内置的,后续如果发现新的清理场景,在扫描脚本里加一个 rule type 就行,不需要动工作流。

3.3 核心扫描脚本:先找证据,再下结论

这一步是整个方案的大脑。我用 Python 写了一个scripts/scan_stale_tests.py,整体流程是:读取规则、扫描测试目录、收集 git 历史和 CI 报告、逐条规则打证据、输出 JSON。

先说 marker-based 的判定。测试文件里常能看到这类用例:

@pytest.mark.skip(reason="暂时跳过,等待服务端修复") def test_order_refund_with_coupon(): ...

脚本会扫描所有测试文件里的 marker,读取skip标记的添加时间,然后和当前时间对比。但这里有个细节:skip标记本身可能放在函数上,也可能放在类上,还有可能是装饰器工厂生成的,所以不能只做字符串匹配。我最后是用ast模块解析 Python 源码,把装饰器解析成结构化的节点,再读取reason参数。这种方式比正则稳得多。

再说 orphan-based 判定。它的核心输入是 git 历史里被删除的产品文件列表:

git log --diff-filter=D --name-only --pretty=format: -- 'src/*.py'

拿到这个列表后,脚本会检查测试目录下有没有 import 这些模块的用例。为了提升准确性,我还会用git log -1 --format=%cI -- <test_file>拿到测试文件最后一次提交时间,只处理 60 天前的文件,避免误伤刚写的用例。

最后是 flaky-based。脚本会读取最近一次 CI 生成的 JUnit XML 报告(路径可以在规则里配置),按测试节点名聚合历史结果。如果某个用例的失败次数占比超过阈值,并且同期没有产品代码提交命中这个用例的关联路径,就标记为行为异常。

扫描完成后,脚本输出一个 JSON 文件,每一条候选用例都附带完整证据:

{ "candidates": [ { "file": "tests/test_payment.py", "test_name": "test_order_refund_with_coupon", "rules_hit": ["stale-skip-over-30d"], "evidence": { "marker": "skip", "skip_added_at": "2024-11-02T10:30:00+08:00", "reason": "暂时跳过,等待服务端修复", "last_modified_at": "2024-11-02T10:30:00+08:00" }, "suggested_action": "quarantine" } ] }

证据字段特别重要。维护者 review 清理 PR 时,看到的不是一句“这条用例没用了”,而是一套可核验的事实链,这样他才有信心点击合并。

3.4 自动生成清理 PR,但把最终闸门留给人类

扫描脚本只负责产出候选清单,真正把清单变成代码变更的是工作流里的 report 生成和 PR 创建步骤。报告生成脚本会读取 candidates.json,把它渲染成一段可读性好的 Markdown,包含每个候选文件、命中规则、证据摘要、建议动作。

报告里我会固定包含一段“如果这条用例在 30 天内被恢复过,说明清理逻辑有误判,请在 PR 里留言”。这既是对维护者的提醒,也是在帮后续调优规则收集信号。

PR 创建完成后还有一道人工闸门:工作流不会自动合并 PR。这意味着每次清理动作都必须有真人 review 确认,哪怕只是粗略扫一眼,也比全自动合并安全得多。跑了一段时间后我发现,维护者 review 一份 20 条候选的清理 PR 通常只要五到十分钟,因为报告里把证据都列好了,基本只是确认“这些确实没用了”而已。

4. 跑通这套流程之后,我踩过的几个值得警惕的坑

4.1 浅克隆会让 git 历史分析直接失效

第一次在工作流里跑扫描脚本时,我发现 orphan-based 判定结果离谱得很,几乎把测试目录里所有文件都标成了候选。排查了半天,罪魁祸首是actions/checkout默认的fetch-depth: 1

浅克隆只带了最新一次提交,git log --diff-filter=D自然查不到任何历史删除记录,脚本以为所有测试文件都成了孤儿。这个问题不报错,只是输出结果完全错误,非常容易忽略。解决方案很简单:checkout 时设置fetch-depth: 0,把完整历史拉下来。

完整历史对一个小型仓库来说体积完全可接受,但如果你的仓库非常大,可以把 fetch-depth 设成一个合理值,比如 500,至少覆盖几十天的变更记录。

4.2 不是所有 skip 用例都该进隔离区

我最初把“带 skip 标记超过 30 天”作为硬性清理条件,结果差点误伤一批合法的跨平台用例。

这类用例长这样:

@pytest.mark.skipif(sys.platform == "win32", reason="Windows 暂不支持该协议") def test_windows_named_pipe(): ...

它们在 Linux 上不会 skip,因为条件不满足;在 Windows 上一直 skip,但不是因为“代码死了”,而是平台能力限制。如果只按标记名和时长一刀切,就会把这类用例当成垃圾清理掉。

修正方案是:分析 marker 时区分skipskipifskipif必须进一步解析条件表达式。如果跳过条件是平台、Python 版本这类环境因素,则不作为清理候选;如果跳过原因是“等待某个修复”“临时屏蔽”,才进入候选列表。这里建议把判定维度从“跳过时长”扩展成“跳过原因 + 是否条件式”,宁可漏报也不要误伤。

4.3 共享 fixture 和 conftest.py 是误删的重灾区

测试用例之间不是孤立的,candidate 清单里的某个测试文件可能被其他测试文件共享。我就遇到过这样的情况:一个看起来“没人用”的 fixture 函数定义在tests/conftest.py里,但实际被另外三个测试模块引用,孤儿用例扫描却只检查了测试函数层面的直接调用,没有解析 fixture 依赖,差点把这个共享 fixture 标记为待清理。

后来我加了一个前置过滤步骤:所有在conftest.py里定义的 fixture、所有被pytest_plugins引用的模块、所有在多个测试文件中出现的工具函数,一律不进候选清单。换句话说,脚本只删“自己模块内部自包含”的用例;fixture 这类跨文件共享资产,宁可保留,等待后续人工复核。

4.4 flaky 用例的判定容易被执行环境干扰

flaky-based 规则刚上线时,误报率比预期高。排查后发现,很多失败不是用例本身的问题,而是 CI runner 环境不稳定:某个共享 runner 上 Docker 资源不足导致超时、外部依赖服务偶发 500、网络重试策略没写好。这些失败和产品代码无关,但都会被记入 JUnit 报告。

不能盲信 JUnit 报告里的失败字段。我后来做了两层过滤:

  • 收集失败原因的分类关键词,比如TimeoutErrorConnectionResetErrorrequests.exceptions.ConnectionError,这类网络/超时类异常不直接当作 flaky 证据;
  • 对比同一次运行里其它用例的失败比例。如果同批次失败率超过 30%,优先怀疑是环境问题,而不是单条用例行为异常。

加了这两层过滤之后,flaky 候选数量明显下降,剩下的基本都是稳定可复现的常见问题信号。

4.5 保护恢复路径:清理前留一个归档 tag

真正开始删除用例之前,我建议每次清理都在 git 里留一个归档点。我的做法是:当清理 PR 确认要合并时,先对main分支打一个格式如下的 tag:

git tag archive/test-cleanup-<repo>-<timestamp> git push origin archive/test-cleanup-<repo>-<timestamp>

这个 tag 可以理解为用例的“回收站入口”。如果清理后某天真的发现丢了什么,“git show tag:path/to/test_file.py” 就能找回原内容。成本很低,但能明显降低维护者点击合并时的心理压力。前面提过“30 天冷静期”,等冷静期结束再打归档 tag 更稳妥,执行频率不需要太高。

5. 这套流水线到底改变了什么:效果观察与后续扩展

5.1 我实际跟踪的一组量化信号

清理流水线跑起来之后,我给自己定了四个衡量指标,每周记录一次:

  • 测试套件中有效用例总数;
  • 单次全量 CI 的中位执行时间;
  • 连续失败一周以上的“烂用例”数量;
  • 因为清理动作导致的线上回归次数。

四个指标里,前两个反映效率和规模,第三个反映套件健康度,第四个最严格——它衡量清理本身的副作用。一个合格的清理方案必须做到:动作发生之后,前三个指标明显改善,第四个指标长期为零或接近零。

实际运行一个季度后,我这边的情况是:用例总数减少了约 30%,全量 CI 时间中位数从 40 分钟降到 26 分钟左右。这 30% 看起来很多,但其中相当一部分是已经失去意义的老功能用例,它们跑着只会拖慢反馈速度。最明显的变化其实是“烂用例”数量减少了,CI 报告重新变得可读,大家又开始愿意看测试结果了。

5.2 把清理和覆盖率数据、风险分布打通

第一版清理方案只依赖源码结构和 git 历史,后来我把覆盖率报告也加了进来,作为第四层判定依据。

逻辑很简单:一条用例如果在一段时间内从未贡献过新的覆盖行,或者它命中的代码区域和其他用例高度重叠,说明它的边际价值可能很低。不过覆盖率数据只能作为旁证,不能单独作为清理依据,因为有些断言非常有价值,即使它们没有新增覆盖行。我的做法是:覆盖率低于阈值 + 同时命中前面三层的某一层,才提升清理优先级。

再往后,我还在清理报告的每个候选用例里附上了“最近 30 天失败次数”和“关联的产品代码模块”。这样维护者 review 时不只看到“它该删”,还能看到“它最近表现如何”“删了会影响哪个模块”。信息维度多一点,做决定就快一点。

5.3 从“定期清理”走向“用例健康度巡检”

这套方案跑到现在,我的体会是:自动清理的价值不在“删”,而在“逼着用例库持续接受体检”。

测试用例是一种需要持续维护的资产,类似库存管理:只进不出一定会爆仓。GitHub Actions 在这里不是花架子,它把“定期巡检”变成了仓库的默认行为,不依赖某个人想起来才做。我现在也不再叫它“清理任务”了,更准确的说法是“用例健康度巡检”——它同时在看:哪些用例死了、哪些用例快死了、哪些用例在拖累整体效率。

如果你也想在这个方向继续深入,有几个自然的延伸方向:

  • 把清理规则接口扩展到更多测试框架,比如 Jest、JUnit、GTest;
  • 在 PR 创建时用同一套规则扫新增用例,提前拦截“新垃圾用例”入库;
  • 把 JUnit 报告换成仓库内长期持久化的运行历史,判定会更准;
  • 把候选清单通过 issue comment 或钉钉、飞书机器人发出来,团队每周扫一眼就够了。

就我个人的实际经验来说,稳定的清理流程比一次轰轰烈烈的大扫除更有用。大扫除只能管一阵子,而自动巡检会让整个仓库慢慢养成一种习惯:每一条测试用例都有它存在的理由,没有理由的会被系统找出来,而不是一直躺在那里假装自己在保护产品质量。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 11:08:37

OV5645 MIPI CSI-2 YUV图像采集驱动:从协议解析到FPGA/Linux实现

简介&#xff1a;OV5645 MIPI YUV驱动是一份面向手机及平板等嵌入式设备的摄像头驱动源码包&#xff0c;主要服务嵌入式驱动开发、摄像头调试以及系统移植相关工程师&#xff1b;该驱动围绕OV5645图像传感器在移动行业处理器接口下的YUV图像输出&#xff0c;完整覆盖传感器初始…

作者头像 李华
网站建设 2026/9/9 11:06:50

手机短信导出全攻略:从Android到iOS的原理与实操

1. 内容整体设计与思路拆解1.1 短信导出到底在解决什么问题先说个实际的场景&#xff1a;我手头有一台用了四年的安卓手机&#xff0c;里面躺着两万多条短信&#xff0c;有银行验证码、快递取件通知、老同学叙旧、家人的叮嘱&#xff0c;还有几段跟客户谈事的完整记录。某天手机…

作者头像 李华
网站建设 2026/9/9 11:06:19

SpringBoot+Vue3+MyBatis+MySQL汽车维修预约系统实战

前阵子把这套汽车维修预约服务系统完整撸了一遍&#xff0c;从需求梳理、数据库设计到前后端联调&#xff0c;坑没少踩。项目本身不算复杂&#xff0c;但该有的模块都有&#xff1a;SpringBoot 做后端接口&#xff0c;Vue3 做管理端页面&#xff0c;MyBatis 负责数据库交互&…

作者头像 李华
网站建设 2026/9/9 11:05:54

超导磁能储存系统SMES的Simulink建模与仿真实践

提到储能&#xff0c;很多人第一反应是锂电池、抽水蓄能或者飞轮&#xff0c;但在电力电子和电力系统领域&#xff0c;超导磁能储存系统&#xff08;SMES&#xff09;一直是个独特存在。它不是通过化学能或势能存储能量&#xff0c;而是直接把电能以磁场形式“锁”在超导线圈里…

作者头像 李华
网站建设 2026/9/9 11:05:41

Android利用Onvif实现局域网摄像头发现与RTSP取流的完整实践

简介&#xff1a;在Android平台上使用Onvif协议完成局域网摄像头自动发现&#xff0c;并获取可播放的视频流地址&#xff0c;是安防与物联网项目开发中常见的需求。资源面向具备Android基础、需要对接不同品牌Onvif兼容设备的开发者&#xff0c;围绕设备发现、身份认证、媒体服…

作者头像 李华