LVGL 安卓移植
背景
LVGL(轻量级和通用图形库)是一个免费和开源的图形库,它提供了创建嵌入式GUI所需的绝大多数功能,具有易于使用的图形元素,漂亮的视觉效果和超低内存占用(相对于Android系统来说)。
LVGL可以在16、32和64位MCU或者处理器上运行。对硬件最低的要求是64kB Flash和16kB RAM,这对动辄 8GB RAM和256GB 存储的手机来说,简直是不值一提。
它在仅需要极少的内存的基础上,还提供了丰富的各种组件,如:按钮、图表、列表、滑块、图像等。
因此,将这款这款图形库移植在Android系统上是可行的,并且是有意义的。本文末尾有本次移植后的全部代码,欢迎大家star。
一些基于LVGL开发的页面案例
更多的例子,请访问他们的官网。不让放链接,真鸡儿。
移植步骤
基础
根据官方给出的移植指南,我们知道移植步骤如下:
To use the graphics library you have to initialize it and setup required components. The order of the initialization is:
1. Call lv_init().
2. Initialize your drivers.
3. Register the display and input devices drivers in LVGL. Learn more about Display and Input device registration.
4. Call lv_tick_inc(x) every x milliseconds in an interrupt to report the elapsed time to LVGL. Learn more.
5. Call lv_timer_handler() every few milliseconds to handle LVGL related tasks.
首先,调用lv_init方法,初始化LVGL。
然后,初始化我们的驱动。再把显示和输入设备驱动注册到LVGL。
再然后,周期性调用lv_tick_inc,用以报告已经过去的时间(其实就是给LVGL提供一个时间基准)。
最后,周期性调用lv_timer_handler()用以触发LVGL内部的任务。
我们的移植也是按照这个步骤来进行。首先创建NDK工程,然后初始化驱动。初始化驱动,针对Android来说,就是创建一个SurfaceView,利用它作为一块虚拟的屏幕,用以显示LVGL渲染的内容。同时,利用它的onTouchEvent方法,获取触摸输入。接着,我们将LVGL代码嵌入到我们工程中。然后,分别创建显示设备和输入设备,并建立与SurfaceView的连接。最后,周期性的调lv_timer_handler(),来触发LVGL的各种任务。如此,LVGL便移植到了Android系统上。
NDK工程创建
我们知道LVGL是基于C开发的,因此,我们需要先创建一个NDK工程,以提供C/C++编程能力。
输入工程信息
选择C++标准工具
工程创建完毕
更改C++文件名称
选中文件,快捷键:Shift+F5
更改库名称为lvgl-android
删除自动生成的MainActivity加载库代码
创建LVGLEntrance类,并写入如下的代码
使用Android Studio自带的创建JNI函数代码工具,自动生成C++代码。(鼠标双击选中函数,然后Alt+Enter,会提示如图创建JNI函数提示)。也可以根据JNI规则,自己手动在lvgl-android.cpp文件中,创建对应的函数。
打开lvgl-android.cpp文件,将会生成如下代码
删除创建工程时,自动创建的stringFromJNI函数(也需要在MainActivity中删除对应的native函数)。
创建LVGLHolderCallback类,用于实现JNI层和Java环境的绑定。
创建自定义SurfaceView,用以将LVGLHolderCallback绑定到SurfaceView。同时,重载onTouchEvent方法,用以实现将触摸事件传递给LVGL层。
将activity_main.xml布局文件,改成使用我们自定义的SurfaceView。如下图所示(也要去除MainActivity中自动生成的TextView的绑定,否则编译不通过)。
至此,Java层的所有工作便结束了。下面我们真正开始将LVGL移植到Android中。
嵌入LVGL
首先,我们需要下载LVGL的代码。代码托管在Github上,我们下载最新的代码即可。国外网站访问可能有点慢,我自己同步了一份代码到Gitee上,地址在此:https://gitee.com/jaesoon/lvgl。大家可以在这里下载。
下载之后,我们选择最新的分支-8.2.0。
工程结构如下:
对于我们移植来说,只需要关心src目录即可。我们将src目录下的文件全部复制到我们的工程目录的cpp下。
复制完成之后的目录如上。因为目录结构问题,我们需要调整下lv_conf_internal.h文件,调整如下图:
添加lv_tick_custom.h和lv_tick_custom.c文件。在该文件中,添加时间基准函数。LVGL所有的操作都依靠于这个时间。
添加lv_conf.h和lvgl.h文件。因为LVGL是一个可以用在MCU上的GUI,由于MCU只有可怜的计算资源、内存和存储空间,所以,它是支持裁剪的:不需要的组件可以不进行编译。这些组件的裁剪由宏定义来实现,都定义在lv_conf.h中。而lvgl.h文件主要的作用是包含内部的各种组件头文件,便于程序中引用。
更改CMakeLists.txt 将工程中的c和cpp文件均加入编译。
将LVGL源码添加到工程中之后,我们就完成了准备工作。LVGL是一个非常优秀的GUI框架。所以,移植起来比较简单。我们只需要实现显示、输入设备和定时处理,即可完成移植。
移植显示设备
任何显示设备,都是一个个的像素点构成的。不管是八位数码管、或者CRT、或者LCD,都可以看成一个个的像素点组成。只不过,有些屏幕的像素点是方的,有的是圆的,有的是矩形的,还有的是异形的。有的像素点是单色的,有的是多彩的。而多彩的像素点,有些用RGB565编码颜色,有些是RGB888编码颜色,还有些是用RGBA8888来编码。不管是哪一种GUI工具包,最终需要输出的数据都是像素点数组。而移植工作就是将这些像素点数组,有序的显示到屏幕上。
了解原理,就比较好移植了。
//显示屏初始化
WIDTH = width;
HEIGHT = height;
buf = new uint32_t[WIDTH * HEIGHT];
lv_disp_draw_buf_init(&lv_disp_buf, lv_buf_1, lv_buf_2, DISP_BUF_SIZE);
static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = WIDTH;
disp_drv.ver_res = HEIGHT;
disp_drv.draw_buf = &lv_disp_buf;
// TODO Use acceleration structure for drawing
disp_drv.flush_cb = window_flush;
lv_disp_drv_register(&disp_drv);
首先,得到屏幕大小(准确说是指定的View的大小,并不是手机屏幕的大小,此处是便于理解)。根据屏幕的尺寸,申请一个数组。后续LGVL将计算好的像素数据保存在这个数组中,然后我们再将这个数组输出到屏幕上显示。
然后,我们初始化了显示缓存。
LGVL支持单缓冲和双缓冲。它们的区别是什么呢?在我们电脑或者手机中,屏幕中显示的东西都会被放在一个称为显示缓存的地方,在通常情况下我们只有一个这样的缓冲区即单缓冲,在单缓冲中任何绘图的过程都会被显示在屏幕中,这也就是我们为什么会看到闪烁。而所谓双缓冲就是在这个显示的缓冲区之外再建立一个不显示的缓冲区,我们所有的绘图都将在这个不显示的缓冲区中进行,只有当一帧都绘制完了之后才会被拷贝到真正的现实缓冲区显示出来,这样中间过程对于最终用户就是不可见的了,那即使是速度比较慢也只会出现停顿而不会有闪烁的现象出现。为了减少图像闪烁的情况,所以我们传入了lv_buf_1和lv_buf_2两个数组,用于实现双缓冲。
接下来,我们定义了一个显示设备驱动结构体变量。这个结构体,代表着我们的屏幕设备。然后,默认配置下该驱动设备属性。之后,设置屏幕的尺寸(单位是像素)。然后,我们再设置它的显存。显存刷新机制就是我们上面定义的双缓冲机制。
再下来, 我们设置了刷新回调函数。当LVGL完成一次渲染之后,将会调用该方法,用以通知屏幕将渲染后的UI数据显示到屏幕上。
最后,我们将该显示驱动注册到LVGL中。如此,LVGL的显示设备注册流程便走完了。
下面,我们来看一看如何将计算好的UI数据显示到屏幕上。
/**
* 将指定像素长度的颜色转换成RGBA数组
* @param data 目标数组
* @param color_p 颜色表
* @param w 像素大小
*/
static void copy_px(uint8_t *data, lv_color_t *color_p, int w) {
for (int i = 0; i < w; i++) {
data[0] = color_p->ch.red;
data[1] = color_p->ch.green;
data[2] = color_p->ch.blue;
data[3] = color_p->ch.alpha;
color_p++;
data += 4;
}
}
static uint32_t *buf;
static void window_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) {
__android_log_print(ANDROID_LOG_ERROR, "LVGL", "func:%s", __func__);
int left = area->x1;
if (left < 0)
left = 0;
int right = area->x2 + 1;
if (right > WIDTH)
right = WIDTH;
int top = area->y1;
if (top < 0)
top = 0;
int bottom = area->y2 + 1;
if (bottom > HEIGHT)
bottom = HEIGHT;
int32_t y;
ANativeWindow_Buffer buffer;
ANativeWindow_lock(window, &buffer, 0);
uint32_t *data = (uint32_t *) buffer.bits;
uint32_t *dest = buf + top * WIDTH + left;
int w = right - left;
for (y = top; y < bottom; y++) {
copy_px((uint8_t *) dest, color_p, w);
dest += WIDTH;
color_p += w;
}
uint32_t *src = buf;
for (int i = 0; i < buffer.height; i++) {
memcpy(data, src, WIDTH * 4);
src += WIDTH;
data += buffer.stride;
}
ANativeWindow_unlockAndPost(window);
lv_disp_flush_ready(disp_drv);
}
根据我们前面创建的SurfaceView可以知道,我们的目的是将SurfaceView作为LGVL的屏幕,将其渲染的图像显示在SurfaceView上。
从window_flush函数入手。函数有三个参数。分别代表:显示驱动,刷新区域和像素点数组(包含像素的颜色数据)。
首先,对刷新区域的边界做一些判断,如果超出屏幕的显示范围,我们就做一下限定。
其次,我们锁定SurfaceView。
然后,我们遍历此次需要更新的区域,再将LVGL计算出来的像素点转换成SurfaceView能显示的格式。
最后将数组拷贝到SurfaceView中,然后解除锁定,并主动触发SurfaceView的绘制。至此,我们在LVGL中渲染的图像就能显示在屏幕上了。
移植输入设备
LVGL支持多种输入设备,比如:触摸、鼠标、键盘、按键和编码器。我们本次要实现的是触摸设备。
触摸的移植相对于显示设备来说,更加简单。我们只需要添加一个触摸回调函数就行了。LVGL会在一定时间间隔内回调该函数,获取触摸数据。不得不说的是,LVGL不支持多点触摸,这多多少少有点遗憾。
你可能会想到:客户的触摸和LVGL的回调存在较大概率是不一致的。如果回调时,触摸已经结束,那么可能会漏掉触摸事件。这是个很好的问题,不过,在LVGL中不是个问题,因为回调的周期是30ms,所以,基本不可能漏掉。如果你感觉这个时间有点长,可以在配置文件中去更改LV_INDEV_DEF_READ_PERIOD属性。
但是,从程序上讲,毕竟回调和触摸不是同一时间的,所以,我们需要将触摸事件保存下来。
首先,定义一个触摸结构体,用以保存触摸事件。
struct TouchState {
int32_t x;
int32_t y;
bool is_touched;
};
static TouchState state;
然后,我们定义并注册触摸设备。
//输入设备注册
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = LvTouchRead;
lv_indev_drv_register(&indev_drv);
这其中,我们定义输入设备类型是POINTER,同时设置了回调函数。回调函数如下:
static void LvTouchRead(lv_indev_drv_t *drv, lv_indev_data_t *data) {
if (state.is_touched) {
data->point.x = state.x;
data->point.y = state.y;
data->state = LV_INDEV_STATE_PR;
} else {
data->state = LV_INDEV_STATE_REL;
}
}
代码中的state,就是我们上面定义的触摸事件存储变量。该函数每次被回调时,都根据存储的状态,返回给LVGL。
那么这个state在哪里被更新呢?
我们在前面的移植步骤中,创建了一个native方法---nativeTouch
public static native void nativeTouch(int x, int y, boolean touch)
该方法在LVGLSurfaceView中被调用
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.SurfaceView;
public class LVGLSurfaceView extends SurfaceView {
public LVGLSurfaceView(Context context) {
super(context);
getHolder().addCallback(new LVGLHolderCallback());
}
public LVGLSurfaceView(Context context, AttributeSet attrs) {
super(context, attrs);
getHolder().addCallback(new LVGLHolderCallback());
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
if (event.getAction() == MotionEvent.ACTION_UP)
LVGLEntrance.nativeTouch(x, y, false);
else
LVGLEntrance.nativeTouch(x, y, true);
return true;
}
}
这样,当用户在屏幕上触摸时,将会把触摸信息传送到JNI层,而JNI中,我们更行了state变量。
extern "C"
JNIEXPORT void JNICALL
Java_com_hybird_lvgl_android_lvgl_LVGLEntrance_nativeTouch(JNIEnv *env, jclass clazz, jint x,
jint y, jboolean touch) {
state.x = x;
state.y = y;
state.is_touched = touch;
}
如此,当用户触摸时,LVGL中自然会得到触摸数据。
定时处理器
我们需要周期性的调用定时处理器来实现定时器的功能。LVGL的渲染任务是基于定时处理器的,所以一定要实现该功能。这个比较容易实现。我们开启一个线程,然后定时调用lv_task_handler()函数即可。
static void *refresh_task(void *data) {
while (run) {
lv_task_handler();
usleep(1000);
}
__android_log_print(ANDROID_LOG_ERROR, "LVGL", "func:%s. refresh task finished.", __func__);
return 0;
}
run = true;
pthread_create(&thread, 0, refresh_task, 0);
绑定
一切准备就绪,我们将SurfeceView与LVGL在Native层进行绑定。
extern "C"
JNIEXPORT void JNICALL
Java_com_hybird_lvgl_android_lvgl_LVGLEntrance_nativeCreate(JNIEnv *env, jclass clazz,
jobject surface) {
__android_log_print(ANDROID_LOG_ERROR, "LVGL", "func:%s", __func__);
window = ANativeWindow_fromSurface(env, surface);
lv_init();
}
extern "C"
JNIEXPORT void JNICALL
Java_com_hybird_lvgl_android_lvgl_LVGLEntrance_nativeChanged(JNIEnv *env, jclass clazz,
jobject surface, jint width,
jint height) {
__android_log_print(ANDROID_LOG_ERROR, "LVGL", "func:%s", __func__);
if (run) {
return;
}
//显示屏初始化
WIDTH = width;
HEIGHT = height;
buf = new uint32_t[WIDTH * HEIGHT];
lv_disp_draw_buf_init(&lv_disp_buf, lv_buf_1, lv_buf_2, DISP_BUF_SIZE);
static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = WIDTH;
disp_drv.ver_res = HEIGHT;
disp_drv.draw_buf = &lv_disp_buf;
// TODO Use acceleration structure for drawing
disp_drv.flush_cb = window_flush;
lv_disp_drv_register(&disp_drv);
//输入设备注册
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = LvTouchRead;
lv_indev_drv_register(&indev_drv);
//设置格式
ANativeWindow_setBuffersGeometry(window, WIDTH, HEIGHT, WINDOW_FORMAT_RGBA_8888);
clearScreen();
gui_init();
run = true;
pthread_create(&thread, 0, refresh_task, 0);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_hybird_lvgl_android_lvgl_LVGLEntrance_nativeDestroy(JNIEnv *env, jclass clazz,
jobject surface) {
run = false;
__android_log_print(ANDROID_LOG_ERROR, "LVGL", "func:%s", __func__);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_hybird_lvgl_android_lvgl_LVGLEntrance_nativeTouch(JNIEnv *env, jclass clazz, jint x,
jint y, jboolean touch) {
state.x = x;
state.y = y;
state.is_touched = touch;
}
测试程序
上面的代码中,我们调用了一个gui_init()函数,该函数即为我们的GUI程序入口。在这里,我们添加一个ui.cpp文件,来编写一个测试程序。
//
// Created by jayyu on 2022/3/29.
//
#include "ui.h"
void gui_init() {
lv_example_btn_1();
}
void event_handler(lv_event_t *e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED) {
LV_LOG_USER("Clicked");
} else if (code == LV_EVENT_VALUE_CHANGED) {
LV_LOG_USER("Toggled");
}
}
void lv_example_btn_1(void) {
lv_obj_t *label;
static lv_style_t style_sel;
lv_style_init(&style_sel);
lv_style_set_text_font(&style_sel, &lv_font_montserrat_44);
lv_obj_t *btn1 = lv_btn_create(lv_scr_act());
lv_obj_add_event_cb(btn1, event_handler, LV_EVENT_ALL, NULL);
lv_obj_align(btn1, LV_ALIGN_CENTER, 0, -220);
lv_obj_set_size(btn1, 320, 120);
label = lv_label_create(btn1);
lv_obj_add_style(label, &style_sel, 0);
lv_label_set_text(label, "Button");
lv_obj_center(label);
lv_obj_t *btn2 = lv_btn_create(lv_scr_act());
lv_obj_add_event_cb(btn2, event_handler, LV_EVENT_ALL, NULL);
lv_obj_align(btn2, LV_ALIGN_CENTER, 0, 40);
lv_obj_set_size(btn2, 320, 120);
lv_obj_add_flag(btn2, LV_OBJ_FLAG_CHECKABLE);
// lv_obj_set_height(btn2, LV_SIZE_CONTENT);
label = lv_label_create(btn2);
lv_obj_add_style(label, &style_sel, 0);
lv_label_set_text(label, "Toggle");
lv_obj_center(label);
}
代码比较简单,就是在页面上添加了两个按钮。
最终的效果是如下
点击Toggle按钮之后,它将会变成红色。这验证了我们的触摸也移植成功了。
总结
LVGL是一个低内存消耗、可扩展性非常强的图形库。常常用在内存只有几十KB的嵌入式设备上。它以很小的内存消耗,提供了按钮、图表、列表、滑块、图像和视频播放器等丰富的开箱即得的组件。它除了可以运行在低端MCU上,还可以运行在树莓派等linux设备上,给工程师提供了很大的想象空间。本文把它移植在Android系统上,也能显示出不俗的效果。这也可以说明,该图形库,在iOS设备上运行,也是可行的。如果将它搭配JavaScript引擎+Vue.js,将会提供一种新的胯端开发应用的新思路。
本文的目的是对LVGL在Android系统上运行进行可行性分析。中间还有一些不完善的地方,比如:LVGL提供了GPU加速功能,我们还没有用上。也没有完成Android虚拟键盘的移植。本文,更希望能够给大家提供一些新的思路和想法。
最后,附上代码gitee搜索 lvgl-android。大家要是感觉有启发性,可以star。