LVGL源码学习之渲染、更新过程(1)---标记和激活

发布于:2025-05-10 ⋅ 阅读:(14) ⋅ 点赞:(0)

LVGL版本:8.1

在正式探究源码前,先对几个和更新相关的名词进行说明,有些名词后面可能会混用:

  • 渲染(render):指的是将发生修改的对象,根据修改的内容,重新写入显示缓存;
  • 绘制(draw):在LVGL中可以等同于渲染概念;
  • 融合(blend):在LVGL中特指写入缓存的过程;
  • 刷写(flush):将显示缓存写入显示器,这是写入硬件的过程;
  • 更新/刷新(refresh):指的是渲染/绘制和更新两个过程的结合,即:将修改的对象显示到显示器屏幕上。

LVGL的层级概念:

显示器(Display)是绘制和显示像素(flush)的硬件对象;

屏幕(screen)是显示器下的高级根对象,一个显示器可以存在多个屏幕(同一时刻仅显示一个屏幕),但一个屏幕只对应一个显示器;

控件(Widgets),是LVGL图形界面的核心元素,是显示和交互的基石,上面所说的屏幕本质上也是一种控件。

缓冲模式(buffer):

       这里说的缓冲指的就是显示缓存,通常将单个缓冲大小设置为显示器的像素总数,如果内存紧缺,也可以设置一个较小的缓冲,同样可以实现正常的显示效果,只是效率会相对低一些。在LVGL中,可以设置单缓冲和双缓冲两种缓冲模式。

  • 单缓冲:缓冲同时用于刷写和更新,若当前正在刷写,则所有的渲染都需要等待,直至刷写完成;
  • 双缓冲:一个用于缓存刷写,另一个用于更新渲染,渲染完成后复制到另一个缓冲进行刷写(或交换地址进行刷写),两个缓冲并行工作;

更新模式:

  • 直写模式(direct):根据绝对位置坐标对显示缓存进行绘制,该模式下需要显示缓存大小等于显示器的像素总数,因此每片区域更新完毕后,可以直接将缓冲刷写到显示器上。如果设置了双缓冲,则每次渲染完毕将更新的区域记录下来,留给另一块缓冲渲染使用;
  • 全刷新(full_refresh):每次发生更新,总是重新绘制整个屏幕(而非区域重绘),该模式和缓冲数量无关:在单缓冲下刷写和渲染不能同时进行;如果使用双缓冲,可以在刷写时仅交换缓冲地址;
  • 普通模式(normal):又称分块模式(partial),上述两种模式标志位都为0时触发,分块刷新区域。和直接模式不同的是,普通模式可以设置较小的缓冲,分块刷写到显示器硬件。

       以上相关概念的介绍,将会在接下来的源码分析经常使用到。

更新过程

       更新源自于对象的修改,从显示器、屏幕到组件层对象的修改都可以触发更新,因此从标记这些修改开始,开启LVGL渲染更新的过程。

标记和激活

       每次修改样式后(包括手动修改、动画修改等),都会触发更新,告诉系统要对被修改的对象节点进行标记,具体包括标记无效区域(invalidate area)标记脏对象(dirty)两种。

//lv_obj_style.c
void lv_obj_refresh_style(lv_obj_t * obj, lv_style_selector_t selector, lv_style_prop_t prop)
{
    LV_ASSERT_OBJ(obj, MY_CLASS);

    if(!style_refr) return;

    lv_obj_invalidate(obj);   //标记无效区域

    lv_part_t part = lv_obj_style_get_selector_part(selector);

    if(prop & LV_STYLE_PROP_LAYOUT_REFR) {  //需要更新布局
        if(part == LV_PART_ANY ||
           part == LV_PART_MAIN ||
           lv_obj_get_style_height(obj, 0) == LV_SIZE_CONTENT ||
           lv_obj_get_style_width(obj, 0) == LV_SIZE_CONTENT) {
            lv_event_send(obj, LV_EVENT_STYLE_CHANGED, NULL);
            lv_obj_mark_layout_as_dirty(obj);   //标记脏数据
        }
    }
    if((part == LV_PART_ANY || part == LV_PART_MAIN) && (prop == LV_STYLE_PROP_ANY ||
       (prop & LV_STYLE_PROP_PARENT_LAYOUT_REFR))) {  //需要更新父节点的布局
        lv_obj_t * parent = lv_obj_get_parent(obj);
        if(parent) lv_obj_mark_layout_as_dirty(parent);  //标记脏数据
    }

    if(prop == LV_STYLE_PROP_ANY || (prop & LV_STYLE_PROP_EXT_DRAW)) {
        lv_obj_refresh_ext_draw_size(obj);  //更新额外绘制区域
    }
    lv_obj_invalidate(obj);  //标记无效区域

    if(prop == LV_STYLE_PROP_ANY ||
       ((prop & LV_STYLE_PROP_INHERIT) && ((prop & LV_STYLE_PROP_EXT_DRAW) || (prop & LV_STYLE_PROP_LAYOUT_REFR)))) {
        if(part != LV_PART_SCROLLBAR) {
            refresh_children_style(obj);
        }
    }
}

标记无效区域(invalidate area):

       所有无效区域都被存储到inv_areas[]中,且用inv_p标识当前的无效区域数量。

//lv_hal_disp.h
typedef struct _lv_disp_t {
    /*......*/
    /** Invalidated (marked to redraw) areas*/
    lv_area_t inv_areas[LV_INV_BUF_SIZE];  //数组,存储前面标记的所有无效区域坐标
    uint16_t inv_p;  //无效区域数量
    /*......*/
} lv_disp_t;

       具体标记函数为lv_obj_invalidate()

//lv_obj_pos.c
void lv_obj_invalidate(const lv_obj_t * obj)
{
    LV_ASSERT_OBJ(obj, MY_CLASS);

    /* 获取对象所在的区域坐标 */
    lv_area_t obj_coords;
    lv_coord_t ext_size = _lv_obj_get_ext_draw_size(obj);  //获取额外绘制大小
    lv_area_copy(&obj_coords, &obj->coords);
    obj_coords.x1 -= ext_size;
    obj_coords.y1 -= ext_size;
    obj_coords.x2 += ext_size;
    obj_coords.y2 += ext_size;

    lv_obj_invalidate_area(obj, &obj_coords);  //标记对象所在的无效区域
}

       底层调用_lv_inv_area(),对于全刷新模式(full_refresh),一旦对象发生样式修改,就会将全屏区域纳入无效区域,且仅更新一次。而其它模式下,将在更新任务到来之前,收集所有在此期间发生修改的无效区域,并记录数量。

//lv_refr.c
/**
 * 函数功能:将显示器某区域标记为无效区域
 * @param disp 指针,被标记区域属于哪个“显示器(displayer)”,就是“屏幕(screen)”的上层
 * @param area_p 指针,被标记的区域坐标范围(x1,y1,x2,y2)
 */
void _lv_inv_area(lv_disp_t * disp, const lv_area_t * area_p)
{
    if(!disp) disp = lv_disp_get_default();
    if(!disp) return;

    /* 传入区域为NULL时,将无效区域数组长度设为0,表示清除无效区域(invalidate area),这是个用于清除的特殊操作 */
    if(area_p == NULL) {
        disp->inv_p = 0;
        return;
    }

    lv_area_t scr_area;   //获取显示器的显示范围
    scr_area.x1 = 0;
    scr_area.y1 = 0;
    scr_area.x2 = lv_disp_get_hor_res(disp) - 1;
    scr_area.y2 = lv_disp_get_ver_res(disp) - 1;

    lv_area_t com_area;
    bool suc;

    suc = _lv_area_intersect(&com_area, area_p, &scr_area);  //取显示范围和无效区域的并集
    if(suc == false)  return; /* 超出显示范围,直接返回 */

    /* 在全刷新(full_refresh)模式下,只要发生样式更新,就设置无效区域为全屏,且仅更新一次 */
    if(disp->driver->full_refresh) {
        disp->inv_areas[0] = scr_area;  //刷新全屏,只需要设置第一个索引区域为全屏
        disp->inv_p = 1;   //只更新一次(全屏)
        lv_timer_resume(disp->refr_timer);   //恢复更新任务定时器,等待更新执行
        return;
    }
    /* 其它模式下,需要一个个将无效区域加入到数组中,并更新索引 */
    //有些设备只能匹配特定宽高的像素,需要对区域做舍入处理
    if(disp->driver->rounder_cb) disp->driver->rounder_cb(disp->driver, &com_area);

    /* 若当前无效区域是数组中已存在的区域的子集,则作废当前区域并返回 */
    uint16_t i;
    for(i = 0; i < disp->inv_p; i++) {
        if(_lv_area_is_in(&com_area, &disp->inv_areas[i], 0) != false) return;
    }

    /* 保存当前无效区域 */
    if(disp->inv_p < LV_INV_BUF_SIZE) {
        lv_area_copy(&disp->inv_areas[disp->inv_p], &com_area);
    }
    else {   /* 如果无效区域数量超出设定,则清空并从0保存 */
        disp->inv_p = 0;
        lv_area_copy(&disp->inv_areas[disp->inv_p], &scr_area);
    }
    disp->inv_p++;  //无效区域数量加1
    lv_timer_resume(disp->refr_timer);  //恢复更新任务定时器,告诉系统可以继续定时更新了
}

标记脏数据(dirty):

       当对象被修改时,有两种情况下可能会被标记为脏数据:

  • 如果带有更新布局的标志位(通常对象大小、位置、变换等样式会附带),就会被标记为脏数据以便后期更新布局;

       后面在布局更新时会再次提及,其最终目的还是为了标记无效区域

//lv_obj_pos.c
void lv_obj_mark_layout_as_dirty(lv_obj_t * obj)
{
    obj->layout_inv = 1;  //将对象标记为脏(无效invalidate)

    lv_obj_t * scr = lv_obj_get_screen(obj);
    scr->scr_layout_inv = 1;  //将对象所处的屏幕标记为脏(无效invalidate)

    lv_disp_t * disp = lv_obj_get_disp(scr);  //获取该对象所在的屏幕
    lv_timer_resume(disp->refr_timer);  //恢复显示器的刷新定时器,告诉系统该显示器可以继续定时更新了
}

        无论是无效区域还是脏数据的标记,在每次标记完后,都会启动更新任务的定时器(重复的启动仅以第一次有效),可以称这个过程为“激活”,在定时器到期后,系统会调用更新任务进行屏幕内容的刷新。

定时更新

       在注册显示器时,LVGL就为其创建了更新定时器,默认周期为30ms。上一节说到,在标记修改后会激活更新任务的定时器,就是启动该定时器,即:

//lv_hal_disp.c
lv_disp_t * lv_disp_drv_register(lv_disp_drv_t * driver)
{
    /*......*/
    disp->refr_timer = lv_timer_create(_lv_disp_refr_timer, LV_DISP_DEF_REFR_PERIOD, disp);
    /*......*/
}

       其中定时更新的函数_lv_disp_refr_timer()是本文的核心,内容有很多,主要包括布局更新区域合并区域分块刷新硬件刷写以及监视器更新(需要在配置文件激活),其中监视器内容本质上是重复上面的更新内容。下面将着重探究前面的几个重点部分。

//lv_refr.c
void _lv_disp_refr_timer(lv_timer_t * tmr)
{
    REFR_TRACE("begin");

    uint32_t start = lv_tick_get();
    volatile uint32_t elaps = 0;

    disp_refr = tmr->user_data;  //这里的user_data就是注册显示器时传入的display

#if LV_USE_PERF_MONITOR == 0 && LV_USE_MEM_MONITOR == 0
    /**
     * 如果使用了任意监视器,会在屏幕左下角(或右下角)实时显示CPU和内存使用情况,此时就不可以暂停更新定时器。
     * 否则必须暂停定时器,等待有脏数据产生再启动
     */
    lv_timer_pause(tmr);
#endif

    /* 更新各个屏幕内的布局(如有需要) */
    lv_obj_update_layout(disp_refr->act_scr);
    if(disp_refr->prev_scr) lv_obj_update_layout(disp_refr->prev_scr);

    lv_obj_update_layout(disp_refr->top_layer);
    lv_obj_update_layout(disp_refr->sys_layer);  //这个顺序更新,表明sys_layer > top_layer > act_scr

    /* 没有活跃的屏幕,则直接返回 */
    if(disp_refr->act_scr == NULL) {
        disp_refr->inv_p = 0;
        LV_LOG_WARN("there is no active screen");
        REFR_TRACE("finished");
        return;
    }

    lv_refr_join_area();  //合并无效区域,减少重叠区域带来的计算开销

    lv_refr_areas();  //刷新无效区域的内容到显示缓存,直写模式将直接刷写到显示器

    /* 如果存在无效区域更新 */
    if(disp_refr->inv_p != 0) {
        if(disp_refr->driver->full_refresh) {  //全刷新模式下,将在此刷写到显示器上
            draw_buf_flush();
        }

        /* 更新完毕后,清除所有无效区域 */
        lv_memset_00(disp_refr->inv_areas, sizeof(disp_refr->inv_areas));
        lv_memset_00(disp_refr->inv_area_joined, sizeof(disp_refr->inv_area_joined));
        disp_refr->inv_p = 0;

        elaps = lv_tick_elaps(start);
        /* 如果定义了监视器,则执行监视器回调函数 */
        if(disp_refr->driver->monitor_cb) {
            disp_refr->driver->monitor_cb(disp_refr->driver, elaps, px_num);
        }
    }

    lv_mem_buf_free_all();
    _lv_font_clean_up_fmt_txt();

    /*...省略监视器内容...*/

    REFR_TRACE("finished");
}

布局更新

       对显示器下的几个屏幕都进行一次布局上的更新,更新与否需要依据前面推送的脏数据,一旦某个屏幕上存在脏数据,就会对整个屏幕的内容进行布局更新。

//lv_obj_pos.c
void lv_obj_update_layout(const lv_obj_t * obj)
{
    static bool mutex = false;  //互斥信号量,最原始的实现方式
    if(mutex) {
        LV_LOG_TRACE("Already running, returning");
        return;
    }
    mutex = true;

    lv_obj_t * scr = lv_obj_get_screen(obj);  //获取当前对象所处的屏幕

    /* 更新布局,直到没有脏数据了,注意在更新过程中有可能产生新的脏数据 */
    while(scr->scr_layout_inv) {
        LV_LOG_INFO("Layout update begin");
        scr->scr_layout_inv = 0;  //将屏幕更新状态重置
        layout_update_core(scr);
        LV_LOG_TRACE("Layout update end");
    }

    mutex = false;
}
底层是一个递归调用更新:

做了两件事:

①常规布局更新。重新计算对象的大小和位置坐标(检查样式的变化),计算完毕后将对象坐标区域纳入无效区域;

②特殊布局更新。主要有两种:flex和grid布局,运用了这两种布局的对象会在这里进行额外更新。(如果用户有自己注册的布局,也会在这里更新)。

//lv_obj_pos.c
static void layout_update_core(lv_obj_t * obj)
{
    uint32_t i;
    uint32_t child_cnt = lv_obj_get_child_cnt(obj);  //获取孩子节点数量
    for(i = 0; i < child_cnt; i++) {  //遍历孩子节点,对这些节点做递归嵌套调用
        lv_obj_t * child = obj->spec_attr->children[i];
        layout_update_core(child);   //深度优先遍历
    }

    if(obj->layout_inv == 0) return;   //如果不是脏数据,则直接返回

    obj->layout_inv = 0;   //重置脏数据状态

    lv_obj_refr_size(obj);  //重新计算对象大小
    lv_obj_refr_pos(obj);  //重新计算目标位置坐标

    if(child_cnt > 0) {   //如果该对象有孩子节点,且使用了特殊布局,则运用该布局专属的回调函数进行额外更新
        uint32_t layout_id = lv_obj_get_style_layout(obj, LV_PART_MAIN);
        if(layout_id > 0 && layout_id <= layout_cnt) {
            void  * user_data = LV_GC_ROOT(_lv_layout_list)[layout_id - 1].user_data;
            LV_GC_ROOT(_lv_layout_list)[layout_id - 1].cb(obj, user_data);
        }
    }
}

       经过布局更新后,所有的脏数据都转变为了无效区域,接下来就是对所有的无效区域进行处理了。这部分内容留到下一篇继续分析。


网站公告

今日签到

点亮在社区的每一天
去签到