FranzKafka Blog https://blog.coderfan.org CODER LIKE A WRITER Wed, 11 Mar 2026 07:07:23 +0000 zh-Hans hourly 1 https://wordpress.org/?v=6.8.2 https://blog.coderfan.org/wp-content/uploads/2025/09/cropped-Generated-Image-September-05-2025-10_56PM-32x32.jpeg FranzKafka Blog https://blog.coderfan.org 32 32 基于OpenSpec的SDD Vibe Coding实践 https://blog.coderfan.org/%e5%9f%ba%e4%ba%8eopenspec%e7%9a%84sdd-vibe-coding%e5%ae%9e%e8%b7%b5.html?utm_source=rss&utm_medium=rss&utm_campaign=%25e5%259f%25ba%25e4%25ba%258eopenspec%25e7%259a%2584sdd-vibe-coding%25e5%25ae%259e%25e8%25b7%25b5 https://blog.coderfan.org/%e5%9f%ba%e4%ba%8eopenspec%e7%9a%84sdd-vibe-coding%e5%ae%9e%e8%b7%b5.html#respond Wed, 11 Mar 2026 07:07:13 +0000 https://blog.coderfan.org/?p=5564 随着大模型能力的发展,越来越多的AI IDE工具开始出现,Vibe Coding也逐渐变得流行起来。通过Vib […]

The post 基于OpenSpec的SDD Vibe Coding实践 first appeared on FranzKafka Blog.

]]>
Read Time:2 Minute, 8 Second

随着大模型能力的发展,越来越多的AI IDE工具开始出现,Vibe Coding也逐渐变得流行起来。通过Vibe Coding,普通人如设计师、产品经理也可以快速进行原型验证。对于开发者而言,各种编程语言、框架与生态的壁垒不再是阻碍,前端开发者借助Vibe Coding也可以快速完成后端部署,后端开发者也可以通过Vibe Coding写出精美的前端页面。

在我的早期实践中,我发现使用大模型进行编程解决项目中的小需求点还是非常优秀的,一旦项目复杂度上升,需求无法再通过简单的文本进行描述时,AI的表现就差强人意了。具体表现在以下几点:

  • 代码冗余,根本原因是AI本身没有模块化设计的思想,且不记得之前的实现,导致重复逻辑散落。
  • 意图偏移,在基于一个基本需求进行拓展衍生时,AI往往会在改动中将之前实现的功能改掉,原因在于AI在多轮的对话中无法将上下文进行高效的关联,导致经常顾此失彼。‘

业界现在提供的解决方案是SDD(Spec-Driven Development),即规格驱动开发。其中OpenSpec是一套开源的SDD规范,专门用于AI Agent编程。

本篇博客将记录如何基于OpenSpec进行编程实践。

基础配置

基础配置部分我们参考OpenSpec官方文档,文档地址可点击这里

1.首先确保系统中已配置Node.js,通过node –version确认其版本信息

已配置Node.js时,通过如下命令安装OpenSpec:

npm install -g @fission-ai/openspec@latest

安装完成后,通过openspec –version查看当前版本信息:

#查看版本信息
openspec  --version
#如下所示:
C:\Users\Administrator>openspec --version
0.17.2

以上就表明openspec已经安装完成。

2.安装完成后我们进入我们的项目目录,进入终端,执行如下命令:

openspec init

之后我们可以看到如下所示画面:

在这个阶段,openspec会要求你进行配置,选择需要配置的AI工具,如Claude Code、Cursor等,根据你的选择openspec会配置相应的命令行,并在当前的项目目录下创建openspec目录,同时会出现changes、specs子目录以及AGENTS.md和project.md两个文件。

在我的实践中我选择了Cursor作为我的AI IDE,其还会出现.cursor\commands目录,其目录内增加了openspec-apply.md,openspec-archive.md以及openspec-proposal.md。

同时我发现在Cursor Agent对话时我们可以使用/openspec自动补全命令行操作,可以方便快捷地完成相关任务。

需要说明的是,在openspec init完成后,我们需要重启我们的AI IDE才可以让配置生效。

这里有一个实用性的小技巧,在openspec生成的配置文件中,都是英文文档。这对我们中文用户而言不是很友好,但我们可以自行编辑openspec/AGENTS.md,让后续生成的内容为中文文档。

这里添加的内容如下:

## Language and Communication
* Default to Simplified Chinese for all OpenSpec artifacts and interaction, including `proposal.md`, `design.md`, `tasks.md`, and spec/spec-delta `spec.md` files.
* For tool compatibility, keep only OpenSpec structural tokens/headings in English: 
* Requirement/Scenario headings: `### Requirement:`, `#### Scenario:`
* Requirement section headings: `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`
* Scenario step keywords: `**GIVEN**`, `**WHEN**`, `**THEN**`, `**AND THEN**`
* Normative keyword: `SHALL` (recommended; write the rest in Chinese, e.g. `系统 SHALL ...`)
* Keep CLI commands, paths, and parameters as-is, and display them in code format (e.g., `openspec validate <id> --strict`).

工程使用

接下来就是将我们的工程实操环节,其实在我们openspec init完成之后,openspec就有给出相应的提示:

1. Populate your project context:
   "Please read openspec/project.md and help me fill it out
    with details about my project, tech stack, and conventions"

2. Create your first change proposal:
   "I want to add [YOUR FEATURE HERE]. Please create an
    OpenSpec change proposal for this feature"

3. Learn the OpenSpec workflow:
   "Please explain the OpenSpec workflow from openspec/AGENTS.md
    and how I should work with you on this project"

首先我们在Agent对话框内,输入please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions,openspec会提出相应的问题引导你进行初步的描述,我们进行初步的一些细化,如下所示:

这时我们需要在对话框内进行一个初步的需求描述,之后就会更新openspec/project.md,但此时其内部很多信息只是模板内容,需要根据我们实际的开发需求进行修改,这里我们手动修改project.md的内容。

之后我们引入我们的第一个提案,这里我们通过命令行/openspec-proposal命令进行创建。

在我的实践中,我想开发一个用于远程管理服务器的应用,该应用需要先实现服务器的添加和SSH的登录,并支持保存、编辑、删除服务器。这里我的/openspec-proposal 输入为实现添加服务器后进行SSH登录基础功能,并实现数据持久化。

在这个阶段,Openspec会在openspec/changes目录下增加新的子目录,并创建design.md、proposal.md以及tasks.md,针对我们的需求又自行细分了三个子需求,如下所示:

│  design.md
│  proposal.md
│  tasks.md
│
└─specs
    ├─data-persistence
    │      spec.md
    │
    ├─server-management
    │      spec.md
    │
    └─ssh-authentication
            spec.md

我们需要仔细查看这些生成的文件,按照自己的实际需要进行修改。

修改完成后,我们通过/openspec-apply PROPOSAL进行实现。如下所示:

第一次就生成了完整的代码,并完成了gradle相关配置,这里我注意到生成的配置使用的gradle版本为9.0,不出所然当我编译发生了一堆错误,后来我明确告诉Agent使用gradle版本8.7,问题才解决,之后可以实现正常编译。

需要说明的是,这期间并不会一帆风顺,你需要多次介入调试并且调整我们的design.md、proposal.md以及tasks.mk,以确保最终能够满足我们的设计要求。

当满足我们的需求之后,我们就可以通过/openspec-archive PROPOSAL进行归档了。

每一个Feature特性我们都可以按照上述的方式进行提案、实现以及归档。在归档阶段,openspec会进行两个动作:

1.将当前的proposal内容从changes的一级目录移动至changes/archive的二级目录,这里会按照日期进行归档

2.从当前提案中提取spec规范,放入specs目录

以上就完成了归档,我们可以开始新的提案创作了。

Update:在openspec的1.2.0版本中,提供了opsx-xxxx开头的commands以及openspec-xxxx开头的skills,opsx-xxxx是openspec提供的原生命令,而openspec-xxxx是作为skills提供给AI进行调用的。同时在openspec的新版本中,原生支持了多语言,无需进行额外设置就能进行中文对话。

Happy
Happy
0 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post 基于OpenSpec的SDD Vibe Coding实践 first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/%e5%9f%ba%e4%ba%8eopenspec%e7%9a%84sdd-vibe-coding%e5%ae%9e%e8%b7%b5.html/feed 0
Github Action中安全配置安卓证书签名 https://blog.coderfan.org/sign-configuration-for-release-android-application-in-github-action.html?utm_source=rss&utm_medium=rss&utm_campaign=sign-configuration-for-release-android-application-in-github-action https://blog.coderfan.org/sign-configuration-for-release-android-application-in-github-action.html#respond Tue, 13 Jan 2026 14:48:09 +0000 https://blog.coderfan.org/?p=5553 如何在Github Action中配置证书签名的安全实践

The post Github Action中安全配置安卓证书签名 first appeared on FranzKafka Blog.

]]>
Read Time:1 Minute, 21 Second

在开发VcServer的过程中,遇到一个比较实际的问题是:我希望在Github仓库中发布Release版本时实现应用签名。

在我的工程项目中,我原本是将KeyStore以及签名Key相关信息都放在build.gradle.kts文件中,通过signingConfigs配置字段进行配置。这部分配置信息原本都是明文的,该文件也会被Git记录从而上传到Github中。

在前期开发阶段,仓库本身是被设置为私有状态,这样开发也没什么大问题。但是如果需要将仓库公开,同时保护我们的签名信息,这样设置就会存在极大的安全风险。

比较合理的方式是将KeyStore、Key相关信息放入local.properties文件中,该文件通常是不会被Git记录的,在build.gradle中我们从local.properties文件中获取KeyStore与Key相关信息。

证书部分的配置如下所示,我们在build.gradle中通过signingConfigs配置字段进行配置:

signingConfigs {
	release {
		//从 local.properties 读取(用于本地开发)
		def keystorePropertiesFile = rootProject.file("local.properties")
		def keystoreProperties = new Properties()
		if (keystorePropertiesFile.exists()) {
			keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
		}
				
		// 环境变量优先级高于 local.properties
		def keystoreFilePath = System.getenv("RELEASE_STORE_FILE") ?: keystoreProperties['RELEASE_STORE_FILE'] ?: 'app/jks/release'
		def keystorePassword = System.getenv("RELEASE_STORE_PASSWORD") ?: keystoreProperties['RELEASE_STORE_PASSWORD'] ?: ''
		def keyAliasName = System.getenv("RELEASE_KEY_ALIAS") ?: keystoreProperties['RELEASE_KEY_ALIAS'] ?: ''
		def keyPasswordValue = System.getenv("RELEASE_KEY_PASSWORD") ?: keystoreProperties['RELEASE_KEY_PASSWORD'] ?: ''
				
		// 检查 keystore 文件是否存在,如果不存在则跳过签名配置(用于 CI/CD 中的 debug 构建)
		// 使用 rootProject.file() 确保路径相对于项目根目录,而不是 app 目录
		def keystoreFile = rootProject.file(keystoreFilePath)
		if (keystoreFile.exists() && keystorePassword && keyAliasName && keyPasswordValue) {
			storeFile keystoreFile
			storePassword keystorePassword
			keyAlias keyAliasName
			keyPassword keyPasswordValue
		} else {
			// 如果文件不存在或缺少配置,使用空配置(Gradle 会跳过签名验证)
			// 这允许在没有 keystore 的情况下构建(例如 debug 构建)
		}
	}
}

以上配置可以确保在本地顺利编译且不会透露签名证书相关信息。但重要的是,如何在Github Action中完成Release版本的编译。眼尖的读者应该发现,build.gradle中配置时有读取系统环境变量,所以我们还需要从环境变量中获取上述信息,并确保环境变量的优先级高于本地文件中读取的信息内容。在本地开发时,我们没有添加环境变量,这部分信息由本地local.properties文件提供,在Github发布Release时,则需要从环境变量中进行读取。

所以这里我们需要将签名相关信息如KeyStore、Key等配置到环境变量内。为了达成这一目标,我们需要通过Github Secrets 来实现,Github Secrets不会在Github Action中进行打印,且对外部不可见。

首先,我们进入我们在Github中所托管的仓库,点击settings->Secrets and variables->Actions->Repository secrets,如下所示:

这里我们添加了四个secrets,分别是RELEASE_KEYSOTRE_BASE64、RELEASE_SOTRE_PASSWORD、RELEASE_KEY_ALIAS、RELEASE_KEY_PASSWORD,分别对应Key文件、KeyStore密码、Key别名以及Key密码,其中Key文件内容是我们将我们本地的Key进行base64编码后生成的内容,在使用时我们需要将其还原为对应路径下的Key文件。

其次,在Github Action的配置文件中,我们需要将secrets中的上述内容设置为环境变量,如下所示:

  - name: Setup Keystore
      if: github.event.inputs.release_tag != ''
      env:
        KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
        KEYSTORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
        KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
        KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
      run:
          .....

以上就是如何在Github Action中实现安全配置以对安卓应用进行签名的全部内容。总结起来就是以下几点:

  • KeyStore、Key信息不能明文写入build.gradle,应写入local.properties文件中
  • KeyStore、Key信息以Github Secrets形式配置为环境变量,在Action中读取
  • Key文件内容通过base64进行编码,确保非明文形式存储

感兴趣的朋友可以参考我的项目VcServer中的配置。

Happy
Happy
0 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post Github Action中安全配置安卓证书签名 first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/sign-configuration-for-release-android-application-in-github-action.html/feed 0
Windows环境下通过WSL虚拟机刷写Jetson AGX Orin https://blog.coderfan.org/flash-jetson-agx-orin-in-windows-wsl.html?utm_source=rss&utm_medium=rss&utm_campaign=flash-jetson-agx-orin-in-windows-wsl https://blog.coderfan.org/flash-jetson-agx-orin-in-windows-wsl.html#respond Thu, 27 Nov 2025 08:27:50 +0000 https://blog.coderfan.org/?p=5517 最近开始接触NVIDIA Orin系列的芯片,团队买了一块NVIDIA Jetson AGX Orin 64G […]

The post Windows环境下通过WSL虚拟机刷写Jetson AGX Orin first appeared on FranzKafka Blog.

]]>
Read Time:1 Minute, 30 Second

最近开始接触NVIDIA Orin系列的芯片,团队买了一块NVIDIA Jetson AGX Orin 64GB的开发板。我基于这块儿板子来搭建基础的开发环境,以便后续让其他开发同事进行模型部署等开发。

开发板本身是预装了Ubuntu 20,Jetpack SDK版本为5.1,而最新的Jetpack已经到了7.0,Jetson官方提供的很多开发示例与Docker镜像都依赖于Jetpack 6.1+,所以我第一件事情就是要更新Jetpack SDK为6.0之后的版本。

Jetpack 6.0+对应的系统版本为Ubuntu 22,无法从Ubuntu 20直接通过apt的方式进行升级,唯一的办法只能通过刷机的办法进行升级。

官方介绍可以通过sdkmanager进行刷写,于是我开始研究如何通过sdkmanager进行刷机。同时由于公司电脑不让使用VMWare等虚拟机,只有WSL环境,所以我需要在WSL环境下通过sdkmanager来进行刷机。

这应该是全网第一篇也是目前唯一一篇介绍如何在WSL环境下对NVIDIA Jetson AGX Orin进行刷机的教程了,希望对大家会有帮助。

硬件连接

整个刷写过程其实只需要准备好Jetson AGX Orin的电源线以及连接Jetson AGX Orin设备和PC电脑的USB线束就可以了。

1.找到40PIN附近的type-c类型的USB口,正常工作模式下其作为USB转Uart的调试口,Recovery模式下作为刷写口。

2.连接电源,电源口在RJ45网口旁,分为针孔类型与type-c类型的电源口

以上就连接好啦。

基础配置

配置WSL环境,这里我们推荐用WSL2而非WSL1,Linux发行版为Ubuntu-22.04。具体配置过程可以参考我的另一篇博客。

当我们安装好WSL Ubuntu环境后,我们对WSL Ubuntu进行一些基础配置,如下所示:

sudo apt update && sudo apt install wslu -y
sudo apt install iputils-ping iproute2 netcat iptables dnsutils network-manager usbutils net-tools python3-yaml dosfstools libgetopt-complete-perl openssh-client binutils xxd cpio udev dmidecode -y
sudo apt install linux-tools-virtual hwdata

为了能让WSL环境下的Ubuntu可以访问到Windows宿主机下的USB设备,从而进行烧写,我们需要在Windows侧安装USBIPD,可以在Powershell中执行如下命令:

winget install --interactive --exact dorssel.usbipd-win

安装完USBIPD后,我们接下来要在WSL Ubuntu内安装sdkmananger,sdkmamager是NVIDIA官方推出的工具,可以端到端地帮助开发者设置Host Device与Target Device设备,以便加速开发。更多信息可以参考官方文档链接

这里我们采用Network Repo的方式进行安装:

wget https://developer.download.nvidia.com/compute/cuda/repos/[distro]/x86_64/cuda-keyring_1.1-1_all.deb
sudo dpkg -i cuda-keyring_1.1-1_all.deb
sudo apt-get update
sudo apt-get -y install sdkmanager

安装完成后我们可以通过sdkmanager命令行的形式进行交互。

刷写配置

现在我们开始进行软件刷写。

先对AGX Orin设备进行上电,并使其进入刷写模式,有两种办法让其进入刷写模式:

1.Orin处于未开机状态,此时长按住②键,再上电,白色指示灯亮起后即可

2.Orin处于开机状态,先长按②键,然后按下③键(Reset键);之后先松开③键,再松开②键

确认Windows Host可以访问到AGX硬件,这里我们在Powershell下通过usbipd进行查看:

usbipd.exe list

这里我们看到的0955:7023就是我们的AGX设备,接下来我们需要把USB设备暴露给WSL Ubuntu环境:

//usbipd.exe bind --busid <BUSID> --force,BUSID请依据实际情况进行选择
usbipd.exe bind --busid 1-8 --force
//usbipd.exe attach --wsl --busid=<BUSID> --auto-attach,BUSID请依据实际情况进行选择
usbipd.exe attach --wsl --busid 1-8 --auto-attach

接着我们打开WSL Ubuntu系统,查看是否有挂载USB设备:

这里显示挂载成功,可以进行刷写操作了。

由于我使用的是WSL Ubuntu,默认无桌面环境,这里我们通过cli模式进行刷写:

sdkmanager --cli

首先要选择登录类型,这里我们选择第一个就行。之后就是Host device(默认即可)、Target device(请根据设备类型进行选择)的选择,如下所示:

耐心等待刷写成功即可。这个过程期间会下载Host device与Target device相匹配的一些软件,对网络有一定要求。

SDK安装

Jetpack SDK严格来说分为三部分,Jetson Linux、Jetson AI SDK以及Jetson Platform Service,Jetson Linux包含BSP、Bootloader、驱动、Ubuntu桌面环境以及NVIDIA工具链等,我们上一个步骤更新的即是Jetson Linux的内容,这一部分是需要进行软件刷写的,这期间我们的开发板处于Recovery模式。

当我们更新完Jetson Linux,接下来就是更新Jetson AI SDK以及Jetson Platform Service,这部分更新也是通过sdkmanager完成,不过更新这部分软件时我们的开发板属于正常工作状态而非Recovery模式。

更新SDK时可以选择USB方式更新和Ethernet方式更新,这里推荐采用Ethernet方式更新,因为USB线缆接入后我的电脑貌似没法与其组成局域网。通过以太网方式更新,我可以让AGX Orin开发板连接至我电脑的热点,并获取到其对应的IP地址。

Happy
Happy
0 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post Windows环境下通过WSL虚拟机刷写Jetson AGX Orin first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/flash-jetson-agx-orin-in-windows-wsl.html/feed 0
Windows11系统配置WSL运行Ubuntu系统 https://blog.coderfan.org/windows11-configure-wsl.html?utm_source=rss&utm_medium=rss&utm_campaign=windows11-configure-wsl https://blog.coderfan.org/windows11-configure-wsl.html#respond Mon, 24 Nov 2025 08:22:19 +0000 https://blog.coderfan.org/?p=5513 环境配置 WSL是Wdinows Subsystem for Linux的简称,与VMWare等虚拟机不同的是 […]

The post Windows11系统配置WSL运行Ubuntu系统 first appeared on FranzKafka Blog.

]]>
Read Time:1 Minute, 32 Second

环境配置

WSL是Wdinows Subsystem for Linux的简称,与VMWare等虚拟机不同的是其没有硬件虚拟化的过程,而是直接在Windows系统中运行Linux内核。

在接触WSL之前我一直都是通过VMWare来创建虚拟机,从而在Windows上做Linux环境下的一些开发。最近在跟同事交流的过程中得知WSL现在也能很好地支撑Linux环境下的开发,于是有了此篇博客,以记录在Windows11环境下的WSL配置过程。

在Windows11环境下配置WSL,其实很简单。我们以管理员形式运行PowerShell,执行以下命令:

wsl  --install

该命令会下载、配置WSL环境,配置完成后需要重启系统才能生效。

重启之后会进入到系统配置步骤,会让用户配置Unix用户名与密码,此时我们按照自己的需要进行配置即可。

之后我们就可以在开始菜单栏中搜索Ubuntu,以应用的形式打开,就是WSL环境下的Ubuntu系统了,跟我们在VMWare内打开虚拟机是类似的。

默认情况下我们安装的Ubuntu系统位于C盘,这种情况是不利于我们开发的,任何挤占C盘资源空间的行为都应被规避,所以我们最好还是将其迁移到D盘中。这里我们先查看wsl中安装Linux发行版的具体版本信息:

wsl --list --verbose

得到的结果如下所示:

PS C:\Windows\system32> wsl --list --verbose
  NAME            STATE           VERSION
* Ubuntu-22.04    Running         1

拿到具体的发行版信息之后,我们可以将当前系统安装的镜像进行打包,并压缩成为tar包,如下所示。需要注意的是,在开始打包前请创建对应的文件夹(比如我这里在D盘下创建的wsl文件夹):

wsl --export Ubuntu-22.04 D:\wsl\ubuntu.tar

打包完成后,我们可以看到对应的目录下有一个ubuntu.tar压缩包,大小约为0.98G。这就是我们打包出来的Ubuntu镜像了。

接着我们需要在WSL环境中将之前安装的Ubuntu系统进行删除,如下所示:

wsl --unregister Ubuntu-22.04

删除完成后,我们将之前打包到D盘的Ubuntu系统重新进行导入,如下所示:

wsl --import Ubuntu D:\wsl\Ubuntu D:\WSL\ubuntu.tar

需要说明的是,上述步骤迁移的只是WSL环境下的Ubuntu系统,WSL自身相关的组件仍旧在C盘中。

在以上配置完成后,我们就可以随时进入Ubuntu系统进行开发了。但还是需要我们先通过任务菜单进入到Ubuntu系统后才能进行开发,而且频繁地在Ubuntu和本地Windows环境进行切换也是一件麻烦的事。有没有更简便的办法呢,答案是肯定的,那就是利用VSCode的WSL插件。

在安装WSL插件后,我们通过ctrl+shift+P,找到Connect to WSL连接我们本地的WSL环境,之后就可以正常进行开发了。这与我们通过Remote SSH连接远程服务器的开发体验是类似的,极大地方便了我们的开发。

目前使用WSL来进行Linux的开发还有点问题在于其对桌面环境的支持还不够完善,从而导致Linux环境下很多带GUI界面的应用无法正常运行。这与VMWare虚拟化出来的虚拟机相比还是比较影响使用体验的,不过对于大多数开发者而言,这已经是足够的了。

代理配置

为了让WSL环境下的Ubuntu也能通过Windows宿主机的v2rayN访问代理,方便进行apt更新、git拉取等,需要进行相关设置。

这里我们在WSL Ubuntu内直接添加名为proxysetup.sh的shell脚本,其内容如下:

# 添加http/https代理, apt代理, git代理
# hostip也可以在Windows环境下通过ipconfig进行查看
# 也可在wsl内通过ip route | grep default | awk '{print $3}命令查看
export hostip=$(ip route | grep default | awk '{print $3}')
# 端口请根据代理软件的实际配置进行设定,v2rayN http代理端口为10809
export hostport=10809
alias proxy='
    export https_proxy="http://${hostip}:${hostport}";
    export http_proxy="http://${hostip}:${hostport}";
    export all_proxy="http://${hostip}:${hostport}";
    git config --global http.proxy "http://${hostip}:${hostport}"
    git config --global https.proxy "http://${hostip}:${hostport}"
    echo -e "Acquire::http::Proxy \"http://${hostip}:${hostport}\";" | sudo tee -a /etc/apt/apt.conf.d/proxy.conf > /dev/null;
    echo -e "Acquire::https::Proxy \"http://${hostip}:${hostport}\";" | sudo tee -a /etc/apt/apt.conf.d/proxy.conf > /dev/null;
'
alias unproxy='
    unset https_proxy;
    unset http_proxy;
    unset all_proxy;
    git config --global --unset http.proxy
    git config --global --unset https.proxy
    sudo sed -i -e '/Acquire::http::Proxy/d' /etc/apt/apt.conf.d/proxy.conf;
    sudo sed -i -e '/Acquire::https::Proxy/d' /etc/apt/apt.conf.d/proxy.conf;
'

上述脚本完成后,我们还需要在v2rayN中开启局域网访问(WSL环境与Windows宿主机实际是局域网)。

通过source将相关命令加载到当前终端环境,后续可以通过在终端内执行proxy命令开启代理,通过unproxy命令关闭代理。

开启代理后可以通过curl -vvv www.google.com进行确认。

Happy
Happy
0 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post Windows11系统配置WSL运行Ubuntu系统 first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/windows11-configure-wsl.html/feed 0
反编译APK替换中文文案为其他语言 https://blog.coderfan.org/decompile-the-apk-and-replace-the-chinese-text-with-another-language.html?utm_source=rss&utm_medium=rss&utm_campaign=decompile-the-apk-and-replace-the-chinese-text-with-another-language https://blog.coderfan.org/decompile-the-apk-and-replace-the-chinese-text-with-another-language.html#respond Mon, 06 Oct 2025 03:21:06 +0000 https://blog.coderfan.org/?p=5493 反编译安卓应用以实现文案语言转换的工程实践。

The post 反编译APK替换中文文案为其他语言 first appeared on FranzKafka Blog.

]]>
Read Time:2 Minute, 48 Second

在近期某个项目中,接收到一个比较奇葩的需求,需要在不修改源码的情况下替换所有的文案为俄文。

接到该需求时,第一反应便是需要修改资源包,通常而言每个应用的所有文本内容都会放到res目录下对应的资源文件夹内,如下所示:

values-zh-rCN //中国大陆地区
values-zh-rHK //中国香港地区
values-zh-rTW //中国台湾地区

正向思维来看,我们需要新增对应地区的语言资源文件夹,将相应的文案进行对应翻译,放入该文件夹,之后重新进行编译打包,再进行签名,后续进行安装就能看到效果了。

以上建立在我们拥有源码工程及对应的签名文件的情况下,如果没有源码去实现这个需求,就只能通过反编译的手段来实现了。

此时我们需要借助apktool这个工具,apktool 是一个非常常用的Android 逆向工程工具,主要用于反编译 APK(Android 应用安装包),以便查看、修改、重新打包和调试应用资源及 Smali 代码。它常被用于分析 APK 的结构、修改界面文字、多语言适配、甚至研究某些应用的实现逻辑。

总的来说,要实现这个目的需要经历如下步骤:

1.首先确定需要进行文案替换的应用具体是哪一个,此时我们可以先打开该应用,使其位于前台,再通过如下命令确认位于前台的应用所对应的包名:

adb shell dumpsys activity activities | grep mResumedActivity

//获取当前出于Resume状态的应用
pissarropro:/ $ dumpsys activity activities | grep mResumedActivity
    mResumedActivity: ActivityRecord{dc6c83a u0 com.android.chrome/com.google.android.apps.chrome.Main t8188}
pissarropro:/ $

2.根据前述步骤拿到的包名,找到系统内对应的APK路径,通过ADB工具拉取该APK

//找到APK对应的路径
pissarropro:/ $ pm list packages -f | grep "com.android.chrome"
package:/data/app/~~rf1cFFBb7xWuaYGMDZ2S_w==/com.android.chrome-CarZF9ibWgDes2CEjGjyXA==/base.apk=com.android.chrome
pissarropro:/ $

//拉取该APK
adb pull /data/app/~~rf1cFFBb7xWuaYGMDZ2S_w==/com.android.chrome-CarZF9ibWgDes2CEjGjyXA==/base.apk

3.通过apk tool对拉取出来的APK进行反编译:

apktool d CarControl.apk -o OUTPUT

如果在执行上述命令的过程中出现如下错误:

Can't find framework resources for package of id: 2. You must install proper framework files

出现该错误的原因是因为APK本身依赖了一些系统资源,APKTool在解析这些资源时,找不到对应的framework资源文件,此时我们就需要从系统中找到这些资源文件并进行安装,以便APKTool进行解析。

通常而言,framework的资源文件位于framework-res.apk内,我们可以通过如下命令进行拉取:

adb pull /system/framework/framework-res.apk

对于不同的厂商而言,每个厂商还有自己定制的framework资源文件,这些资源文件也需要拉取:

//华为
adb pull /system/framework/hwext-res.apk
//小米
adb pull /system/framework/miui-framework-res.apk

拿到上述资源后,我们再进行安装:

apktool if framework-res.apk
apktool if framework-res-ext.apk

安装完成之后我们再进行反编译,得到如下内容:

AndroidManifest.xml
apktool.yml
assets/
original/
res/
unknown/

之后我们找到res/values-zh-rCN/strings.xml文件,将该文件内的中文内容进行翻译修改,直接将其修改为对应的语言文本即可。

修改完成之后,我们需要使用apktool重新进行打包,使用如下命令进行打包:

Apktool b OUTPUT -o  CarControl_New.apk

这样我们就得到了新的APK,接下来我们需要注意,如果直接重新安装应用,大概率是会安装失败的,因为我们并没有原始的签名,所以这里只能是将新生成的APK进行推入。之后我们重新启动应用,就可以看到效果了。

更进一步,我们来了解一下apktool的具体工作原理,apktool其实是一个工具的组合,可以对APK进行解包与打包操作。在解包操作中,apktool会进行如下步骤:

  1. 解压APK文件:将*.apk解压为文件结构,classe.dex、resources.arsc、AndroidManifest.xml以及res资源等
  2. 加载framework资源:如果应用依赖系统资源(@android: 前缀的),apktool 会加载之前通过 apktool if framework-res.apk 安装的系统资源包
  3. 反编译二进制资源:将二进制的 resources.arsc 和 AndroidManifest.xml 通过 aapt的逆过程转为可读 XML
  4. 反汇编dex字节码:使用 baksmali 将 classes.dex 转换为 .smali 文件
  5. 输出结果:最终生成一个结构化目录,可以直接编辑

在经过上述步骤后,我们可以拿到AndroidManifest.xml,修改权限申请、入口Activity等内容;同时也拿到了res资源文件,我们可以修改对应的资源文件,实现不同的文案显示、颜色样式等;更进一步我们可以直接修改smali文件,实现逻辑修改、去广告等操作。

后记

在解决该问题的过程中,除了apktool,我们还可以用APKEditor。相对于apktool,APKEditor更适合这种简单的文案修改工作,使用起来也会更得心应手。

除了使用这种反编译的方式修改APK资源,我们还可以用Overlay的方法实现该需求,这种方式更为合理,对系统的侵入修改会更小。对于Overlay的具体原理可以参考我的另一篇文章,此处不做过多赘述,这里只列举一下大概的流程:

1.新建APK,无需Activity、Service等内容,只需将values-zh-rCN/strings.xml内容放置于res目录

2.配置AndroidManifest.xml,内容可参考如下:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.seres.settings.overlay">

    <overlay
        android:targetPackage="com.android.settings"
        android:priority="999"
        android:isStatic="true" />
</manifest>

3.打包APK,无Root情况下需要同样的系统签名;Root情况下可以手动推入系统

4.使用adb shell cmd overlay list查看是否生效:

franzkafka@franzkafka:~$ adb shell cmd overlay list
com.android.uwb.resources
[x] com.android.uwb.resources.cuttlefish.overlay

com.android.connectivity.resources
[x] com.android.connectivity.resources.cuttlefish.overlay

android
[x] android.cuttlefish.overlay
[x] android.cuttlefish.phone.overlay
[ ] com.android.internal.display.cutout.emulation.corner
[ ] com.android.internal.display.cutout.emulation.double
[ ] com.android.internal.systemui.navbar.gestural_wide_back
[ ] com.android.internal.display.cutout.emulation.hole
[ ] com.android.internal.display.cutout.emulation.tall
[ ] com.android.internal.systemui.navbar.threebutton
[ ] com.android.internal.systemui.navbar.gestural_extra_wide_back
[ ] com.android.theme.font.notoserifsource
[ ] com.android.internal.display.cutout.emulation.waterfall
[ ] com.android.internal.systemui.navbar.transparent
[ ] com.android.role.notes.enabled
[ ] com.android.internal.systemui.navbar.gestural
[ ] com.android.internal.systemui.navbar.gestural_narrow_back
[x] com.android.systemui:neutral
[x] com.android.systemui:accent
[x] com.android.systemui:dynamic

com.android.nfc
[x] com.android.nfc.cuttlefish.overlay

com.android.providers.settings
[x] com.android.providers.settings.cuttlefish.overlay

com.android.wifi.resources
[x] com.android.wifi.resources.cf

com.android.networkstack.tethering
[x] com.android.networkstack.tethering.cuttlefishoverlay

com.android.systemui
[x] com.android.systemui.auto_generated_rro_vendor__
Happy
Happy
100 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post 反编译APK替换中文文案为其他语言 first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/decompile-the-apk-and-replace-the-chinese-text-with-another-language.html/feed 0
Android系统中实现GUI Agent要点解析 https://blog.coderfan.org/gui-agent-to-control-android-system-implemention.html?utm_source=rss&utm_medium=rss&utm_campaign=gui-agent-to-control-android-system-implemention https://blog.coderfan.org/gui-agent-to-control-android-system-implemention.html#respond Sun, 07 Sep 2025 14:33:58 +0000 https://blog.coderfan.org/?p=5384 安卓系统中实现GUI Agent的工程实践心得。

The post Android系统中实现GUI Agent要点解析 first appeared on FranzKafka Blog.

]]>
Read Time:3 Minute, 36 Second

随着大模型能力的增强,通过大模型来控制我们的安卓系统已经逐渐成为现实。基于大模型实现对安卓设备的控制,可以取代传统的基于规则脚本来控制安卓设备的方式,具有更好的性能表现、任务完成率以及更好的泛化性,极大地节省开发者与测试人员的时间。

当前比较成熟地基于大模型来控制安卓设备的项目包含由阿里开发的MobileAgent项目以及第三方个人的droidrun项目,前者基于MobileAgent的专用模型,具备端到端的能力。而后者则是利用OpenAI, Anthropic, Gemini等第三方大模型的能力,需要额外的前/后处理。当然,截止到我写这篇博客的时间,面壁智能推出的AgentCPM-GUI也已经开源。

在具体的实现上,两者也存在差距,阿里MobileAgent主要是基于ADB控制安卓设备,在使用时需要通过USB或者无线ADB的方式连接安卓设备与PC主机,从而进行设备控制;而后者主要是基于安卓系统提供的Accessibility API进行控制,需要安装特定的APK并开启相应的权限方能控制安卓设备。

无论哪种实现,我们都需要具备以下几种能力:

  • 屏幕元素获取:获取当前屏幕内容信息,以判断当前界面状态,为下一步决策提供输入
  • 模拟触控输入:当我们需要执行相应的动作时,需要进行菜单点击、滑动,或者触发软按键
  • 文本内容输入:文本输入属于基础的交互,涉及到搜索、聊天等场景时需要支持

每一种能力都服务于特定的目的,在具体落地时采用的方案也可多样。今天这篇文章将根据工作日常中接触到的内容讲讲如何实现。

屏幕元素获取

屏幕元素获取通常作为第一步,也是相当关键的一步。在具体落地实施上,以MobileAgent为例,通过ADB命令screencap直接截取当前屏幕的内容,这里的内容就是人眼可见的内容。尽管Android的图形绘制系统存在不同的窗口层级,但最终都会经过SurfaceFlinger到HWC进行合成送显,而screencap截取得内容实际上就是SurfaceFlinger中进行合成后的图像内容。无论是应用的Activity、SystemUI组件(状态栏、导航栏等)还是一个单独的dialog,都会进行合成显示。

除了使用ADB命令screencap,我们也可以使用Accessibility API来对安卓系统的屏幕视图信息进行获取,这里与screencap不同的是我们可以获取当前界面Layout下的XML布局信息,当然也可以进行截图,具体可参考如下代码:

//当接收到界面变化的事件时dump AccessibilityNodeInfo
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
   Log.i(TAG, "Event: ${event?.eventType}, ${event?.text}")
   // 当窗口内容变化时导出View树
   if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
            val rootNode = rootInActiveWindow
            if (rootNode != null) {
                exportViewTreeToXml(rootNode)
            }
   }
}

//序列化为Json
private fun exportViewTreeToXml(root: AccessibilityNodeInfo) {
        try {
            val file = File(filesDir, "view_tree.xml")
            val fos = FileOutputStream(file)
            val serializer = Xml.newSerializer()
            serializer.setOutput(fos, "UTF-8")
            serializer.startDocument("UTF-8", true)
            serializer.startTag(null, "ViewTree")
            dumpNodeToXml(root, serializer)
            serializer.endTag(null, "ViewTree")
            serializer.endDocument()
            fos.close()
            Log.i(TAG, "View tree exported to: ${file.absolutePath}")
        } catch (e: Exception) {
            Log.e(TAG, "Failed to export view tree", e)
        }
}
//按照不同字段进行dump
private fun dumpNodeToXml(node: AccessibilityNodeInfo, serializer: org.xmlpull.v1.XmlSerializer) {
        serializer.startTag(null, "Node")
        serializer.attribute(null, "class", node.className?.toString() ?: "")
        serializer.attribute(null, "text", node.text?.toString() ?: "")
        serializer.attribute(null, "contentDescription",    node.contentDescription?.toString() ?: "")
        serializer.attribute(null, "resourceId", node.viewIdResourceName ?: "")
        serializer.attribute(null, "clickable", node.isClickable.toString())
        serializer.attribute(null, "focusable", node.isFocusable.toString())
        serializer.attribute(null, "enabled", node.isEnabled.toString())
        // 递归遍历子节点
        for (i in 0 until node.childCount) {
            val child = node.getChild(i)
            if (child != null) {
                dumpNodeToXml(child, serializer)
                child.recycle()
            }
        }
        serializer.endTag(null, "Node")
}

上面的代码示例使用无障碍服务提供的能力对界面的Layout布局信息进行了dump,大模型可以分析这些数据后来理解安卓系统的界面元素。

除了获取Layout布局信息,我们也可以直接使用无障碍服务来进行截图,这里我们其实使用的是无障碍服务的接口——takeScreenshot,如下所示:

public void takeScreenshot (int displayId, 
                Executor executor, 
                AccessibilityService.TakeScreenshotCallback callback)

//截图实现
@RequiresApi(Build.VERSION_CODES.R)
fun takeScreenshotExample() {
        // 调用 takeScreenshot
        val screenshotCallback = object : AccessibilityService.ScreenshotCallback() {
            override fun onSuccess(result: ScreenshotResult) {
                val bitmap: Bitmap? = result.screenshot
                bitmap?.let { saveBitmap(it) }
                Log.d(TAG, "Screenshot success!")
            }

            override fun onFailure(errorCode: Int) {
                Log.e(TAG, "Screenshot failed, errorCode=$errorCode")
            }
        }

        // 延迟截图或者触发截图逻辑
        takeScreenshot(screenshotCallback, mainExecutor)
}

这里有两个注意点:

1.takeScreenshot只支持Android12及以上版本;

2.使用无障碍服务需要声明AccessibilityService_canTakeScreenshot

模拟触控输入

模拟触控输入其实就是模拟人类使用时的点击、滑动、拖动等,也包括软按键触摸。以MobileAgent的实现为例,模拟事件输入都采用ADB的方式完成。

点击:点击是指人类手指操作屏幕时短时间从按下到抬起的这么一个过程,在系统内核层这类事件以input event事件的形式进行上报,我们通过getevent -l命令进行查看,如下所示:

EV_ABS       ABS_MT_POSITION_X    00000320   # X = 800
EV_ABS       ABS_MT_POSITION_Y    000005dc   # Y = 1500
EV_KEY       BTN_TOUCH            DOWN
EV_SYN       SYN_REPORT           00000000
...
EV_KEY       BTN_TOUCH            UP
EV_SYN       SYN_REPORT           00000000

其中每一行的内容都依照type、code、value的形式进行显示。ABS_MT_POSITION_X与ABS_MT_POSITION_Y 代表触点的X,Y坐标,value即对应的取值。在通过ADB进行模拟时,我们同样需要这两个参数来确定我们具体点击的位置。这里我们通过input tap命令进行模拟:

#模拟点击:点击x坐标145,y坐标为569的位置
input tap 145 569

除了这类简单的点击,在实际操作过程中还存在滑动与拖拽这两种比较复杂的操作,通常发生在处理进度条、悬浮窗这样的界面中。这样类似的操作也可以通过ADB命令完成,如下所示:

#模拟滑动:从x坐标325,y坐标145的位置到x坐标325,y坐标为298的位置
input swipe 325 145 325  298
#模拟拖拽:从x坐标345,y坐标124的位置到x坐标348到y坐标459的位置
input draganddrop 345 124 348 459

除了ADB,我们也可以通过无障碍服务来实现这样的操作,此时我们需要借助无障碍服务提供的接口——dispatchGesture:

public final boolean dispatchGesture (GestureDescription gesture, 
                AccessibilityService.GestureResultCallback callback, 
                Handler handler)


//click点击
fun click(x: Float, y: Float) {
        val path = Path().apply {
            moveTo(x, y)
        }

        val gestureBuilder = GestureDescription.Builder()
        gestureBuilder.addStroke(
            GestureDescription.StrokeDescription(
                path,
                0,      // 开始时间(ms)
                100     // 持续时间(ms),越短越接近点击
            )
        )

        val gesture = gestureBuilder.build()
        dispatchGesture(gesture, null, null)
}

//swipe滑动
   fun swipe(x1: Float, y1: Float, x2: Float, y2: Float) {
    val path = Path().apply {
        moveTo(x1, y1)
        lineTo(x2, y2)
    }

    val gesture = GestureDescription.Builder()
        .addStroke(GestureDescription.StrokeDescription(path, 0, 500))
        .build()

    dispatchGesture(gesture, null, null)
}

在使用无障碍服务来执行模拟点击时,当前屏幕上正在被处理的点击行为都会被取消,不管该行为是来自于用户还是其他接入无障碍服务的服务。

需要注意的是,使用无障碍服务来模拟触控行为需要确保我们的服务接入无障碍服务且声明了AccessibilityService_canPerformGestures。

文本内容输入

在我们与安卓设备交互的过程中,文本输入是必不可少的环节。要实现GUI Agent也必然要实现对文本内容的输入。在具体的实现上,我们可以通过ADB也可以通过无障碍服务实现。

通过ADB来实现,安装系统本身支持input text命令,如果我们输入英文内容可以直接使用input text命令,但这种方式并不支持中文字符(Unicode编码),此时我们需要借助一个第三方的输入法:ADBKeyBoard,其原理是让使用者通过发送广播的方式来进行文本输入与编辑,使用方式如下所示:

adb shell am broadcast -a ADB_INPUT_TEXT --es msg '你好'

在使用之前我们需要安装ADBKeyBoard并切换输入法,具体操作步骤如下:

adb install ADBKeyboard.apk 
adb shell ime enable com.android.adbkeyboard/.AdbIME
adb shell ime set com.android.adbkeyboard/.AdbIME   

这种方式需要额外安装应用,如果我们更进一步,可以参考ADBKeyBoard的源码,在Agent服务内实现一套类似的,其原理也并不复杂,类似于实现一个简单的输入法。

如果我们不通过ADB方式来实现,亦可以通过无障碍服务来实现,此时也有两种办法:

1.使用AccessibilityNodeInfo.ACTION_SET_TEXT,可直接修改可编辑控件的文本,如下所示:

fun inputText(node: AccessibilityNodeInfo?, text: String) {
    if (node == null) return

    if (node.isEditable) {
        val args = Bundle()
        args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
        node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
    } else {
        // 遍历子节点
        for (i in 0 until node.childCount) {
            inputText(node.getChild(i), text)
        }
    }
}

2.使用ACTION_FOCUS + ACTION_PASTE,将剪切板的内容直接进行粘贴

fun pasteText(node: AccessibilityNodeInfo?, text: String) {
    if (node == null) return

    if (node.isEditable) {
        // 先聚焦
        node.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
        
        // 将文字放入剪贴板
        val clipboard = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
        val clip = android.content.ClipData.newPlainText("text", text)
        clipboard.setPrimaryClip(clip)

        // 粘贴
        node.performAction(AccessibilityNodeInfo.ACTION_PASTE)
    } else {
        for (i in 0 until node.childCount) {
            pasteText(node.getChild(i), text)
        }
    }
}

在实践过程中,我发现仅仅是实现文本输入可能还达不到目的,应为有些操作还是依赖输入法的,比如回车按键、搜索按键等,此时我们可以先执行点击输入框或者使用ACTION_FOCUS来调出键盘,在文本输入完成后再点击键盘完成完整操作。

这里也许有人会疑惑,为什么不按照人类的输入习惯直接操作键盘以拼音输入法的方式进行文本输入呢,事实上这种方式当然可以,不过其效率较低,实践中并不推荐。

以上就是在Android系统中实现GUI Agent的要点解析,希望能对你有所帮助。

Happy
Happy
100 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post Android系统中实现GUI Agent要点解析 first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/gui-agent-to-control-android-system-implemention.html/feed 0
自建Rustdesk服务全流程 https://blog.coderfan.org/self-host-rustdesk-deployment.html?utm_source=rss&utm_medium=rss&utm_campaign=self-host-rustdesk-deployment https://blog.coderfan.org/self-host-rustdesk-deployment.html#respond Fri, 25 Jul 2025 09:44:39 +0000 https://blog.coderfan.org/?p=5402 自建RustDesk全流程分享

The post 自建Rustdesk服务全流程 first appeared on FranzKafka Blog.

]]>
Read Time:1 Minute, 52 Second

作为开发,为了随时随地响应,远程桌面一直是个强需求。疫情期间用过TeamViewer、向日葵,后面又用ToDesk,用下来私以为ToDesk算是比较称手的。比较鸡贼的是免费版的ToDesk在使用一段时间后就会出现各种“连不上”的问题诱导用户进行付费。机缘巧合之下,我了解到Rustdesk。

Rustdesk 是一个开源的远程桌面软件,旨在提供一个快速、简单、且安全的远程访问和桌面共享解决方案。RustDesk 的设计目标是为用户提供类似于TeamViewerAnyDesk的远程桌面体验,但具有开源、隐私友好和自托管的优势。

由于Rustdesk开源且可以自托管,意味着用户可以完全掌控远程桌面的全过程。恰好我有独立的服务器,平常跑点小的服务,现在也可以用来部署Rustdesk了,从此再也不必为了远程桌面而付费。

本篇博客将介绍如何自托管Rustdesk以及如何使用其客户端。在开始自托管的具体操作之前,我们还是来了解一下Rustdesk的工作原理。

工作原理

Rustdesk仍旧采用了经典了C/S架构,在控制端与被控端都需要安装相应的客户端,客户端与客户端之间建立通信前都需要经过服务端。借用一张老图:

在服务端有三个比较重要的服务,分别是ID Server、Relay Sever以及API Server,上述图中只体现了ID Server与Relay Server。其各自的功能与作用如下:

ID Server:主要负责设备标识符(ID)分配与管理,它是客户端进行通信时的第一个中介。当客户端启动后,就会向ID Server注册其设备ID。设备ID应是唯一的,类似于我们每个人的身份证。当设备A想要连接设备B时,就会向ID Server发送设备B的ID请求,ID Server返回设备B的IP地址等信息,以便设备A进行连接。

Relay Server:主要负责中继数据,正常情况下设备A与设备B打洞成功后会直连,但是如果打洞连接失败,就会尝试通过Relay Server转发数据。Relay Sever可以帮助穿透防火墙、NAT等网络障碍。

一般情况下我们只会用到ID Server和Relay Server,但如果我们想在后台对客户端进行管理,或者在客户端进行用户登录等操作,就需要API Server。

API Server:用于处理与客户端相关的操作,通常用于实现后台服务、数据存储、身份验证等。

整体工作流程如下:

  • 设备A和设备B都启动了 RustDesk 客户端,并连接到公共服务器或自托管服务器。设备A通过输入设备B的ID与密码发起连接请求。
  • 公共服务器或自托管服务器会帮助设备A查找设备B的网络信息(如 IP 地址等),并建立与设备B的直接连接。
  • A与设备B成功打洞建立连接,此时Rustdesk会自动启动加密通道。设备B的屏幕图像会被捕获并传输到设备A,同时设备B也会接收到设备A的键盘等输入操作。

以上就是Rustdesk大体的工作原理啦。

部署流程

这里我将分别介绍两种部分,方案一是只有ID Server与Relay Server的,无法在后台对客户端进行管理,方案二则带有API Server,可以在后台对客户端进行管理。

针对方案一,可以参考如下流程:

wget https://raw.githubusercontent.com/techahold/rustdeskinstall/master/install.sh
chmod +x install.sh
./install.sh

需要注意的是在安装结束时会显示IP/DNS和密钥,并将它们分别插入客户端设置 > 网络 > ID/中继服务器的ID服务器密钥字段中就部署成功了。

针对方案二, 由于引入了API Server,增加了数据库等更为复杂的组件,推荐使用docker安装。这里我们推荐使用lejianwen/server-s6镜像运行。

# 安装 Docker
curl -fsSL https://get.docker.com | bash
# 安装 Docker Compose 插件(推荐新版)
sudo apt install docker-compose-plugin -y
# 在确认安装结束后,我们最好通过如下命令检查是否正常安装:
docker --version
docker compose version

#创建docker compose配置文件
mkdir -p ~/rustdesk-server && cd ~/rustdesk-server
nano docker-compose.yml

#将如下内容写入docker-compose.yml
networks:
   rustdesk-net:
     external: false
 services:
   rustdesk:
     ports:
       - 21114:21114
       - 21115:21115
       - 21116:21116
       - 21116:21116/udp
       - 21117:21117
       - 21118:21118
       - 21119:21119
     image: lejianwen/rustdesk-server-s6:latest
     environment:
       - RELAY=<relay_server[:port]>
       - ENCRYPTED_ONLY=1
       - MUST_LOGIN=N
       - TZ=Asia/Shanghai
       - RUSTDESK_API_RUSTDESK_ID_SERVER=<id_server[:21116]>
       - RUSTDESK_API_RUSTDESK_RELAY_SERVER=<relay_server[:21117]>
       - RUSTDESK_API_RUSTDESK_API_SERVER=http://<api_server[:21114]>
       - RUSTDESK_API_KEY_FILE=/data/id_ed25519.pub
       - RUSTDESK_API_JWT_KEY=xxxxxx # jwt key
     volumes:
       - /data/rustdesk/server:/data
       - /data/rustdesk/api:/app/data #将数据库挂载
     networks:
       - rustdesk-net
     restart: unless-stopped

#启动服务:
docker compose up -d
#停止服务:
docker compose down
#查看服务状态:
docker compose ps
#重启服务:
docker compose restart

以上就完成方案二的配置了。

客户端使用

Rustdesk提供了众多平台的客户端,包括Windows、MacOS、iOS、Android等,你可以从其官方Release中进行下载。

这里我们以Windows客户端使用为例,下载Windows客户端后,我们运行,可以看到这样一个界面:

我们可以看到自己的设备ID以及对应的一次性密码(截图中未显示是由于我个人关闭了一次性密码)。为了能够正常使用,我们需要配置好ID Server,点击ID旁边的⋮,进入设置:

在ID/中继服务器中我们设置如下:

如果是方式一部署的,ID服务器与Key都以安装结束时的显示的内容进行填入,如果是以方式二进行部署的,则根据docker-compose.yml配置的内容进行填入。

在配置完成之后,如果我需要连接另一个设备,我们需要知道另一个设备的ID和一次性密码,将ID填入控制远程桌面内进行连接,输入一次性密码即可。如果密码验证通过,就可以进行远程控制了。

服务端使用

如果我们按照方案二的方式搭建了API Server,我们就可以登录后台对客户端进行管理。服务端登录的地址与docker-compose.yml中配置的RUSTDESK_API_RUSTDESK_API_SERVER字段保持一致,初次登录时的用户名为admin,密码在安装完成时的命令行终端可见。登录后界面如下:

我们可以在用户信息中修改用户名进而密码,在我的设备中查看客户端相关信息,在系统中添加Oauth管理、用户管理等等,更多内容大家可以自行部署后查看。

同时服务端也集成了WebClient,这意味着我们可以通过服务端网页界面链接客户都安,只要能登录网页我们就能通过网页连接到设备并进行控制了。

Happy
Happy
100 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post 自建Rustdesk服务全流程 first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/self-host-rustdesk-deployment.html/feed 0
Android12 Input子系统解析 https://blog.coderfan.org/android12-input-event-dispatch-progress.html?utm_source=rss&utm_medium=rss&utm_campaign=android12-input-event-dispatch-progress https://blog.coderfan.org/android12-input-event-dispatch-progress.html#respond Mon, 28 Apr 2025 15:14:25 +0000 https://blog.coderfan.org/?p=4333 安卓系统Input读取与分发流程解析。

The post Android12 Input子系统解析 first appeared on FranzKafka Blog.

]]>
Read Time:18 Minute, 27 Second

大家有没有想过,为什么我们在安卓设备上点击屏幕,对应的菜单就会出现,屏幕上的按钮会出现相应的特效,这一过程到底是怎么实现的,这里就需要涉及到Input事件的分发与处理了。今天这篇文章将会针对这个问题进行分析。

Input概述

从第一段中我们引申出的问题,我们需要自下而上考虑这些问题:

1.系统Driver如何读取设备上的事件,这些设备有可能是鼠标、键盘、触摸屏等

2.从系统Driver到Native Framework如何传递,乃至分发事件

3.分发事件后如何匹配到对应的窗口

4.在多个Layer的情况下如何保证同一区域下对应Layer的处理

5.对应窗口对应Layer时,是如何具体处理Input的

6.Input事件与View的关联处理是如何实现的

首先我们先看一张图,其囊括了Android Input子系统的主体部分:

这里面涉及到的几个重要部分如下:

SystemServer:Android中整个Input子系统的Entry,由SystemServer拉起InputManager使得整个Input子系统开始运作。

InputManagerService:向下会接入com_android_server_input_InputManagerService,这部分属于是JNI接口。

NativeInputManager:属于 com_android_server_input_InputManagerService 的核心部分,源码位于frameworks/base/services/core/jni/com_android_server_InputManagerService.cpp,其核心实现为NativeInputManager类。

InputManager:属于Native C++层的管理入口

InputReader:由InputManager进行创建,将从Eventhub中获取到的事件(通过调用Eventhub中的getEvents)进行加工处理,并将数据传入InputDispatcher进行分发。

EventHub:用于管理/dev/input目录下的字符设备,通过epoll机制实现事件监听,并通过ioctl,read等系统调用获取input设备信息并获取input设备的具体事件。

这里我们摘录部分关键性代码,来串联整个调用链。

首先是整个Input系统的初始化:

//frameworks/base/services/java/com/android/server/SystemServer.java 
private void startOtherServices(@NonNull TimingsTraceAndSlog t) {
   t.traceBegin("startOtherServices");
   final Context context = mSystemContext;
   DynamicSystemService dynamicSystem = null;
   IStorageManager storageManager = null;
   NetworkManagementService networkManagement = null;
   IpSecService ipSecService = null;
   VpnManagerService vpnManager = null;
   VcnManagementService vcnManagement = null;
   NetworkStatsService networkStats = null;
   NetworkPolicyManagerService networkPolicy = null;
   NsdService serviceDiscovery = null;
   WindowManagerService wm = null;
   SerialService serial = null;
   NetworkTimeUpdateService networkTimeUpdater = null;
   InputManagerService inputManager = null;
   TelephonyRegistry telephonyRegistry = null;
   ConsumerIrService consumerIr = null;
   MmsServiceBroker mmsService = null;
   HardwarePropertiesManagerService hardwarePropertiesService = null;
   PacProxyService pacProxyService = null;
   ......
   //启动framwork Java InputManagerService
   t.traceBegin("StartInputManagerService");
   inputManager = new InputManagerService(context);
   t.traceEnd();
   .....
}

//frameworks/base/services/core/java/com/android/server/input/InputManagerService.java 
public InputManagerService(Context context) {
        this.mContext = context;
        this.mHandler = new InputManagerHandler(DisplayThread.get().getLooper());

        mStaticAssociations = loadStaticInputPortAssociations();
        mUseDevInputEventForAudioJack =
                
        context.getResources().getBoolean(R.bool.config_useDevInputEventForAudioJack);
        Slog.i(TAG, "Initializing input manager, mUseDevInputEventForAudioJack="
                + mUseDevInputEventForAudioJack);
       //通过nativeInit
        mPtr = nativeInit(this, mContext, mHandler.getLooper().getQueue());

        String doubleTouchGestureEnablePath = context.getResources().getString(
                R.string.config_doubleTouchGestureEnableFile);
        mDoubleTouchGestureEnableFile = 
        TextUtils.isEmpty(doubleTouchGestureEnablePath) ? null :
        new File(doubleTouchGestureEnablePath);

        LocalServices.addService(InputManagerInternal.class, new LocalService());
 }

//frameworks/base/services/core/jni/com_android_server_input_InputManagerService.cpp 
static jlong nativeInit(JNIEnv* env, jclass /* clazz */,
        jobject serviceObj, jobject contextObj, jobject messageQueueObj) {
    sp<MessageQueue> messageQueue = android_os_MessageQueue_getMessageQueue(env, messageQueueObj);
    if (messageQueue == nullptr) {
        jniThrowRuntimeException(env, "MessageQueue is not initialized.");
        return 0;
    }
    //通过new NativeInputManager对象进行native framework 初始化,从而进入到inputfinger
    NativeInputManager* im = new NativeInputManager(contextObj, serviceObj,
            messageQueue->getLooper());
    im->incStrong(0);
    return reinterpret_cast<jlong>(im);
}

//frameworks/base/services/core/jni/com_android_server_input_InputManagerService.cpp
NativeInputManager::NativeInputManager(jobject contextObj,
        jobject serviceObj, const sp<Looper>& looper) :
        mLooper(looper), mInteractive(true) {
    JNIEnv* env = jniEnv();

    mServiceObj = env->NewGlobalRef(serviceObj);

    {
        AutoMutex _l(mLock);
        mLocked.systemUiLightsOut = false;
        mLocked.pointerSpeed = 0;
        mLocked.pointerGesturesEnabled = true;
        mLocked.showTouches = false;
        mLocked.pointerDisplayId = ADISPLAY_ID_DEFAULT;
    }
    mInteractive = true;

    InputManager* im = new InputManager(this, this);
    mInputManager = im;
    defaultServiceManager()->addService(String16("inputflinger"), im);
}

//frameworks/native/services/inputflinger/InputManager.cpp
InputManager::InputManager(
        const sp<InputReaderPolicyInterface>& readerPolicy,
        const sp<InputDispatcherPolicyInterface>& dispatcherPolicy) {
    //创建InputDispatcher,该对象主要用于事件分发
    mDispatcher = createInputDispatcher(dispatcherPolicy);
    //
    mClassifier = new InputClassifier(mDispatcher);
    //创建InputReader,该对象主要用于事件读取
    mReader = createInputReader(readerPolicy, mClassifier);
}

//frameworks/native/services/inputflinger/reader/InputReaderFactory.cpp 
sp<InputReaderInterface> createInputReader(const sp<InputReaderPolicyInterface>& policy,
                                           const sp<InputListenerInterface>& listener) {
    return new InputReader(std::make_unique<EventHub>(), policy, listener);
}

//frameworks/native/services/inputflinger/reader/InputReader.cpp
InputReader::InputReader(std::shared_ptr<EventHubInterface> eventHub,
                         const sp<InputReaderPolicyInterface>& policy,
                         const sp<InputListenerInterface>& listener)
      : mContext(this),
        mEventHub(eventHub),
        mPolicy(policy),
        mGlobalMetaState(0),
        mLedMetaState(AMETA_NUM_LOCK_ON),
        mGeneration(1),
        mNextInputDeviceId(END_RESERVED_ID),
        mDisableVirtualKeysTimeout(LLONG_MIN),
        mNextTimeout(LLONG_MAX),
        mConfigurationChangesToRefresh(0) {
    mQueuedListener = new QueuedInputListener(listener);

    { // acquire lock
        std::scoped_lock _l(mLock);

        refreshConfigurationLocked(0);
        updateGlobalMetaStateLocked();
    } // release lock
}

//frameworks/native/services/inputflinger/reader/EventHub.cpp
EventHub::EventHub(void)
      : mBuiltInKeyboardId(NO_BUILT_IN_KEYBOARD),
        mNextDeviceId(1),
        mControllerNumbers(),
        mNeedToSendFinishedDeviceScan(false),
        mNeedToReopenDevices(false),
        mNeedToScanDevices(true),
        mPendingEventCount(0),
        mPendingEventIndex(0),
        mPendingINotify(false) {

       .....
}

初始化完成之后,通过一连串的start接口,开始Input子系统的运行

//frameworks/base/services/java/com/android/server/SystemServer.java 
private void startOtherServices(@NonNull TimingsTraceAndSlog t) {
  .....
  t.traceBegin("StartInputManager");
  inputManager.setWindowManagerCallbacks(wm.getInputManagerCallback());
  //通过start开始运行
  inputManager.start();
  t.traceEnd();
  .....
}
//frameworks/base/services/core/java/com/android/server/input/InputManagerService.java
public void start() {
        Slog.i(TAG, "Starting input manager");
        //通过nativeStart进行启动
        nativeStart(mPtr);

        // Add ourself to the Watchdog monitors.
        Watchdog.getInstance().addMonitor(this);

        registerPointerSpeedSettingObserver();
        registerShowTouchesSettingObserver();
        registerAccessibilityLargePointerSettingObserver();
        registerLongPressTimeoutObserver();
        registerMaximumObscuringOpacityForTouchSettingObserver();
        registerBlockUntrustedTouchesModeSettingObserver();

        mContext.registerReceiver(new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                updatePointerSpeedFromSettings();
                updateShowTouchesFromSettings();
                updateAccessibilityLargePointerFromSettings();
                updateDeepPressStatusFromSettings("user switched");
            }
        }, new IntentFilter(Intent.ACTION_USER_SWITCHED), null, mHandler);

        updatePointerSpeedFromSettings();
        updateShowTouchesFromSettings();
        updateAccessibilityLargePointerFromSettings();
        updateDeepPressStatusFromSettings("just booted");
        updateMaximumObscuringOpacityForTouchFromSettings();
        updateBlockUntrustedTouchesModeFromSettings();
}
//frameworks/base/services/core/jni/com_android_server_input_InputManagerService.cpp 
static void nativeStart(JNIEnv* env, jclass /* clazz */, jlong ptr) {
    NativeInputManager* im = reinterpret_cast<NativeInputManager*>(ptr);

    status_t result = im->getInputManager()->start();
    if (result) {
        jniThrowRuntimeException(env, "Input manager could not be started.");
    }
}
//frameworks/native/services/inputflinger/InputManager.cpp
status_t InputManager::start() {
    //开启dispatcher
    status_t result = mDispatcher->start();
    if (result) {
        ALOGE("Could not start InputDispatcher thread due to error %d.", result);
        return result;
    }
    //开启reader
    result = mReader->start();
    if (result) {
        ALOGE("Could not start InputReader due to error %d.", result);
        mDispatcher->stop();
        return result;
    }
    return OK;
}
//frameworks/native/services/inputflinger/dispatcher/InputDispatcher.cpp
status_t InputDispatcher::start() {
    if (mThread) {
        return ALREADY_EXISTS;
    }
    mThread = std::make_unique<InputThread>(
            "InputDispatcher", [this]() { dispatchOnce(); }, [this]() { mLooper->wake(); });
    return OK;
}
//frameworks/native/services/inputflinger/reader/InputReader.cpp
status_t InputReader::start() {
    if (mThread) {
        return ALREADY_EXISTS;
    }
    mThread = std::make_unique<InputThread>(
            "InputReader", [this]() { loopOnce(); }, [this]() { mEventHub->wake(); });
    return OK;
}

在我们自上而下了解了整个Input系统的初始化与运行流程后,接下来我们针对每个单独的模块来进行分析,去了解这里面更多的细节。

事件读取-InputReader

从名字上我们就能大概知道Eventhub的具体作用,这里我们先看看Eventhub的初始化过程:

//frameworks/native/services/inputflinger/reader/EventHub.cpp
EventHub::EventHub(void)
      : mBuiltInKeyboardId(NO_BUILT_IN_KEYBOARD),
        mNextDeviceId(1),
        mControllerNumbers(),
        mNeedToSendFinishedDeviceScan(false),
        mNeedToReopenDevices(false),
        mNeedToScanDevices(true),
        mPendingEventCount(0),
        mPendingEventIndex(0),
        mPendingINotify(false) {
    ensureProcessCanBlockSuspend();
    mEpollFd = epoll_create1(EPOLL_CLOEXEC);
    LOG_ALWAYS_FATAL_IF(mEpollFd < 0, "Could not create epoll instance: %s", strerror(errno));
    mINotifyFd = inotify_init();
    mInputWd = inotify_add_watch(mINotifyFd, DEVICE_PATH, IN_DELETE | IN_CREATE);
    LOG_ALWAYS_FATAL_IF(mInputWd < 0, "Could not register INotify for %s: %s", DEVICE_PATH,
                        strerror(errno));
    if (isV4lScanningEnabled()) {
        mVideoWd = inotify_add_watch(mINotifyFd, VIDEO_DEVICE_PATH, IN_DELETE | IN_CREATE);
        LOG_ALWAYS_FATAL_IF(mVideoWd < 0, "Could not register INotify for %s: %s",
                            VIDEO_DEVICE_PATH, strerror(errno));
    } else {
        mVideoWd = -1;
        ALOGI("Video device scanning disabled");
    }
    struct epoll_event eventItem = {};
    eventItem.events = EPOLLIN | EPOLLWAKEUP;
    eventItem.data.fd = mINotifyFd;
    int result = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, mINotifyFd, &eventItem);
    LOG_ALWAYS_FATAL_IF(result != 0, "Could not add INotify to epoll instance.  errno=%d", errno);
    int wakeFds[2];
    result = pipe(wakeFds);
    LOG_ALWAYS_FATAL_IF(result != 0, "Could not create wake pipe.  errno=%d", errno);
    mWakeReadPipeFd = wakeFds[0];
    mWakeWritePipeFd = wakeFds[1];
    result = fcntl(mWakeReadPipeFd, F_SETFL, O_NONBLOCK);
    LOG_ALWAYS_FATAL_IF(result != 0, "Could not make wake read pipe non-blocking.  errno=%d",
                        errno);
    result = fcntl(mWakeWritePipeFd, F_SETFL, O_NONBLOCK);
    LOG_ALWAYS_FATAL_IF(result != 0, "Could not make wake write pipe non-blocking.  errno=%d",
                        errno);
    eventItem.data.fd = mWakeReadPipeFd;
    result = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, mWakeReadPipeFd, &eventItem);
    LOG_ALWAYS_FATAL_IF(result != 0, "Could not add wake read pipe to epoll instance.  errno=%d",
                        errno);
}

在Eventhub初始化过程中,最重要的是利用了inotify与epoll两个系统调用。前者用于监听/dev/input下的input设备节点添加或者删除的变化,后者用于监听某个具体Input设备节点,具体可见代码:

//frameworks/native/services/inputflinger/reader/EventHub.cpp
EventHub::EventHub(void){
.....
    //通过inotify_init接口创建inotify实例,返回值为文件描述符
    mINotifyFd = inotify_init();
    //通过inotify_add_watch接口将/dev/input目录加入监听,返回值为另外的文件描述符,称为Watch 
    //descriptor,当存在input设备节点添加或删除时,在epoll中会监听到该信息
    mInputWd = inotify_add_watch(mINotifyFd, DEVICE_PATH, IN_DELETE | IN_CREATE);
    LOG_ALWAYS_FATAL_IF(mInputWd < 0, "Could not register INotify for %s: %s", DEVICE_PATH,strerror(errno));
    if (isV4lScanningEnabled()) {
        mVideoWd = inotify_add_watch(mINotifyFd, VIDEO_DEVICE_PATH, IN_DELETE | IN_CREATE);
        LOG_ALWAYS_FATAL_IF(mVideoWd < 0, "Could not register INotify for %s: %s",
                            VIDEO_DEVICE_PATH, strerror(errno));
    } else {
        mVideoWd = -1;
        ALOGI("Video device scanning disabled");
    }
......
}
//frameworks/native/services/inputflinger/reader/EventHub.cpp
status_t EventHub::readNotifyLocked() {
......
    ALOGV("EventHub::readNotify nfd: %d\n", mINotifyFd);
    //读取inotify event
    res = read(mINotifyFd, event_buf, sizeof(event_buf));
    if (res < (int)sizeof(*event)) {
        if (errno == EINTR) return 0;
        ALOGW("could not get event, %s\n", strerror(errno));
        return -1;
    }
    while (res >= (int)sizeof(*event)) {
        event = (struct inotify_event*)(event_buf + event_pos);
        if (event->len) {
            //如果inotify event中所携带的watch descriptor为对应的input设备目录
            //则进行CREATE 或 DELETE的判断
            if (event->wd == mInputWd) {
                std::string filename = std::string(DEVICE_PATH) + "/" + event->name;
                //如果是新建设备节点,进行处理
                if (event->mask & IN_CREATE) {
                    openDeviceLocked(filename);
                } else {
                    ALOGI("Removing device '%s' due to inotify event\n", filename.c_str());
                    closeDeviceByPathLocked(filename);
                }
}
......
}

在上面的代码中我们可以看到,当/dev/input目录下创建了新的设备节点时,将会进入openDeviceLocked函数,在该函数内会通过ioctl接口完成设备信息的读取:

void EventHub::openDeviceLocked(const std::string& devicePath) {
//open系统调用
int fd = open(devicePath.c_str(), O_RDWR | O_CLOEXEC | O_NONBLOCK);
.....
// Get device name.获取设备名称
char buffer[80];
ioctl(fd, EVIOCGNAME(sizeof(buffer) - 1), &buffer)
// Get device driver version.获取驱动版本
int driverVersion;
ioctl(fd, EVIOCGVERSION, &driverVersion)
// Get device identifier.获取设备标识
struct input_id inputId;
ioctl(fd, EVIOCGID, &inputId)
// Get device physical location.
ioctl(fd, EVIOCGPHYS(sizeof(buffer) - 1), &buffer)
// Get device unique id.
ioctl(fd, EVIOCGUNIQ(sizeof(buffer) - 1), &buffer)
// Load the configuration file for the device.加载Input设备配置
device->loadConfigurationLocked();
.....
device->configureFd();
.....
addDeviceLocked(std::move(device));
}

这里面比较重要的一些步骤包括Input设备的配置文件加载解析,针对特定设备的一些设置以及设备加载完成后的处理,重点包括loadConfigurationLocked函数、configureFd函数和addDeviceLocked函数,其源码如下:

//frameworks/native/services/inputflinger/reader/EventHub.cpp
void EventHub::Device::loadConfigurationLocked()
{
   configurationFile=getInputDeviceConfigurationFilePathByDeviceIdentifier(identifier,
                                                                       InputDeviceConfigurationFileType::
                                                                             CONFIGURATION);
    if (configurationFile.empty())
    {
        ALOGD("No input device configuration file found for device '%s'.", identifier.name.c_str());
    }
    else
    {
        android::base::Result<std::unique_ptr<PropertyMap>> propertyMap =
                     PropertyMap::load(configurationFile.c_str());
        if (!propertyMap.ok())
        {
            ALOGE("Error loading input device configuration file for device '%s'.  "
                  "Using default configuration.",
                  identifier.name.c_str());
        }
        else
        {
            configuration = std::move(*propertyMap);
        }
    }
}
//frameworks/native/libs/input/InputDevice.cpp 
std::string getInputDeviceConfigurationFilePathByDeviceIdentifier(
        const InputDeviceIdentifier& deviceIdentifier,
        InputDeviceConfigurationFileType type) {
    if (deviceIdentifier.vendor !=0 && deviceIdentifier.product != 0) {
        if (deviceIdentifier.version != 0) {
            // Try vendor product version.
            std::string versionPath = getInputDeviceConfigurationFilePathByName(
                    StringPrintf("Vendor_%04x_Product_%04x_Version_%04x",
                            deviceIdentifier.vendor, deviceIdentifier.product,
                            deviceIdentifier.version),
                    type);
            if (!versionPath.empty()) {
                return versionPath;
            }
        }

        // Try vendor product.
        std::string productPath = getInputDeviceConfigurationFilePathByName(
                StringPrintf("Vendor_%04x_Product_%04x",
                        deviceIdentifier.vendor, deviceIdentifier.product),
                type);
        if (!productPath.empty()) {
            return productPath;
        }
    }

    // Try device name.
    return getInputDeviceConfigurationFilePathByName(deviceIdentifier.getCanonicalName(), type);
}

//针对Keyboard键盘,设置repeatRate;针对Sensor设备,设置Timestamp的时钟源
void EventHub::Device::configureFd() {                                                                                                                                                                      
    // Set fd parameters with ioctl, such as key repeat, suspend block, and clock type
    if (classes.test(InputDeviceClass::KEYBOARD)) {
        // Disable kernel key repeat since we handle it ourselves
        unsigned int repeatRate[] = {0, 0};
        if (ioctl(fd, EVIOCSREP, repeatRate)) {
            ALOGW("Unable to disable kernel key repeat for %s: %s", path.c_str(), strerror(errno));
        }    
    }    

    // Tell the kernel that we want to use the monotonic clock for reporting 
    // timestamps
    // associated with input events.  This is important because the input system
    // uses the timestamps extensively and assumes they were recorded using the 
    // monotonic clock.
    int clockId = CLOCK_MONOTONIC;
    if (classes.test(InputDeviceClass::SENSOR)) {
        // Each new sensor event should use the same time base as
        // SystemClock.elapsedRealtimeNanos().
        clockId = CLOCK_BOOTTIME;
    }    
    bool usingClockIoctl = !ioctl(fd, EVIOCSCLOCKID, &clockId);
    ALOGI("usingClockIoctl=%s", toString(usingClockIoctl));
}

在Input设备的配置文件加载过程中,首先会在系统目录/system/product、/system/system_ext、/system/odm与/system/vendor下进行查找,查找时依据FILE_DIR+device_name+FILE_EXTENSION这样的形式进行,其中FILE_DIR定义次级目录的名称,其可选值为idc、keylayouts、keychars,相应的FILE_EXTENSION定义配置文件后缀名,其可选值为.idc、.kl、.kcm。这三种类型的配置文件,适用于不同类型的input设备:

*.kl:按键布局配置,用于将Linux 按键代码和坐标轴代码映射到 Android系统;通俗点来讲,就好比我们把键盘上的每一个按键都进行一个编号,该编号都有一个值与之对应,用户态拿到这个编号,也就知道了对应按下哪个键,由此完成后续处理。Android系统会在/odm/usr/keylayout、/vendor/usr/keylayout、/system/usr/keylayout、/data/system/devices/keylayout内进行查找,系统默认的配置文件位于 /system/usr/keylayout 内,如下所示:

trout_x86:/system/usr/keylayout # ls -la
total 692
drwxr-xr-x 2 root root 8192 2009-01-01 08:00 .
drwxr-xr-x 7 root root 4096 2009-01-01 08:00 ..
-rw-r--r-- 1 root root  811 2009-01-01 08:00 AVRCP.kl
-rw-r--r-- 1 root root 9453 2009-01-01 08:00 Generic.kl
-rw-r--r-- 1 root root  810 2009-01-01 08:00 Vendor_0079_Product_0011.kl
-rw-r--r-- 1 root root 1644 2009-01-01 08:00 Vendor_0079_Product_18d4.kl
-rw-r--r-- 1 root root 1645 2009-01-01 08:00 Vendor_044f_Product_b326.kl
-rw-r--r-- 1 root root 1543 2009-01-01 08:00 Vendor_045e_Product_028e.kl
-rw-r--r-- 1 root root 1644 2009-01-01 08:00 Vendor_045e_Product_028f.kl
-rw-r--r-- 1 root root 1548 2009-01-01 08:00 Vendor_045e_Product_02a1.kl
-rw-r--r-- 1 root root 1568 2009-01-01 08:00 Vendor_045e_Product_02d1.kl
-rw-r--r-- 1 root root 1568 2009-01-01 08:00 Vendor_045e_Product_02dd.kl
-rw-r--r-- 1 root root 1402 2009-01-01 08:00 Vendor_045e_Product_02e0.kl

其中General.kl是通用的,我们可以看看文件内的具体内容:

# Copyright (C) 2010 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

#
# Generic key layout file for full alphabetic US English PC style external keyboards.
#
# This file is intentionally very generic and is intended to support a broad range of keyboards.
# Do not edit the generic key layout to support a specific keyboard; instead, create
# a new key layout file with the required keyboard configuration.
#

key 1     ESCAPE
key 2     1
key 3     2
key 4     3
key 5     4
key 6     5
key 7     6
key 8     7
key 9     8
key 10    9
key 11    0
key 12    MINUS
key 13    EQUALS
key 14    DEL
key 15    TAB

更多信息可以参考该链接

*.kcm:按键字符映射配置,用于将Android的按键键值(包含辅助键)映射为Unicode字符。也就是当你在键盘上按压某个键时,你在屏幕上所看到的具体字符(是A还是B,是A还是a等),Android12默认的字符映射配置位于/system/usr/keychars内,如下所示:

trout_x86:/system/usr/keychars $ 
trout_x86:/system/usr/keychars $ ls -la
total 88
drwxr-xr-x 2 root root  4096 2009-01-01 08:00 .
drwxr-xr-x 7 root root  4096 2009-01-01 08:00 ..
-rw-r--r-- 1 root root 15891 2009-01-01 08:00 Generic.kcm
-rw-r--r-- 1 root root  1234 2009-01-01 08:00 Vendor_18d1_Product_0200.kcm
-rw-r--r-- 1 root root  8318 2009-01-01 08:00 Vendor_18d1_Product_5018.kcm
-rw-r--r-- 1 root root 15666 2009-01-01 08:00 Virtual.kcm
-rw-r--r-- 1 root root 15840 2009-01-01 08:00 qwerty.kcm
-rw-r--r-- 1 root root 15959 2009-01-01 08:00 qwerty2.kcm
trout_x86:/system/usr/keychars $ 

其中Generic.kcm用于标准外部键盘的字符映射,Virtual.kcm用于虚拟键盘的字符映射。以Vendor开头的则属于硬件厂商所生产的键盘字符映射。我们可以打开看看文件内的具体内容:

# Copyright (C) 2010 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

#
# Key character map for a built-in generic virtual keyboard primarily used
# for instrumentation and testing purposes.
#

type FULL

### Basic QWERTY keys ###

key A {
    label:                              'A'
    base:                               'a'
    shift, capslock:                    'A'
    shift+capslock:                     'a'
}

key B {
    label:                              'B'
    base:                               'b'
    shift, capslock:                    'B'
    shift+capslock:                     'b'
}

key C {
    label:                              'C'
    base:                               'c'
    shift, capslock:                    'C'
    alt:                                '\u00e7'
    shift+alt:                          '\u00c7'
    shift+capslock:                     'c'
}

更多信息可参考链接

*.idc:输入设备配置文件,包含设备专用的属性配置,这些通常是一些输入设备所特有的、非通用的配置,Android12中我们可以在/system/usr/idc目录下找到相关配置,如下所示:

trout_x86:/system/usr/idc $ ls -la
total 36
drwxr-xr-x 2 root root 4096 2009-01-01 08:00 .
drwxr-xr-x 7 root root 4096 2009-01-01 08:00 ..
-rw-r--r-- 1 root root  634 2009-01-01 08:00 AVRCP.idc
-rw-r--r-- 1 root root 1396 2009-01-01 08:00 Vendor_054c_Product_05c4.idc
-rw-r--r-- 1 root root 1396 2009-01-01 08:00 Vendor_054c_Product_09cc.idc
-rw-r--r-- 1 root root  782 2009-01-01 08:00 Vendor_0957_Product_0001.idc
-rw-r--r-- 1 root root  916 2009-01-01 08:00 Vendor_248a_Product_8266.idc
-rw-r--r-- 1 root root  869 2009-01-01 08:00 qwerty.idc
-rw-r--r-- 1 root root  870 2009-01-01 08:00 qwerty2.idc
trout_x86:/system/usr/idc $ 

以上是监听设备挂载、加载设备配置与内部实例化的过程。接下来我们看看事件读取的具体过程。事件的读取入口位于Eventhub.cpp的getEvents函数,这也是Eventhub中最为核心最为重要的函数,鉴于该函数比较复杂,我们将其分为几部分逐一进行分析。

1.首先是判断是否存在设备添加、移除等事件,如存在,则将这些事件上报至InputReader:

size_t EventHub::getEvents(int timeoutMillis, RawEvent* buffer, size_t bufferSize){
    .....
    // 存在设备被关闭,进行上报, event->type = DEVICE_REMOVED
    for (auto it = mClosingDevices.begin(); it != mClosingDevices.end();)
    {
        std::unique_ptr<Device> device = std::move(*it);
        ALOGV("Reporting device closed: id=%d, name=%s\n", device->id, device->path.c_str());
        event->when = now;
        event->deviceId = (device->id == mBuiltInKeyboardId) ? ReservedInputDeviceId::BUILT_IN_KEYBOARD_ID : device->id;
        event->type = DEVICE_REMOVED;
        event += 1;
        it = mClosingDevices.erase(it);
        mNeedToSendFinishedDeviceScan = true;
        if (--capacity == 0)
        {
                break;
         }
        }
        if (mNeedToScanDevices)
        {
            mNeedToScanDevices = false;
            scanDevicesLocked();
            mNeedToSendFinishedDeviceScan = true;
        }
        //存在设备被打开,进行上报,event->type = DEVICE_ADDED;
        while (!mOpeningDevices.empty())
        {
            std::unique_ptr<Device> device = std::move(*mOpeningDevices.rbegin());
            mOpeningDevices.pop_back();
            ALOGV("Reporting device opened: id=%d, name=%s\n", device->id, device->path.c_str());
            event->when = now;
            event->deviceId = device->id == mBuiltInKeyboardId ? 0 : device->id;
            event->type = DEVICE_ADDED;
            event += 1;
            // Try to find a matching video device by comparing device names
            for (auto it = mUnattachedVideoDevices.begin(); it != mUnattachedVideoDevices.end();
                 it++)
            {
                std::unique_ptr<TouchVideoDevice>& videoDevice = *it;
                if (tryAddVideoDeviceLocked(*device, videoDevice))
                {
                    // videoDevice was transferred to 'device'
                    it = mUnattachedVideoDevices.erase(it);
                    break;
                }
            }
            auto [dev_it, inserted] = mDevices.insert_or_assign(device->id, std::move(device));
            if (!inserted)
            {
                ALOGW("Device id %d exists, replaced.", device->id);
            }
            mNeedToSendFinishedDeviceScan = true;
            if (--capacity == 0)
            {
                break;
            }
        }
        //设备扫描结束,进行上报,event->type = FINISHED_DEVICE_SCAN;
        if (mNeedToSendFinishedDeviceScan)
        {
            mNeedToSendFinishedDeviceScan = false;
            event->when = now;
            event->type = FINISHED_DEVICE_SCAN;
            event += 1;
            if (--capacity == 0)
            {
                break;
            }
        }
        .....
}

这部分逻辑是因为我们在读取Event事件的时候随时会发生设备关闭、拔掉、出现异常或者新增设备等情况,所以当存在这些情况时我们需要优先进行处理,及时更新设备列表,才能保障后续的Input event读取。

2.正常读取内核上报的Input事件

   if (eventItem.events & EPOLLIN) {
....
       const int32_t deviceId = device->id == mBuiltInKeyboardId ? 0 : device->id;
       const size_t count = size_t(readSize) / sizeof(struct input_event);
       for (size_t i = 0; i < count; i++) {
                        struct input_event& iev = readBuffer[i];
                        device->trackInputEvent(iev);
                        events.push_back({
                                .when = processEventTimestamp(iev),
                                .readTime = systemTime(SYSTEM_TIME_MONOTONIC),
                                .deviceId = deviceId,
                                .type = iev.type,
                                .code = iev.code,
                                .value = iev.value,
                        });
                    }
                    if (events.size() >= EVENT_BUFFER_SIZE) {
                        // The result buffer is full.  Reset the pending event index
                        // so we will try to read the device again on the next iteration.
                        mPendingEventIndex -= 1;
                        break;
                    }
}
.....

这里会将从内核中读取到的事件存储在Vector内,并返回该Vector,跟随着调用链路,我们可以看到该方法在InputReader类中的loopOnce方法中被调用,获取到这些输入事件后,进入processEventsLocked方法内。

void InputReader::loopOnce() {
...
std::vector<RawEvent> events = mEventHub->getEvents(timeoutMillis);

  { // acquire lock
        std::scoped_lock _l(mLock);
        mReaderIsAliveCondition.notify_all();

        if (!events.empty()) {
            mPendingArgs += processEventsLocked(events.data(), events.size());
        }
  }
...
}

在后续的处理中,会跳转到InputDevice类中的process方法,在该方法中会通过InputMapper传递到各个子Mapper中,这里的每一个子Mapper都对应一个特定的输入设备,如键盘Keyboard(KeyboardInputMapper)、操纵杆Joystick(JoystickInputMapper)、Cursor(CursorInputMapper)等,以键盘输入为例,我们来看看针对键盘的输入具体是如何处理的:

//frameworks/native/services/inputflinger/reader/mapper/KeyboardInputMapper.cpp
std::list<NotifyArgs> KeyboardInputMapper::process(const RawEvent& rawEvent) {
...
           if (isSupportedScanCode(scanCode)) {
                out += processKey(rawEvent.when, rawEvent.readTime, rawEvent.value != 0, scanCode,
                                  mHidUsageAccumulator.consumeCurrentHidUsage());
            }
....
}

//

实际上每个子Mapper都会依照输入设备的特性对原始的Events改写一些数据,后续会通过notify方法将改写后的数据传递到InputDispatcher中,这一步又是如何实现的呢。

事件分发-InputDispatcher

首先我们来看看事件是如何从InputReader传递到InputDispatcher中的,其实就是在创建InputReader时将InputDispatcher的Listener传入,当接收到Input事件时即通过Listener进行后续处理,具体代码如下:

InputManager::InputManager(const sp<InputReaderPolicyInterface>& readerPolicy,
                           InputDispatcherPolicyInterface& dispatcherPolicy,
                           PointerChoreographerPolicyInterface& choreographerPolicy,
                           InputFilterPolicyInterface& inputFilterPolicy) {

    mDispatcher = createInputDispatcher(dispatcherPolicy);
    mTracingStages.emplace_back(
            std::make_unique<TracedInputListener>("InputDispatcher", *mDispatcher));
    .....
    mReader = createInputReader(readerPolicy, *mTracingStages.back());
}

这里TracedInputListener继承于InputListenerInterface,InputListenerInterface用于InputReader传递事件至InputDispatcher:

/*
 * The interface used by the InputReader to notify the InputListener about input events.
 */
class InputListenerInterface {
public:
    InputListenerInterface() { }
    InputListenerInterface(const InputListenerInterface&) = delete;
    InputListenerInterface& operator=(const InputListenerInterface&) = delete;
    virtual ~InputListenerInterface() { }

    virtual void notifyInputDevicesChanged(const NotifyInputDevicesChangedArgs& args) = 0;
    virtual void notifyKey(const NotifyKeyArgs& args) = 0;
    virtual void notifyMotion(const NotifyMotionArgs& args) = 0;
    virtual void notifySwitch(const NotifySwitchArgs& args) = 0;
    virtual void notifySensor(const NotifySensorArgs& args) = 0;
    virtual void notifyVibratorState(const NotifyVibratorStateArgs& args) = 0;
    virtual void notifyDeviceReset(const NotifyDeviceResetArgs& args) = 0;
    virtual void notifyPointerCaptureChanged(const NotifyPointerCaptureChangedArgs& args) = 0;

    void notify(const NotifyArgs& args);
};

那么具体是在什么地方进行传递的呢,这里我们回到InputReader类的loopOnce方法:

void InputReader::loopOnce() {
    .....
    // Flush queued events out to the listener.
    // This must happen outside of the lock because the listener could potentially call
    // back into the InputReader's methods, such as getScanCodeState, or become blocked
    // on another thread similarly waiting to acquire the InputReader lock thereby
    // resulting in a deadlock.  This situation is actually quite plausible because the
    // listener is actually the input dispatcher, which calls into the window manager,
    // which occasionally calls into the input reader.
    for (const NotifyArgs& args : notifyArgs) {
        mNextListener.notify(args);
    }
    ....

}

这里的mNextListener也就是InputDispatcher注册进来的TracedInputListener对象,通过notify方法进行Event分发:

void InputListenerInterface::notify(const NotifyArgs& generalArgs) {
    Visitor v{
            [&](const NotifyInputDevicesChangedArgs& args) { notifyInputDevicesChanged(args); },
            [&](const NotifyKeyArgs& args) { notifyKey(args); },
            [&](const NotifyMotionArgs& args) { notifyMotion(args); },
            [&](const NotifySwitchArgs& args) { notifySwitch(args); },
            [&](const NotifySensorArgs& args) { notifySensor(args); },
            [&](const NotifyVibratorStateArgs& args) { notifyVibratorState(args); },
            [&](const NotifyDeviceResetArgs& args) { notifyDeviceReset(args); },
            [&](const NotifyPointerCaptureChangedArgs& args) { notifyPointerCaptureChanged(args); },
    };
    std::visit(v, generalArgs);
}

这里实际会根据Event的类型不同进入到不同的notify子方法中,以Key事件为例子,最终我们进入到InputDispatcher中的notifyKey方法中:

void InputDispatcher::notifyKey(const NotifyKeyArgs& args) {
...
  needWake = enqueueInboundEventLocked(std::move(newEntry));
....

}

这里会将Input事件塞入队列,之后唤醒looper,通过dispatchOnce进行分发:

void InputDispatcher::dispatchOnce() {
...
        // Run a dispatch loop if there are no pending commands.
        // The dispatch loop might enqueue commands to run afterwards.
        if (!haveCommandsLocked()) {
            dispatchOnceInnerLocked(/*byref*/ nextWakeupTime);
        }
....
}

之后会进入dispatchOnceInnerLocked方法,在这其中会根据Event类型不同进行分发,这里的Event类型包含DEVICE_RESET、FOCUS、TOUCH_MODE_CHANGED、POINTER_CAPTURE_CHANGED、KEY等等,这里我们还是以Key继续往下看:

case EventEntry::Type::KEY: {
....
      std::shared_ptr<const KeyEntry> keyEntry =
                    std::static_pointer_cast<const KeyEntry>(mPendingEvent);
      if (dropReason == DropReason::NOT_DROPPED && isStaleEvent(currentTime, *keyEntry)) {
                dropReason = DropReason::STALE;
      }
      if (dropReason == DropReason::NOT_DROPPED && mNextUnblockedEvent) {
                dropReason = DropReason::BLOCKED;
      }
      done = dispatchKeyLocked(currentTime, keyEntry, &dropReason, nextWakeupTime);
      break;
....
}

在事件分发的过程中,需要对应到具体的窗口,以便拥有该窗口的应用进行后续处理。这一步其实就是在dispatchKeyLocked中进行的:

bool InputDispatcher::dispatchKeyLocked(nsecs_t currentTime, std::shared_ptr<const KeyEntry> entry,
                                        DropReason* dropReason, nsecs_t& nextWakeupTime) {

  // Identify targets.
    Result<sp<WindowInfoHandle>, InputEventInjectionResult> result =
            findFocusedWindowTargetLocked(currentTime, *entry, nextWakeupTime);

    if (!result.ok()) {
        if (result.error().code() == InputEventInjectionResult::PENDING) {
            return false;
        }
        setInjectionResult(*entry, result.error().code());
        return true;
    }
    sp<WindowInfoHandle>& focusedWindow = *result;
    LOG_ALWAYS_FATAL_IF(focusedWindow == nullptr);

    setInjectionResult(*entry, InputEventInjectionResult::SUCCEEDED);

    std::vector<InputTarget> inputTargets;
    addWindowTargetLocked(focusedWindow, InputTarget::DispatchMode::AS_IS,
                          InputTarget::Flags::FOREGROUND, getDownTime(*entry), inputTargets);

    // Add monitor channels from event's or focused display.
    addGlobalMonitoringTargetsLocked(inputTargets, getTargetDisplayId(*entry));

    if (mTracer) {
        ensureEventTraced(*entry);
        for (const auto& target : inputTargets) {
            mTracer->dispatchToTargetHint(*entry->traceTracker, target);
        }
    }

    // Dispatch the key.
    dispatchEventLocked(currentTime, entry, inputTargets);
    return true;

}

所有的步骤完成后,写入对应的Channel,应用就能接收到这些Input事件进行后续处理了。以按键事件为例,这里我们跳转到InputTransport类的publishKeyEvent方法:

status_t InputPublisher::publishKeyEvent(uint32_t seq, int32_t eventId, int32_t deviceId,
                                         int32_t source, ui::LogicalDisplayId displayId,
                                         std::array<uint8_t, 32> hmac, int32_t action,
                                         int32_t flags, int32_t keyCode, int32_t scanCode,
                                         int32_t metaState, int32_t repeatCount, nsecs_t downTime,
                                         nsecs_t eventTime) {
....
return mChannel->sendMessage(&msg);
}

以上就是整个事件读取与分发的全过程了,最后我们以下图来作为总结:

由于篇幅有限,这篇文章并未解析App是如何接收事件并进行处理的,后面有机会可以另外写一篇文章进行分析。

Happy
Happy
0 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post Android12 Input子系统解析 first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/android12-input-event-dispatch-progress.html/feed 0
安卓应用开发如何使用系统Native库 https://blog.coderfan.org/how-android-applications-use-native-libraries.html?utm_source=rss&utm_medium=rss&utm_campaign=how-android-applications-use-native-libraries https://blog.coderfan.org/how-android-applications-use-native-libraries.html#respond Tue, 22 Apr 2025 14:20:10 +0000 https://blog.coderfan.org/?p=5342 关于安卓系统中应用如何使用系统依赖库

The post 安卓应用开发如何使用系统Native库 first appeared on FranzKafka Blog.

]]>
Read Time:2 Minute, 47 Second

最近在开发一个Android应用的过程中遇到了一个问题,就是在集成第三方C++开源项目时该开源项目依赖于系统原生的OpenCL实现。根据以往的经验,厂商通常都会将OpenCL实现以共享库的形式集成到系统中供外部使用。

尽管我已经事先确认了系统中存在该共享库,但在应用启动阶段还是报错表示无法找到该依赖库。通过一番排查,终于发现了该问题所在。

简单来说,在Android12版本之后,如果应用需要使用Android系统原生提供的库,需要在App的AndroidManifest.xml中<Application>中添加相应的权限配置,如下所示:

<uses-native-library android:name="libOpenCL.so" android:required="false"/>
<uses-native-library android:name="libOpenCL-car.so" android:required="false"/>
<uses-native-library android:name="libOpenCL-pixel.so" android:required="false"/>

通过上述声明,在应用安装时就会向系统表明该应用将会使用系统提供的libOpenCL.so,libOpenCL-car.so以及libOpenCL-pixel.so,后面的android:required用于标识该应用对该依赖库的依赖程度:如果值为true,系统内不存在该依赖库时则无法进行安装,如果值为false,系统内不存在该依赖库时也可以进行安装。

按照上述方法进行处理后,发现还是无法正常使用该依赖库。通过后续搜索,我在这个链接中找到相应的解释:针对/vendor/lib,/vendor/lib64以及/system/lib,/system/lib64这两个目录下的共享库,如果需要对应用开放,我们应该在/vendor/etc/public.libraries.txt和/system/etc/public.libraries.txt进行声明。由于我需要的libOpenCL.so库在/vendor/lib64/目录下,我们需要在/vendor/etc/public.libraries.txt进行添加,在添加之后即可以被应用使用。

那么,类似于/vendor/etc/public.libraries.txt以及/system/etc/public.libraries.txt这两个文件,到底是如何工作的呢。

这里我们需要深入安卓系统的ART,了解安卓系统是如何加载这些依赖库并实现接口调用的。

在安卓系统中,负责加载依赖库并实现接口调用的模块为libnativeloader,其源码位于${ANDROID_BUILD_TOP}/art/libnativeloader。

机制概述

libnativeloader负责在ART中加载Native共享库,这些共享库有两种来源:一是APK打包时JNI接口引入的Native库,二是系统自身开放出来的Native共享库。

最典型的使用案例就是当我们在App代码内通过System.loadLibrary(“library-name”)加载共享库,当调用该方法时,ART 将调用委托给 libnativeloader 库,并传递指向调用该方法的类加载器(Class Loader)的引用,也就是该库的调用方。然后,libnativeloader 根据类加载器查找与之关联的链接器命名空间,并尝试从该命名空间加载请求的库。

而链接器命名空间在 APK 被加载到系统中时创建,并且与加载该 APK 的类加载器关联。链接器命名空间的配置确保只有嵌入在 APK 中的 JNI 库可以从该命名空间访问,从而防止 APK 加载其他 APK 的 JNI 库。

链接器命名空间的配置也取决于 APK 的其他特征,例如 APK 是否属于系统应用,如果非系统应用或者预装应用,即从应用市场安装的应用或者通过其他方式安装的应用,只有列在 /system/etc/public.libraries.txt 或者/vendor/etc/ public.libraries.txt中的公共本地库可以从平台被访问,而对于系统应用或者预装应用 ,所有在 /system/lib64,/vendor/lib64下的库都可以访问。

libnativeloader除了用于在ART中加载共享库,还负责抽象两种动态链接的接口:libdl.so以及libnativebridge.so,前者负责无需翻译的动态链接,比如x86_64架构的应用运行在x86_64的PC上时,通过libdl.so直接进行加载链接;后者则负责需要进行翻译的动态链接,比如arm64架构的应用运行在x86_64的PC上时,通过libnativebridge.so进行加载链接。

libnativeloader的源码组成主要包含native_loader.cpp、native_loader_namespace.cpp、public_libraries.cpp、library_namespaces.cpp四个源码。native_loader.cpp实现了libnativeloader的公共接口,属于对library_namespaces.cpp和native_loader_namespace.cpp的封装。library_namespcaes.cpp实现了LibraryNamespaces的单例,负责创建与配置链接器的命名空间。native_loader_namespaces.cpp实现了NativeLoaderNameSpace类。而publoc_libraries.cpp负责读取各个分区下的*.txt文件,用于决定公共可见的native库。

调用流程

这里我们来看看具体的调用流程:

art/runtime/runtime.cc->Runtime::Start()
art/runtime/jni/java_vm_ext.cc->JavaVMExt::LoadNativeLibrary
art/libnativeloader/native_loader.cpp->OpenNativeLibrary
art/libnativeloader/native_loader.cpp->CreateClassLoaderNamespaceLocked
art/libnativeloader/library_namespaces.cpp->LibraryNamespaces::Create

我们看看具体是怎么实现的,这里我们直接看LibraryNamespaces类中的Create方法:

Result<NativeLoaderNamespace*> LibraryNamespaces::Create(JNIEnv* env,
                                                         uint32_t target_sdk_version,
                                                         jobject class_loader,
                                                         ApiDomain api_domain,
                                                         bool is_shared,
                                                         const std::string& dex_path,
                                                         jstring library_path_j,
                                                         jstring permitted_path_j,
//获取系统system分区下公开可被使用的库
std::string system_exposed_libraries = default_public_libraries();
.....

//获取系统vendor分区下公开可被使用的库
 const std::string vendor_libs =
      filter_public_libraries(target_sdk_version, uses_libraries, vendor_public_libraries());

//获取系统product分区1下公开可被使用的库
 const std::string product_libs =
      filter_public_libraries(target_sdk_version, uses_libraries, product_public_libraries());
....

}

跟随着调用链路,我们可以看到一系列类似于Init*PublicLibraries的函数,也就是读取各个分区下的public_libraries.txt文件进行解析,以 /system/etc/public.libraries.txt的解析为例:

static std::string InitDefaultPublicLibraries(bool for_preload) {
  //root_dir->/system
  std::string config_file = root_dir() + kDefaultPublicLibrariesFile;
  Result<std::vector<std::string>> sonames =
      ReadConfig(config_file, [&for_preload](const struct ConfigEntry& entry) -> Result<bool> {
        if (for_preload) {
          return !entry.nopreload;
        } else {
          return true;
        }
      });
  if (!sonames.ok()) {
    LOG_ALWAYS_FATAL("%s", sonames.error().message().c_str());
    return "";
  }

  // If this is for preloading libs, don't remove the libs from APEXes.
  if (!for_preload) {
    // Remove the public libs provided by apexes because these libs are available
    // from apex namespaces.
    for (const auto& [_, library_list] : apex_public_libraries()) {
      std::vector<std::string> public_libs = base::Split(library_list, ":");
      sonames->erase(std::remove_if(sonames->begin(),
                                    sonames->end(),
                                    [&public_libs](const std::string& v) {
                                      return std::find(public_libs.begin(), public_libs.end(), v) !=
                                             public_libs.end();
                                    }),
                     sonames->end());
    }
  }

  std::string libs = android::base::Join(*sonames, ':');
  ALOGD("InitDefaultPublicLibraries for_preload=%d: %s", for_preload, libs.c_str());
  return libs;
}

这个函数中的root_dir()得到/system,而kDefaultPublicLibrariesFile的值为/etc/public.libraries.txt,也就是读取/system/etc/public.libraries.txt,通过解析后将这些依赖库加载到对应的链接器空间,这样应用就可以使用了。

Happy
Happy
100 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post 安卓应用开发如何使用系统Native库 first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/how-android-applications-use-native-libraries.html/feed 0
安卓应用开发之应用签名 https://blog.coderfan.org/android-application-developement-about-signing-apk.html?utm_source=rss&utm_medium=rss&utm_campaign=android-application-developement-about-signing-apk https://blog.coderfan.org/android-application-developement-about-signing-apk.html#respond Tue, 25 Feb 2025 14:24:52 +0000 https://blog.coderfan.org/?p=5325 Android应用开发中如何对应用进行签名。

The post 安卓应用开发之应用签名 first appeared on FranzKafka Blog.

]]>
Read Time:1 Minute, 43 Second

在某次调试过程中,我发现ADB安装APK出现了如下报错:INSTALL_PARSE_FAILED_NO_CERTIFICATES。

这是我第一次遇到这个问题,虽然长期从事安卓开发,但都集中在Framework、HAL与内核,对于App的开发了解有限,完全算不上专业的App开发者。而且在此之前,调试时多次安装我都没有遇到这种问题。经过排查,发现该问题出现的原因是因为该APK为Release版本,而安卓系统针对Release版本的APK是要求应用必须进行签名的,而debug版本的签名则不需要。

那么如何对安卓的APK进行签名以及安卓系统是如何进行校验的呢。这里我们将两种方式来对APK进行签名。

方式一,我们通过命令行方式来进行签名:

首先我们需要创建Java KeyStore文件,也就是我们在很多Android工程中见到的*.jks文件,这里我们通过keytool命令来进行创建:

keytool -genkeypair \
        -alias ${JKS_ALIAS} \
        -keyalg RSA \
        -keysize 2048 \
        -validity 365 \
        -keystore "${PATH_TO_JKS} \
        -storepass "${KEYSTORE_PASSWORD}" \
        -keypass "${KEY_PASSWORD}" \
        -dname "CN=WH, OU=WH, O=WH, L=China, ST=China, C=China"

这里面各个参数的含义:

alias:用于对该JKS设置别名;

keyalg:证书加密算法;

keysize:证书加密算法位数;

validity:证书有效期;

keystore:签名证书文件;

storepass:KeyStore的密码;

keypass:证书私钥密码;

dname:证书签名的身份信息;

之后我们可以检测是否正常生成JKS文件:

keytool -list -v -keystore ${PATH_TO_JKS}

之后我们再进行签名处理:

jarsigner -verbose -keystore "${PATH_TO_JKS}" \
              -storepass "$KEYSTORE_PASSWORD" \
              -keypass "$KEY_PASSWORD"   \  
              ${NEED_SIGNED_APK}  "$JKS_ALIAS" \                                                    
              -signedjar "${SIGNED_APK}"

最后我们可以检查是否完成了签名:

jarsigner -verify -verbose ${SIGNED_APK}

#其输出大概是这样的
  s = signature was verified 
  m = entry is listed in manifest
  k = at least one certificate was found in keystore

- Signed by "CN=WH, OU=WH, O=WH, L=China, ST=China, C=China"
    Digest algorithm: SHA-256
    Signature algorithm: SHA256withRSA, 2048-bit key

jar verified.

Warning: 
This jar contains entries whose certificate chain is invalid. Reason: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
This jar contains entries whose signer certificate is self-signed.
This jar contains signatures that do not include a timestamp. Without a timestamp, users may not be able to validate this jar after any of the signer certificates expire (as early as 2026-02-21).
POSIX file permission and/or symlink attributes detected. These attributes are ignored when signing and are not protected by the signature.

Re-run with the -verbose and -certs options for more details.

此时我们就已经完成了对APK的签名。

方式二,我们通过gradle配置来实现签名,此时我们一般通过AndroidStudio来操作。

首先我们通过AndroidStudio,点击上方Build-Generate Signed Bundle/APK,选择aab或者APK,之后我们可以看到在Key store path看到create new的按钮,点击就可以创建jks文件:

具体填入的信息与我们手动创建该文件其实是一样的,只不过交互形式改变了而已。

在生成完jks文件后,我们需要在build.gradle中添加如下配置:

signingConfigs { 
  release { 
    keyAlias 'your-key-alias' 
    keyPassword 'your-key-password' 
    storeFile file('/path/to/your/keystore/file.jks') 
    storePassword 'your-keystore-password' 
   }
}

这样当我们在AndroidStudio中编译生成APK时,就会自动进行签名。

当我们拿到一个APK时,在不确定其是否为Release或者Debug的前提下,我们可以通过AndroidStudio进行打开,找到应用的AndroidManifest.xml,找到debuggable字段:

如果android:debuggable=”true”,则表明该apk为debug版本的apk,否则则为release版本的apk。

关于签名还有一点需要说明的是,如果我们在签名之后更换了签名文件,编译出来的产物在安装时可能会出现unmatched signature,这是因为之前的签名与后续的签名文件存在差异,这在Android系统中是不被接受的。

最后,我们需要理解为什么需要对我们的应用进行签名。其实很简单,对应用签名其实是为了表明应用的所有者的身份信息,尤其是我们上架到应用市场时,我们后续应用的更新和升级都会进行签名的验证。如果某个第三方在不知情或未经授权的情况下设法取得我们的应用签名密钥,此人可能会为应签名并进行分发,从而恶意替换我们的原版应用。另外,还可能会利用开发者的身份为应用签名并进行分发,从而攻击其他应用或系统本身,或者损坏或窃取用户数据。

Happy
Happy
100 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %

The post 安卓应用开发之应用签名 first appeared on FranzKafka Blog.

]]>
https://blog.coderfan.org/android-application-developement-about-signing-apk.html/feed 0