安卓系统应用支持 gradle编译的ROM 集成方案

2,006 阅读5分钟

背景

安卓开源项目(AOSP)中各个应用模块的开发编译方式采用的是 make 编译方式,每个模块需要通过定义 Android.mk 或者 Android.bp 文件配置编译规则。相对于主流的普通 App 的 gradle 编译方式有很多的缺点。比如:

  1. 编译速度慢,每次 make 编译都要遍历查询编译所有依赖模块,即使使用 mm 仅编译当前模块的编译方式也没有 gradle 速度快。
  2. 集成依赖第三方库不够灵活。make 编译方式只能采用本地依赖,不像 gradle 的远程依赖那么灵活。开发过程中经常依赖了很多远程 maven 库,每次升级,都要手动下载集成到 libs 目录下,不仅麻烦,而且使得 git 仓库变得越来越大。另外make 编译方式不支持直接引用 aar 的库。
  3. make 编译不支持主流的 android 开发语言 kotlin。

由此可见,系统应用的 make 编译方式对于习惯了使用 gradle 编译方式的开发是非常不友好的,为了帮助普通开发者能够快速的参与到系统应用的开发,本文探索了支持 make 和 gradle 编译两种方式的最佳途径。

思考

我们的目的是像开发普通应用一样来开发系统应用,并且开发的应用可以随着系统编译集成到 ROM 中。

先看看有哪些可能的实现方式:

方式一,完全采用 make 的编译方式。相对与 gradle 编译方式很很多缺陷(如背景介绍),势必对开发效率影响很大。

方式二,只采用 gradle 的编译方式。由于系统编译采用的 make 编译,开发的应用无法随着 ROM 编译集成到系统中。需要通过预置应用的方式将提前编译好的apk 文件放在预置应用目录进行集成。带来的问题是:

  • 由于应用的迭代比较频繁,手动去更新预置 apk 实现繁琐,维护复杂;
  • 人工操作容易出错,忘记更新,更新错误都会导致效率降低;
  • apk 更新带来大量无用的 git 历史记录,导致 git 仓库变得越来越大。

有的公司采用的方式是,实现jenkins自动化,监控应用代码入库,自动编译上传 apk。ROM 编译时,自动下载最新 apk 到预置应用目录。这种方案可以解决上面的问题,但实现成本较高,需要搭建自动化配置,服务器云存储,版本管理等。

方式三,实现同时支持 make和 gradle 编译方式。支持 make 编译便于 ROM 编译集成,支持 gradle 编译,提升应用开发效率,支持独立编译(不需要下载aosp 其他源码)。

实现同时支持 make和 gradle 编译方案实现

实现思路,通过修改 Android.mk 脚本,触发 gradle 进行编译,将编译后的 apk 输出到预置应用路径。

比如下面是 HomepadLauncher 应用的 Android.mk 文件内容,为方便理解,本文添加了中文注释介绍了实现的关键点。

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)

# Module name should match apk name to be installed
LOCAL_MODULE := HomepadLauncher

# 根据 ROM 编译类型是否为 userdebug 来决定 gradle 编译 assembleDebug 或 assembleRelease 的版本。
ifeq ($(TARGET_BUILD_VARIANT), userdebug)
    LOCAL_BUILD_TYPE = assembleDebug
else
    LOCAL_BUILD_TYPE = assembleRelease
endif

# 触发 checkChanges.sh 脚本检查本地代码是否变化,仅当代码有变化后才执行 gradle 编译,避免重复编译。
LOCAL_CHANGE_RESULT := $(shell cd $(LOCAL_PATH) && ./checkChanges.sh $(LOCAL_BUILD_TYPE))
$(info "check changes return $(LOCAL_CHANGE_RESULT)")
ifneq ($(LOCAL_CHANGE_RESULT), 0)
    # run gradle script for generating apk file
    $(info "==================== START build $(LOCAL_MODULE) $(LOCAL_BUILD_TYPE)")
    # buildApk.sh 脚本传入编译类型参数,执行 gradle 的编译
    $(info $(shell cd $(LOCAL_PATH) && ./buildApk.sh $(LOCAL_BUILD_TYPE)))
    $(info "==================== END build $(LOCAL_MODULE) $(LOCAL_BUILD_TYPE)")
endif

LOCAL_MODULE_TAGS := optional

# 指定直接使用 gradle 编译生成的 apk,不会真正通过 make 编译源码
LOCAL_SRC_FILES := app/build/outputs/apk/$(LOCAL_BUILD_TYPE).apk
LOCAL_MODULE_CLASS := APPS

LOCAL_CERTIFICATE := PRESIGNED
include $(BUILD_PREBUILT)

重点任务在 buildApk.sh 脚本中,这个脚本可不是简单的执行 ./gradlew assembleDebug 或 ./gradlew assembleRelease 命令那么简单。我们在开发时使用 android studio 会自动下载 android sdk 和 gradle 为我们提前初始化好应用的运行环境。但是安卓系统源码的编译通常部署在远程的编译服务器中,如果没有提前下载好 android sdk 和 gradle 是无法执行 gradle 编译的。因此 buildApk.sh 内部的执行流程如下图所示。

image-20220518160433336

我们逐一介绍下部分脚本的功能实现。

下载安装安卓 sdk 的脚本,installAndroidSDK.sh:

#!/bin/bash
echo "start installAndroidSdk.sh..."
ANDROID_SDK_PATH=$HOME/Android/Sdk
echo "use android sdk path: $ANDROID_SDK_PATH \n"
SDK_MGR_PATH=$HOME/Android/sdkmgr
mkdir -p $SDK_MGR_PATH && cd $SDK_MGR_PATH || exit 1
if [ ! -e "${SDK_MGR_PATH}/commandlinetools-linux-6200805_latest.zip" ]; then
	echo "start download sdk manager ..."
	#wget https://dl.google.com/android/repository/commandlinetools-linux-6200805_latest.zip
	# 墙的原因,国内采用了腾讯的镜像
	wget https://mirrors.cloud.tencent.com/AndroidSDK/commandlinetools-linux-6200805_latest.zip
	if [ $? -ne 0 ]; then
	    echo "Download android sdkmanager failed!!!"
	    rm commandlinetools-linux-6200805_latest.zip
	    exit 1
	fi
	unzip commandlinetools-linux-6200805_latest.zip
	if [ $? -ne 0 ]; then
	    echo "unzip commandlinetools failed!!!"
	    rm commandlinetools-linux-6200805_latest.zip
	    exit 1
	fi
fi
export PATH="$SDK_MGR_PATH/tools/bin:${PATH}"
echo "start download android sdk..."
#PROXY_OPTS="--no_https --proxy=http --proxy_host=mirrors.neusoft.edu.cn --proxy_port=80"
PROXY_OPTS="--no_https"
yes | sdkmanager --sdk_root=$ANDROID_SDK_PATH --licenses $PROXY_OPTS

# 此处下载的 android sdk 版本需要根据项目具体使用的版本进行调整
sdkmanager --verbose --sdk_root=$ANDROID_SDK_PATH "platform-tools" "platforms;android-29" "build-tools;28.0.3" $PROXY_OPTS
if [ $? -ne 0 ]; then
    echo "download android sdk failed!!!"
    exit 1
fi
echo "download android sdk success~~"
cd -
echo "return directory: " && pwd
# 生成本地的 local.properties 文件,配置 sdk 路径
echo sdk.dir=$ANDROID_SDK_PATH > local.properties

下载 gradle 的脚本 downloadGradle.sh (本人项目中用的是 5.4.1版本,如需借鉴,请根据项目依赖的实际版本进行调整):

#!/bin/bash
echo "start downloadGradle.sh"

GRADLE_DOWNLOAD_PATH=~/.gradle/wrapper/dists/gradle-5.4.1-all/3221gyojl5jsh0helicew7rwx

if [ -e ${GRADLE_DOWNLOAD_PATH}/gradle-5.4.1-all.zip.ok ];then
	echo "gradle already readly"
	exit 0
fi

rm -r $GRADLE_DOWNLOAD_PATH
echo "begin download gradle..."
wget -P $GRADLE_DOWNLOAD_PATH https://mirrors.cloud.tencent.com/gradle/gradle-5.4.1-all.zip
if [ $? -ne 0 ]; then
	rm -r $GRADLE_DOWNLOAD_PATH
    echo "download gradle failed!!!"
    exit 1
else
	echo "download gradle success"
fi

检查代码是否变化的脚本 checkChanges.sh:

#!/bin/bash
# check current repo changes, if no changes, return 0
if [ ! -e app/build/outputs/apk/$1.apk ]; then
    exit 1
fi
if [ -e app/build/lastBuildMD5.txt ]; then
    LAST_MD5=`head app/build/lastBuildMD5.txt`
    #echo "last md5 is $LAST_MD5"
    CURRENT_MD5=`find . -type f -not -path "*/build/*" -not -path "*./.*" -exec stat \{\} -c "%y\n" \; | sort -n -r | md5sum | cut -f 1 -d ' '`
    #echo "current md5 is $CURRENT_MD5"
    if [ $LAST_MD5 == $CURRENT_MD5 ]; then
        # no changes
        echo "0"
        exit 0
    else
        echo "code changed"
        exit 1
    fi
else
    exit 1
fi

buildApk.sh 脚本:

#!/bin/bash
function useJdk()
{
    export JAVA_HOME=$1
    export ANDROID_JAVA_HOME=$1
}

grep sdk.dir local.properties || ./installAndroidSDK.sh
if [ $? -ne 0 ]; then
    echo "install android sdk failed!!!"
    exit 1
fi
./downloadGradle.sh
if [ $? -ne 0 ]; then
    echo -e "downloadGradle failed!!!"
    exit 1
fi

origin_java_path=$JAVA_HOME
root_path=$(dirname $(dirname $(dirname "$PWD")))
jdk8_path=$root_path"/prebuilts/jdk/jdk8/linux-x86"

# android 10 运行环境用的 java9,gradle 需要使用 java8,此处切换 java 环境
useJdk $jdk8_path

./gradlew clean
#rm -fr ./app/build/outputs
./gradlew $1
if [ $? -ne 0 ]; then
    echo -e "build $PWD $1 failed!!!"
    useJdk $origin_java_path
    exit 1
fi

cp ./app/build/outputs/apk/*/*.apk ./app/build/outputs/apk/$1.apk
# save current files snapshot md5
find . -type f -not -path "*/build/*" -not -path "*./.*" -exec stat \{\} -c "%y\n" \; | sort -n -r | md5sum | cut -f 1 -d ' ' > app/build/lastBuildMD5.txt
echo "build $PWD $1 success"
useJdk $origin_java_path

所有脚本需要放在与应用项目的Android.mk 文件同级目录下。

遇到的问题

  1. 脚本中的部分命令比如(wget, yes)无法执行,需要配置下文件 build/soong/ui/build/paths/config.go, 允许使用这2个命令。

image-20220518162052102

  1. gradle 编译时报错
Exception in thread "main" java.lang.RuntimeException: Could not create parent directory for lock file /nonexistent/.gradle/wrapper/dists/gradle-5.4.1-all/3221gyojl5jsh0helicew7rwx/gradle-5.4.1-all.zip.lck
	at org.gradle.wrapper.ExclusiveFileAccessManager.access(ExclusiveFileAccessManager.java:43)
	at org.gradle.wrapper.Install.createDist(Install.java:48)
	at org.gradle.wrapper.WrapperExecutor.execute(WrapperExecutor.java:107)
	at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:61)

解决方法,需要禁用 sandbox,修改文件 build/soong/ui/build/sandbox_linux.go:

image-20220518162216395

由于真实编译采用的是 gradle 编译,基于 android sdk 而非系统源码,因为无法直接引用系统私有 api 以及新增的系统 api。本方案对于系统私有接口依赖不强的收益比较大。针对新增系统接口,可以采用占位编译 framework.jar 的方式实现,或者搭建系统级的基础库。本文不再赘述。

小结

本文介绍了aosp 系统应用支持 gradle 编译集成的方案,方便普通应用开发者无缝的参与系统应用的开发,支持独立编译。大大提高了开发效率。