前提

      我一直想捣鼓一下机器人开发,想着自己独立去设计一款机器人,但了解到整个流程中所要学习的技能以及自己为数不多的业余时间,这事儿让我觉得有点犯难。直到去年看到古月居发布了OriginBot智能小车,在看完它的整体资料后,我觉得它有以下几个特点比较吸引我:

  • 开源特性,基本上展现了如何从头到尾去设计开发一款小型的机器人,这和我的初始想法非常吻合。
  • 软硬件的可拓展性,旭日X3派提升了它的可开发性,毕竟我不希望买它回来只是跑跑例程。
  • 价格,在同类型的产品中它的价格比较亲民。

      在组装好小车并跑了一些例程后,我觉得小车还缺少一个遥控器,毕竟不能一直端着笔记本电脑去控制小车吧,但是我又不想买手柄,于是我把目光投向了我身边的那部沉睡已久的安卓手机,想法来了,那就用手机去遥控小车吧。

      其实用手机控制小车这也不是什么新鲜事,网上例子也很多,蓝牙,WiFi都可以,虽然都是学习,但是我也想整点不一样的,后来查资料发现ROS2也有安卓的版本,这就正合我意了,而且有了ROS2也能做更多的事情了。

      我其实并不会安卓开发,也不会java,但是安卓开发作为一门很成熟的技术,咱们程序员稍微学习一下,开发点简单的应用应该没什么问题。问题的关键是如何编译ROS2的安卓版本,下面就来讲讲整个编译过程以及我踩过的坑吧。

编译过程

系统:Ubuntu22.04

GitHub上的原项目:https://github.com/esteve/ros2_java

编译的步骤基本和原项目一致,但是这个项目的版本比较老,有些步骤直接使用会报错,所以经过我踩坑后的步骤如下:

  1. 配置Android SDK
    可以直接下载Android studio进行配置,在初次启动Android studio时会提示用户安装必要的sdk和其他模块,一般Ubuntu系统会安装在用户目录下。
  2. 配置Android NDK
    可以直接在Android studio中进行下载配置,也可以前往NDK官网下载,下载后可以和SDK放在同一目录,至于NDK的版本选最新的就可以。
  3. 设置环境变量
    在.bashrc文件最后一排设置SDK和NDK的环境变量,如下

    export ANDROID_HOEM=~/Android/Sdk
    export PATH=$PATH:$ANDROID_HOEM/tools:$ANDROID_HOEM/platform-tool
    export ANDROID_NDK=~/Android/ndk-r25b
  4. 克隆ros2和ros2 java源码

    mkdir -p $HOME/ros2_android_ws/src
    cd $HOME/ros2_android_ws
    curl https://raw.githubusercontent.com/ros2-java/ros2_java/main/ros2_java_android.repos | vcs import src

    原项目的版本是ros2 galactic,想要换成最新的长期支持版本humble也可以,需要将ros2_java_android.repos里面的galactic字样修改为humble,其中Fast DDS的版本也可以更新为最新的版本。
    我也整理了一份humble的repos:

    curl https://raw.githubusercontent.com/uglymie/ros2-humble-for-android/main/ros2_java_android.repos | vcs import src

    此处要特别注意一个问题,因为本项目需要大量下载GitHub上的ROS2相关模块,所以要求电脑终端必须要能流畅的访问GitHub,强调一下,这个是必要条件。

    最终得到的文件目录如下图:

  5. 设置编译配置

    export PYTHON3_EXEC="$( which python3 )"
    export PYTHON3_LIBRARY="$( ${PYTHON3_EXEC} -c 'import os.path; from distutils import sysconfig; print(os.path.realpath(os.path.join(sysconfig.get_config_var("LIBPL"), sysconfig.get_config_var("LDLIBRARY"))))' )"
    export PYTHON3_INCLUDE_DIR="$( ${PYTHON3_EXEC} -c 'from distutils import sysconfig; print(sysconfig.get_config_var("INCLUDEPY"))' )"
    export ANDROID_ABI=armeabi-v7a
    export ANDROID_NATIVE_API_LEVEL=android-29
    export ANDROID_TOOLCHAIN_NAME=arm-linux-androideabi-clang

    其中ANDROID相关选项可以更换,如

    export ANDROID_ABI=arm64-v8a
    export ANDROID_NATIVE_API_LEVEL=android-29
    export ANDROID_TOOLCHAIN_NAME=aarch64-linux-android-clang

    这里我们选择 ANDROID_ABI 为arm64-v8a

  6. 编译命令

    colcon build \
       --packages-ignore cyclonedds rcl_logging_log4cxx rcl_logging_spdlog rosidl_generator_py rclandroid ros2_talker_android ros2_listener_android \
       --cmake-args \
       -DPYTHON_EXECUTABLE=${PYTHON3_EXEC} \
       -DPYTHON_LIBRARY=${PYTHON3_LIBRARY} \
       -DPYTHON_INCLUDE_DIR=${PYTHON3_INCLUDE_DIR} \
       -DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE} \
       -DANDROID=ON \
       -DANDROID_FUNCTION_LEVEL_LINKING=OFF \
       -DANDROID_NATIVE_API_LEVEL=${ANDROID_TARGET} \
       -DANDROID_TOOLCHAIN_NAME=${ANDROID_TOOLCHAIN_NAME} \
       -DANDROID_STL=c++_shared \
       -DANDROID_ABI=${ANDROID_ABI} \
       -DANDROID_NDK=${ANDROID_NDK} \
       -DTHIRDPARTY=ON  \
       -DCOMPILE_EXAMPLES=OFF \
       -DCMAKE_FIND_ROOT_PATH="${PWD}/install" \
       -DBUILD_TESTING=OFF \
       -DRCL_LOGGING_IMPLEMENTATION=rcl_logging_noop \
       -DTHIRDPARTY_android-ifaddrs=FORCE

    此编译命令经过多次问题排查更改,已经可以避免大多数错误。但是仍然可能出现其他错误:

    • 找不到jni.h和jni_md.h
    fatal error: jni.h: No such file or directory

    解决方法: 可以添加全局搜索路径到 .bashrc 或者配置文件 /etc/profile 中,其中java-11-openjdk-amd64为当前系统已经安装的jdk版本,CPATH关键字表示适用于所有语言。

    export CPATH=/usr/lib/jvm/java-11-openjdk-amd64/include:$CPATH
    export CPATH=/usr/lib/jvm/java-11-openjdk-amd64/include/linux:$CPATH
    • 编译到Fast-DDS时可能出现找不到asio和tinyxml2的头文件

    解决方法: 可以在其目录下(eProsima/Fast-DDS)

    的CMakeList.txt文件里添加包含头文件路径

    include_directories(thirdparty/asio/asio/include)
    include_directories(thirdparty/tinyxml2)

打包过程

  1. jar文件和so文件
    编译完成后在install目录下会生成很多文件夹,其中包含了所有的jar文件和so文件,可以直接在目录下搜索,如下图:

可以将两种文件分别拷贝并整理到单独的文件目录下,比如:

ros2-humble/arm64-v8a/jar
ros2-humble/arm64-v8a/so

2. 根据需要加载库文件
编译好的jar文件和so文件包含了比较完整的ros常用功能包,以下是so文件:

可以看到共有1144个文件,事实上很多消息类的库文件我们是按需所用,比如项目中只需要用到std_msgs,那么其他的消息类库文件就可以不用再加载,如果加载所有的库文件会使最终的apk文件变得比较大。

测试应用程序

下面是一个测试APP的简单说明,这个应用也是我之前学习的时候在GitHub上找到的:https://github.com/YasuChiba/ros2-android-test-app


我觉得拿来作为测试用的应用比较合适,当然这个应用使用的库文件是ros2 galactic编译好的,我将其替换成humble也是没问题的。

测试APP主界面包括四个Button和一个TextView(空白处),如下图:

要实现的功能也比较简单,建立一个发布者和一个订阅者,定时发布消息,其话题名称为/chatter,消息类型为字符串,可以开始和暂停发布或订阅。
Android相关代码这里就不做说明,可以看上面完整的项目;下面看一下在java语言下的ros2发布及订阅节点的实现。

发布者节点,TalkerNode.java:

package com.example.ros2_android_test_app;

import java.util.concurrent.TimeUnit;

import android.util.Log;

import org.ros2.rcljava.node.BaseComposableNode; // 引入ROS2节点相关库
import org.ros2.rcljava.publisher.Publisher; // 引入发布者相关库
import org.ros2.rcljava.timer.WallTimer; // 引入计时器相关库

public class TalkerNode extends BaseComposableNode {
private static String logtag = TalkerNode.class.getName();

private final String topic; // 定义节点发布的话题

public Publisher<std_msgs.msg.String> publisher; // 声明发布者

private int count; // 定义计数器

private WallTimer timer; // 声明计时器

public TalkerNode(final String name, final String topic) {
    super(name);
    this.topic = topic;
    // 创建发布者
    this.publisher = this.node.<std_msgs.msg.String>createPublisher(
            std_msgs.msg.String.class, this.topic);
}

public void start() {
    Log.d(logtag, "TalkerNode::start()");
    if (this.timer != null) {
        this.timer.cancel(); // 如果计时器已存在,取消计时器
    }
    this.count = 0; // 将计数器归零
    // 创建计时器,每500毫秒执行一次onTimer函数
    this.timer = node.createWallTimer(500, TimeUnit.MILLISECONDS, this::onTimer);
}

private void onTimer() {
    std_msgs.msg.String msg = new std_msgs.msg.String(); // 创建消息对象
    msg.setData("Hello! ROS2 Humble! " + this.count); // 设置消息内容
    this.count++; // 计数器自增
    this.publisher.publish(msg); // 发布消息
}

public void stop() {
    Log.d(logtag, "TalkerNode::stop()");
    if (this.timer != null) {
        this.timer.cancel(); // 如果计时器已存在,取消计时器
    }
}

订阅者节点,ListenerNode.java:

package com.example.ros2_android_test_app;

import android.util.Log;
import android.widget.TextView;

import org.ros2.rcljava.node.BaseComposableNode;
import org.ros2.rcljava.subscription.Subscription;

public class ListenerNode extends BaseComposableNode {
    private final String topic;   // 订阅的 ROS2 消息主题名称
    private final TextView listenerView;  // 用于在 Android UI 上显示消息内容的 TextView 控件

    private Subscription<std_msgs.msg.String> subscriber;  // ROS2 订阅者对象,用于接收消息

    public ListenerNode(final String name, final String topic,
                        final TextView listenerView) {
        super(name);  // 调用父类 BaseComposableNode 的构造方法,传入节点名称
        this.topic = topic;  // 保存订阅的主题名称
        this.listenerView = listenerView;  // 保存用于显示消息内容的 TextView 控件

        // 创建 ROS2 订阅者对象,订阅指定主题的 std_msgs/String 类型的消息
        this.subscriber = this.node.<std_msgs.msg.String>createSubscription(
                std_msgs.msg.String.class, this.topic, msg
                        -> {
                    // 当接收到新消息时,将其内容显示在 TextView 控件上
                    this.listenerView.setText("Hello ROS2 from Android: " + msg.getData() +
                            "\r\n" + listenerView.getText());
                });
    }
}

用于管理 ROS2 的执行器(Executor)和在 Android 设备上运行的 ROS2 节点,ROSActivity.java:

package com.example.hyperbot;

import android.os.Bundle;
import android.os.Handler;

import androidx.appcompat.app.AppCompatActivity;

import org.ros2.rcljava.RCLJava;
import org.ros2.rcljava.executors.Executor;
import org.ros2.rcljava.executors.SingleThreadedExecutor;

import java.util.Timer;
import java.util.TimerTask;

public class ROSActivity extends AppCompatActivity {
    private Executor rosExecutor;  // ROS2 执行器对象,用于处理节点的消息
    private Timer timer;  // 定时器对象,定时执行节点的 spinSome() 方法
    private Handler handler;  // Android UI 线程的 Handler 对象,用于在 UI 线程上执行节点的 spinSome() 方法

    private static String logtag = ROSActivity.class.getName();

    private static long SPINNER_DELAY = 0;  // 定时器的启动延迟时间(单位:毫秒)
    private static long SPINNER_PERIOD_MS = 200;  // 定时器的周期时间(单位:毫秒)

    // 生命周期方法,当活动第一次创建时调用
    @Override
    public void onCreate(final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        this.handler = new Handler(getMainLooper());  // 创建 Android UI 线程的 Handler 对象
        RCLJava.rclJavaInit();  // 初始化 RCLJava 库
        this.rosExecutor = this.createExecutor();  // 创建 ROS2 执行器对象
    }

    // 生命周期方法,当活动从暂停状态恢复时调用
    @Override
    protected void onResume() {
        super.onResume();
        timer = new Timer();  // 创建定时器对象
        timer.scheduleAtFixedRate(new TimerTask() {
            public void run() {
                Runnable runnable = new Runnable() {
                    public void run() {
                        rosExecutor.spinSome();  // 在 UI 线程上执行节点的 spinSome() 方法
                    }
                };
                handler.post(runnable);  // 将 Runnable 对象提交到 UI 线程的消息队列中, 避免在子线程中更新UI
            }
        }, this.getDelay(), this.getPeriod());  // 启动定时器,定时执行节点的 spinSome() 方法
    }

    // 生命周期方法,当活动暂停时调用
    @Override
    protected void onPause() {
        super.onPause();
        if (timer != null) {
            timer.cancel();  // 取消定时器的执行
        }
    }

    public void run() {
        rosExecutor.spinSome();  // 执行节点的 spinSome() 方法
    }

    public Executor getExecutor() {
        return this.rosExecutor;  // 获取 ROS2 执行器对象
    }

    protected Executor createExecutor() {
        return new SingleThreadedExecutor();  // 创建单线程的 ROS2 执行器对象
    }

    protected long getDelay() {
        return SPINNER_DELAY;  // 获取定时器的启动延迟时间
    }

    protected long getPeriod() {
        return SPINNER_PERIOD_MS;  // 获取定时器的周期时间
    }
}

最后在MainActivity中创建新的发布和订阅者节点对象,并将其添加到执行器:

listenerNode = new ListenerNode("ros2_humble_node_listener", "/chatter", listenerView);
talkerNode = new TalkerNode("ros2_humble_node_talker", "/chatter");

getExecutor().addNode(listenerNode);
getExecutor().addNode(talkerNode );
最后的运行效果如下:

将手机和电脑连接到同一网络下,可以在终端中看到手机端发布的话题及消息,如下图:

至此,关于ros2 humble的Android版本的编译以及测试到此结束;下一篇来聊聊如何开发APP来控制OriginBot的运动,敬请期待。