我做了一个审计 Android 项目的 LangGraph Agent:架构全公开
- 原文链接:dev.to/samuvelp/i-…
- 原文作者:Samuvel Pandian
每个 Android 团队都会拖到疼才处理的问题
你一定有过这种感觉:打开一个三个月没碰过的 Android 项目,结果构建直接挂掉。AGP 要升级,Kotlin 要升级,Compose BOM 又往前走了两个版本。你一路更新、一路修联动崩溃,刚推上去,就有人提醒你:targetSdk 已经落后两个版本,下个季度 Google Play 就可能拒绝上架。
这还只是依赖层面。与此同时,你的 AndroidManifest.xml 里可能有一个导出的 Service,却没有权限保护,因为有人在黑客松期间临时加过。代码库里还残留着没人想动的 AsyncTask。Compose 采用率只有 20%,XML 布局却有 47 个,而且没有迁移计划。技术债不在一个地方,而是散落在构建文件、manifest、源码目录和版本目录里。
工具并不是没有:lint 能抓一部分问题,依赖更新插件能抓另一部分,人工审查 manifest 也能补一点安全视角。但没人把它们一次性跑通、没人做统一优先级,更没人告诉你“先修导出的 BroadcastReceiver,再考虑那个 AndroidX 的小版本升级”。我想补上的就是这个缺口。
解决方案:一条命令,完整审计,AI 排优先级
DroidDoctor 是一个开源 CLI。它会扫描整个 Android 项目:Gradle 依赖、manifest 安全、废弃 API 使用、Compose 采用情况,然后把结构化结果交给 LLM,产出带优先级的行动计划。一条命令,给出 0-100 的健康分,明确区分“现在就修”和“后面再修”。我用 LangGraph 来搭它的分析流程,让整个管线是一个状态机,而不是一堆散脚本。
架构拆解:7 节点状态机
图结构如下:
┌─────────────────────────┐
│ scan_project_structure │ ← entry point
└────────────┬────────────┘
│
┌───────▼────────┐ error?
│ conditional │──────────► END
│ routing │
└───────┬────────┘
│ continue
┌───────▼──────────────────┐
│ analyze_gradle_deps │
└───────┬──────────────────┘
┌───────▼──────────────────┐
│ audit_manifest │
└───────┬──────────────────┘
┌───────▼──────────────────┐
│ detect_deprecated_apis │
└───────┬──────────────────┘
┌───────▼──────────────────┐
│ check_compose_adoption │
└───────┬──────────────────┘
┌───────▼──────────────────┐
│ collect_results │ ← fan-in sync point
└───────┬──────────────────┘
│
┌───────▼──────────────────┐ --no-llm?
│ llm_analyze │──── skip ──┐
└───────┬──────────────────┘ │
┌───────▼──────────────────┐ │
│ generate_report │◄────────────┘
└───────┬──────────────────┘
│
END
每个节点都接收并返回同一个 AgentState TypedDict。LangGraph 会自动把每个节点返回的字典 merge 回运行时状态,所以节点只需要返回自己改动的 key。这是整个实现能保持清爽的核心模式。
关键设计决策
LLM 不直接扫描文件。 每个分析节点都用确定性的 Python 代码完成:正则、XML 解析、HTTP 版本查询。LLM 只在最后接收结构化 JSON 摘要,并把它综合成优先级报告。这样能获得可复现扫描、更快执行速度,以及可用于 CI 的 --no-llm 离线模式。
各节点做什么
scanner:解析 settings.gradle(.kts),用 include() 正则找模块,再遍历模块目录,定位构建文件、manifest、源码树和布局目录,填充 ModuleInfo 数据类列表。
gradle:解析每个模块的 build.gradle(.kts) 和 libs.versions.toml,提取依赖、SDK 版本、AGP/Kotlin/Compose BOM 版本,再请求 Google Maven 和 Maven Central API,按 critical、major、minor 标记版本陈旧程度。
manifest:用 xml.etree.ElementTree 解析每个 AndroidManifest.xml,检查导出组件是否缺权限保护、危险权限、明文流量开关、硬编码 debuggable=true、备份规则缺失等问题。
deprecated:对所有 .kt / .java 文件做正则扫描,覆盖 13 种模式:AsyncTask、IntentService、startActivityForResult、LocalBroadcastManager、ViewModelProviders.of()、android.support.*、kotlin-android-extensions、kapt 等,并给出替代建议。
compose:统计所有 res/layout* 目录里的 XML 布局文件数量,以及带 @Composable 注解的 Kotlin 文件数量,计算采用率。指标很朴素,但足够让迁移进度可见。
analyzer:把所有发现序列化成 JSON,喂给 LLM 和一段偏“主见型”的系统提示词,抽取健康分。支持 Claude、OpenAI、Gemini、Groq、Ollama,提供方通过动态导入切换。
reporter:拼接最终 Markdown 报告,输出依赖表、manifest 问题、废弃 API、AI 分析区块。
条件路由
图里有两个路由点。第一处在扫描后:若找不到任何 build.gradle,状态里会写入 error,图直接跳到 END。第二处是 --no-llm:开启后 build_graph(skip_llm=True) 会把 collect_results 直接连到 generate_report,跳过 LLM 节点。也就是说这是“图拓扑”层面的改变,不是运行时 if 判断。
代码走读
贯穿全流程的状态结构
class AgentState(TypedDict, total=False):
project_path: str
project_name: str
is_multi_module: bool
modules: list[ModuleInfo]
llm_provider: str # claude | openai | gemini | groq | ollama
llm_model: str | None
gradle_deps: list[GradleDependency]
sdk_versions: SdkVersions
manifest_issues: list[ManifestIssue]
deprecated_apis: list[DeprecatedApiUsage]
compose_metrics: ComposeAdoptionMetrics | None
health_score: int # 0-100
llm_report: str
final_report: str
error: str | None
total=False 意味着所有 key 都是可选的,节点只填自己负责的那一段状态,剩下的交给 LangGraph merge。
版本目录解析(最棘手的部分)
Gradle 的 libs.versions.toml 有三种常见声明格式,你必须全覆盖:
def _parse_library_line(line: str, catalog: VersionCatalog) -> None:
alias_match = re.match(r'^(\S+)\s*=\s*(.+)$', line)
if not alias_match:
return
alias = alias_match.group(1)
value = alias_match.group(2).strip()
# 格式 1: "group:artifact:version"
simple = re.match(r'^"([^:]+):([^:]+):([^"]+)"$', value)
if simple:
catalog.libraries[alias] = ParsedDependency(
group=simple.group(1), artifact=simple.group(2),
version=simple.group(3),
)
return
# 格式 2+3: { module = "g:a", version.ref = "x" }
# 或: { group = "g", name = "a", version.ref = "x" }
group = _extract_field(value, "group")
name = _extract_field(value, "name")
module = _extract_field(value, "module")
version_ref = _extract_field(value, "version.ref")
if module:
parts = module.split(":")
if len(parts) == 2:
group, name = parts
resolved_version = None
if version_ref and version_ref in catalog.versions:
resolved_version = catalog.versions[version_ref]
catalog.libraries[alias] = ParsedDependency(
group=group, artifact=name, version=resolved_version
)
随后再做一层映射,把 TOML alias 转成 Gradle accessor(例如 androidx-core-ktx → libs.androidx.core.ktx),并检查它是否出现在构建文件里。这个两阶段处理能解决版本目录带来的“间接引用”问题。
图的组装方式
def build_graph(skip_llm: bool = False) -> StateGraph:
graph = StateGraph(AgentState)
graph.add_node("scan_project_structure", scan_project_structure)
graph.add_node("analyze_gradle_dependencies", analyze_gradle_dependencies)
graph.add_node("audit_manifest", audit_manifest)
graph.add_node("detect_deprecated_apis", detect_deprecated_apis)
graph.add_node("check_compose_adoption", check_compose_adoption)
graph.add_node("collect_results", _collect_results)
if not skip_llm:
graph.add_node("llm_analyze", llm_analyze)
graph.add_node("generate_report", generate_report)
graph.set_entry_point("scan_project_structure")
graph.add_conditional_edges(
"scan_project_structure",
_route_after_scan,
{"continue": "analyze_gradle_dependencies", "error": END},
)
graph.add_edge("analyze_gradle_dependencies", "audit_manifest")
graph.add_edge("audit_manifest", "detect_deprecated_apis")
graph.add_edge("detect_deprecated_apis", "check_compose_adoption")
graph.add_edge("check_compose_adoption", "collect_results")
if skip_llm:
graph.add_edge("collect_results", "generate_report")
else:
graph.add_edge("collect_results", "llm_analyze")
graph.add_edge("llm_analyze", "generate_report")
graph.add_edge("generate_report", END)
return graph
注意 skip_llm 在 build 阶段直接改图,不是在运行时分支判断,所以编译后的 graph 形状是不同的。
LLM 提示词
系统提示词是“有立场”的,目标是让模型扮演资深 Android 工程师,而不是礼貌但泛泛的助手:
SYSTEM_PROMPT = """\
You are DroidDoctor, a senior Android engineer performing a project health review.
Given the audit data, produce:
1. A HEALTH SCORE (0-100) based on:
- Dependency freshness (30% weight)
- Security posture from manifest (30% weight)
- Code modernization / deprecated API usage (20% weight)
- Compose adoption progress (20% weight)
2. A prioritized action plan grouped by:
FIX NOW — security vulnerabilities, critical outdated deps
FIX THIS SPRINT — major version gaps, deprecated APIs
PLAN FOR NEXT QUARTER — compose migration, minor updates
Be opinionated. A senior Android engineer would be direct.
Avoid generic advice — reference the specific deps and files found.
"""
动态加载多提供方
支持 5 家 LLM 提供方,并且不强制安装全部依赖:
PROVIDER_CONFIG = {
"claude": {"package": "langchain_anthropic", "class": "ChatAnthropic", "default_model": "claude-sonnet-4-20250514"},
"openai": {"package": "langchain_openai", "class": "ChatOpenAI", "default_model": "gpt-4o"},
"gemini": {"package": "langchain_google_genai", "class": "ChatGoogleGenerativeAI", "default_model": "gemini-2.5-pro-preview-06-05"},
"groq": {"package": "langchain_groq", "class": "ChatGroq", "default_model": "llama-3.3-70b-versatile"},
"ollama": {"package": "langchain_ollama", "class": "ChatOllama", "default_model": "llama3.1"},
}
def _build_llm(provider: str, model: str | None = None) -> BaseChatModel:
config = PROVIDER_CONFIG[provider]
module = importlib.import_module(config["package"])
cls = getattr(module, config["class"])
return cls(**{config["model_kwarg"]: model or config["default_model"]})
importlib.import_module 的价值是:你只需要安装当前选用提供方所需的包。比如本地走 Ollama,就不需要 API key,也不用装 langchain-anthropic。
输出示例
╭──────── 健康评分 — MyApp ────────╮
│ 62/100 │
╰──────── 已扫描 3 个模块 ─────────╯
┌─────────────── 过期依赖 ────────────────┐
│ 依赖项 │ 当前值 │ 最新值 │ 严重度 │
│ com.android.tools.build │ 8.1.0 │ 8.7.3 │ MAJOR │
│ org.jetbrains.kotlin │ 1.9.10 │ 2.1.0 │ CRIT. │
│ targetSdk │ 33 │ 35 │ CRIT. │
│ androidx.core:core-ktx │ 1.10.0 │ 1.15.0 │ MINOR │
└──────────────────────────────────────────────────────┘
┌─────────────── Manifest 问题 ──────────────────────┐
│ 严重 │ Service "SyncService" 已导出但缺少 │
│ │ intent-filter 或权限保护 │
│ 严重 │ 已启用明文(HTTP)流量 │
│ 警告 │ 危险权限:CAMERA │
└──────────────────────────────────────────────────────┘
╭──── Compose 采用情况 ────╮
│ XML 布局:47 │
│ Composable:12 │
│ 覆盖率:20.3% │
│ ░░░░████████████████ 20% │
╰──────────────────────────╯
╭──────────── AI 优先级分析 ─────────────╮
│ 立即修复: │
│ - SyncService 已导出但没有权限保护。 │
│ 设备上任意 App 都可绑定它。应添加 │
│ android:permission 或设 │
│ exported="false"。 │
│ - Kotlin 1.9 → 2.1 是大版本跳跃。 │
│ 应尽早完成,避免后续生态继续拉开差距。 │
│ │
│ 本迭代修复: │
│ - 将 NetworkHelper.java 中的 AsyncTask │
│ 替换为 viewModelScope.launch + │
│ Dispatchers.IO │
│ - 将 AGP 从 8.1 升到 8.7(支持 │
│ targetSdk 35 所必需) │
│ │
│ 下季度规划: │
│ - 当前有 47 个 XML 布局且 Compose │
│ 覆盖率只有 20%。建议先从新增页面开始, │
│ 然后优先迁移设置页/个人页等低风险页面。 │
╰──────────────────────────────────────────╯
快速开始
pip install droiddoctor
# 配置你要用的 LLM 提供方 API Key
export ANTHROPIC_API_KEY=sk-ant-...
# 扫描你的 Android 项目
droiddoctor /path/to/your/android-project
# 或切换到其他提供方
droiddoctor . --provider openai
# 离线模式(不调用 LLM)
droiddoctor . --no-llm
我在构建过程中的收获
LangGraph 对比手写编排。 对这种线性流水线,你当然可以直接按顺序调用函数。但 LangGraph 自带条件路由、状态管理、进度流输出。加 --no-llm 时,我不需要在 run loop 里再写 if,而是直接改图拓扑。后续要把分析节点并行化,也是改边,不是重写控制流。只要流程出现第二个分支,这个抽象就开始回本。
确定性节点 + LLM 汇总,优于让 LLM 直接读原始文件。 早期原型里,我把 build.gradle 原文直接喂给 Claude 让它找问题,结果会幻觉依赖版本、漏掉版本目录里的间接引用、也无法去 Maven Central 查询最新版本。当前架构是:扫描交给代码(稳定可复现),优先级判断交给 LLM(它确实更擅长这个)。
版本目录解析真的比想象中复杂。 有三种声明形态("g:a:v"、{ module = "g:a" }、{ group = "g", name = "a" }),还要处理 version.ref 间接版本,再把 alias 映射成 Gradle accessor(androidx-core-ktx → libs.androidx.core.ktx)。我考虑过引入 TOML 解析库,但按行正则在真实项目里已够用,而且少一个依赖。
每个评分维度都要做扣分封顶。 健康分按加权维度扣分:依赖最多扣 30,安全最多扣 30,废弃 API 最多扣 20,Compose 采用最多扣 20。如果不封顶,一个有 50 个过期依赖的项目会直接被打到 0 分,即便 manifest 很干净、代码也挺现代。封顶后分数才能体现“问题广度”,而不是某一类问题的“深度”。
下一步计划
DroidDoctor 还在早期阶段,有不少方向欢迎贡献:
- 并行分析节点:现在 4 个分析节点是串行执行。LangGraph 支持 fan-out/fan-in,把它们并行后扫描时间会明显下降。
- ProGuard / R8 规则校验:检测缺失 keep 规则、过宽规则和常见混淆错误。
- CI 集成:做一个 GitHub Action,在 PR 里自动回帖健康报告并跟踪分数趋势。
- Gradle Build Scan 集成:直接复用已有 build scan 数据,而不是重复解析文件。
- 自定义规则:允许团队通过配置文件定义自己的废弃模式和 manifest 策略。
仓库地址是 github.com/samuvelp/dr…,欢迎提 issue 和 PR。
术语表(本篇命中)
| 术语 | 英文 | 释义 |
|---|---|---|
| 状态机 | state machine | 用状态与边来描述流程节点和转移条件的执行模型 |
| 条件路由 | conditional routing | 按运行状态决定流程分支去向的路由机制 |
| 扇出 / 扇入 | fan-out / fan-in | 流程并行展开后再汇合的拓扑结构 |
| 版本目录 | version catalog | Gradle 中集中管理依赖与版本映射的 libs.versions.toml 机制 |
| 健康分 | health score | 依据多维指标计算出的项目健康度评分 |
| 确定性扫描 | deterministic scan | 相同输入稳定输出相同结果的规则化分析过程 |
| 离线模式 | offline mode | 不调用外部 LLM,仅使用本地规则扫描与报告生成 |