littlevgl_7.11源码分析(2)--Apple的学习笔记

一,前言

接着上一篇littlevgl_7.11源码分析(1)--Apple的学习笔记,继续看更新绘图到显示的过程吧!就把重点放在接着要执行的task任务_lv_disp_refr_task函数中。不过这个函数源码写的好难看,已经超过50行了,不能封装一下嘛!我把里面的一些stm32 3D加速的宏开关关闭的内容都删除了,依然那么长。因为看惯优秀代码了,所以比较挑剔,哈哈~

二,_lv_disp_refr_task绘图源码分析

我理解的一些含义都用中文注释写在了语句的上方了。
我先分析的是如下第一层的代码。而且是我一边调试验证一边分析的,通过此方法来降低难度,并且及时纠正错误的理解。其中用双缓冲方式进行重绘里面lv_refr_vdb_flush就直接绘制,绘制完就切换framebuffer指针,等到while(vdb->flushing)显示更新完成后,再将已经绘制显示的内容copy到另外一个framebuffer。简单来看双framebuffer的切换用法是绘制完成后切换指针,并且copy显示内容便于下次更新。但是我之前理解的双缓冲概念是渲染完成后交换指针,然后一个用于显示,一个用于继续渲染整个屏幕。这里是显示结束后交换指针。
感觉哪里不对,于是我去官网又看了下porting,原来它都是用的简单的porting填充,所以flush_cb中用绘制像素的方式一点点进行绘制,其实按双缓冲的设计思路flush_cb回调函数中用户应该不直接进行lcd显示,通知另外的线程进行显示,那么就和我之前理解一样了,渲染完成后交换指针,原先的已完成渲染则用于显示,而后面的用于继续渲染。而我的stm32是DMA方式的lcd显示,所以flush_cb会调用函数中我问填写的就直接显示的函数了。但是其实我验证了下,在flush_cb中添加while(1),lv_task_handler函数就被阻塞了,说明lvgl的flush_cb中若添加了lcd显示,那么渲染和lcd是在一个线程按顺序来的,双缓存没用起来。但是若添加其他task线程就另当别论咯~比如一个线程渲染完成后在更新显示中,另外一个线程又开始绘图了,此时双缓冲就起到做用了。
在此函数中lv_refr_join_area合并区域。目的就是可以少绘制些,也算是性能优化的设计,达到同一个位置只画一次。
lv_refr_areas就有讲究了,一开始我没理解,后来看了官网说是有3中framebuffer。第一种是只有一个framebuffer。第二种是有双framebuffer,但是小于显示区域。第三种是有双framebuffer大于等于显示区域。我上面描述的关于双framebuffer就是第3种,而lv_refr_join_area是针对初始化为第二种双framebuffer时候,flush_cb直接在lv_refr_join_area中画的,此时if(lv_disp_is_true_double_buf(disp_refr))条件不满足,所以要么在lv_refr_join_area中更新显示,要么在lv_refr_vdb_flush中更新显示,这取决于初始化的时候设置的双缓存的大小符合第二类还是第三类。然后lv_refr_areas中除了显示,在显示前还会填充区域内容。因为昨天分析的无效区域待显示,这个区域都是rect,里面没有包括像素内容填充。添加像素内容可以叫做渲染。图像GUI都是先渲染到framebuffer然后显示,所以lv_refr_areas函数很重要。

void _lv_disp_refr_task(lv_task_t * task)
{
    LV_LOG_TRACE("lv_refr_task: started");

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

    disp_refr = task->user_data;

#if LV_USE_PERF_MONITOR == 0
    /* Ensure the task does not run again automatically.
     * This is done before refreshing in case refreshing invalidates something else.
     */
    lv_task_set_prio(task, LV_TASK_PRIO_OFF);
#endif

    /*Do nothing if there is no active screen*/
    if(disp_refr->act_scr == NULL) {
        disp_refr->inv_p = 0;
        return;
    }
    /* 连接的区域进行拼接 */
    lv_refr_join_area();
    /* 在区域中重绘,并且非真正意义的双缓存则在此函数中直接一部分一部分的进行显示 */
    lv_refr_areas();
    /* 用双缓冲方式进行重绘 */
    /*If refresh happened ...*/
    if(disp_refr->inv_p != 0) {
        /* In true double buffered mode copy the refreshed areas to the new VDB to keep it up to date.
         * With set_px_cb we don't know anything about the buffer (even it's size) so skip copying.*/
        /* 这里判断是否Two screen-sized framebuffers */
        if(lv_disp_is_true_double_buf(disp_refr)) {
            if(disp_refr->driver.set_px_cb) {
                LV_LOG_WARN("Can't handle 2 screen sized buffers with set_px_cb. Display is not refreshed.");
            }
            else {
                /* 获取初始化时候通过lv_disp_buf_init注册的buffer */
                lv_disp_buf_t * vdb = lv_disp_get_buf(disp_refr);

                /*Flush the content of the VDB*/
                lv_refr_vdb_flush();
                /* 这里的while循环会导致死机吧,最好设计一个计数timeout可以支持错误退出 */
                /* With true double buffering the flushing should be only the address change of the
                 * current frame buffer. Wait until the address change is ready and copy the changed
                 * content to the other frame buffer (new active VDB) to keep the buffers synchronized*/
                while(vdb->flushing);

                lv_color_t * copy_buf = NULL;
                copy_buf = _lv_mem_buf_get(disp_refr->driver.hor_res * sizeof(lv_color_t));
                /* 切换缓冲区 */
                uint8_t * buf_act = (uint8_t *)vdb->buf_act;
                uint8_t * buf_ina = (uint8_t *)vdb->buf_act == vdb->buf1 ? vdb->buf2 : vdb->buf1;
                /* 获取水平宽度 */
                lv_coord_t hres = lv_disp_get_hor_res(disp_refr);
                uint16_t a;
                /* 通过inv_p来检查本次显示若有无效区域则copy到另外一个进行同步备份到另外一个framebuffer中 */
                for(a = 0; a < disp_refr->inv_p; a++) {
                    if(disp_refr->inv_area_joined[a] == 0) {
                        uint32_t start_offs =
                            (hres * disp_refr->inv_areas[a].y1 + disp_refr->inv_areas[a].x1) * sizeof(lv_color_t);

                        lv_coord_t y;
                        uint32_t line_length = lv_area_get_width(&disp_refr->inv_areas[a]) * sizeof(lv_color_t);

                        for(y = disp_refr->inv_areas[a].y1; y <= disp_refr->inv_areas[a].y2; y++) {
                            /* The frame buffer is probably in an external RAM where sequential access is much faster.
                             * So first copy a line into a buffer and write it back the ext. RAM */
                            /* 制作一个临时的copy_buf,用来做性能优化的,万一是外部ram先copy到内部再copy到另外一个外部地址区域,这样会快些 */
                            _lv_memcpy(copy_buf, buf_ina + start_offs, line_length);
                            _lv_memcpy(buf_act + start_offs, copy_buf, line_length);
                            start_offs += hres * sizeof(lv_color_t);
                        }
                    }
                }
                /* 释放临时缓存 */
                if(copy_buf) _lv_mem_buf_release(copy_buf);
            }
        } /*End of true double buffer handling*/

        /*Clean up*/
        /* 清楚已经更新过的缓存区域 */
        _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;
        /* monitor_cb函数用来做render时间监控的 */
        elaps = lv_tick_elaps(start);
        /*Call monitor cb if present*/
        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();
}

那么接下来详细分析_lv_disp_refr_task中的函数lv_refr_join_area。之前我已经提及了目的是合并待重绘区域,减少同一个位置的重绘。它用了双层for循环依次对比第一个和后续所有图像区是否有重叠,有的话就合并到被对比的对象,然后依次到最后一个。但是仔细看了_lv_area_is_on函数,应该表达为若有包含关系的,则进行区域合并,若只有一点点重叠是不会合并的,因为_lv_area_join函数内容也很简单。分析到此说明lvgl关于重叠区域合并代码还没有完善,只是写了个框架函数。
但是我又仔细相关,若是我说的只要有2个叠加区域则合并,那么合并出来若必需要用rect方框来表示的话,其实区间会变大,反而不优化。这样引出了一问题,为什么绘制区域都是用矩形表示的呢,我估计这样做的原因是容易处理。要是绘图区域是个不规则图像好像也不好用c语言表达哦,所以绘图区域画布framebuffer都是用矩形表达。
这个函数简单来看就是区域排序,把大的区域放前面。其它分析详见下方的中文注释。

static void lv_refr_join_area(void)
{
    uint32_t join_from;
    uint32_t join_in;
    lv_area_t joined_area;
    for(join_in = 0; join_in < disp_refr->inv_p; join_in++) {
        /* 检查当前inv_area_joined中的标记若已经被合并则跳过 */
        if(disp_refr->inv_area_joined[join_in] != 0) continue;

        /*Check all areas to join them in 'join_in'*/
        for(join_from = 0; join_from < disp_refr->inv_p; join_from++) {
            /*Handle only unjoined areas and ignore itself*/
            /* 检查当前inv_area_joined中的标记若已经被合并则跳过,指向同一个对象也跳过 */
            if(disp_refr->inv_area_joined[join_from] != 0 || join_in == join_from) {
                continue;
            }

            /*Check if the areas are on each other*/
            /* 判断后面的一个区间包含在前面区间中,则取前面的一个区间到joined_area中,因为它最大 */
            if(_lv_area_is_on(&disp_refr->inv_areas[join_in], &disp_refr->inv_areas[join_from]) == false) {
                continue;
            }
            /* 合并获取最大边框到joined_area中 */
            _lv_area_join(&joined_area, &disp_refr->inv_areas[join_in], &disp_refr->inv_areas[join_from]);

            /*Join two area only if the joined area size is smaller*/
            if(lv_area_get_size(&joined_area) < (lv_area_get_size(&disp_refr->inv_areas[join_in]) +
                                                 lv_area_get_size(&disp_refr->inv_areas[join_from]))) {
                /* 赋值到无效区域中 */
                lv_area_copy(&disp_refr->inv_areas[join_in], &joined_area);
                /* 标记已经添加到join_in */
                /*Mark 'join_form' is joined into 'join_in'*/
                disp_refr->inv_area_joined[join_from] = 1;
            }
        }
    }
}

_lv_area_is_on的含义是检查a2_p被包含在a1_p区间内。因为用的是&条件。

bool _lv_area_is_on(const lv_area_t * a1_p, const lv_area_t * a2_p)
{
    if((a1_p->x1 <= a2_p->x2) && (a1_p->x2 >= a2_p->x1) && (a1_p->y1 <= a2_p->y2) && (a1_p->y2 >= a2_p->y1)) {
        return true;
    }
    else {
        return false;
    }
}

_lv_area_join就是获取2者中最大的外框,由于之前_lv_area_is_on已经检查返回true,所以其实a_res_p就是取a1_p。

void _lv_area_join(lv_area_t * a_res_p, const lv_area_t * a1_p, const lv_area_t * a2_p)
{
    a_res_p->x1 = LV_MATH_MIN(a1_p->x1, a2_p->x1);
    a_res_p->y1 = LV_MATH_MIN(a1_p->y1, a2_p->y1);
    a_res_p->x2 = LV_MATH_MAX(a1_p->x2, a2_p->x2);
    a_res_p->y2 = LV_MATH_MAX(a1_p->y2, a2_p->y2);
}

接着分析_lv_disp_refr_task中的函数lv_refr_areas,这个函数的逻辑结构比较简单,然后经历过上面的一些分析,对这些变量的含义都已经理解了,所以lv_refr_areas函数是非常容易理解的。然后关于area和part的关系。就会提及到我之前说的初始化为第二种双framebuffer,它比屏幕小的的framebuffer,还记得lv_disp_is_true_double_buf函数吗?就是用来判断是否真的双framebuffer,因为正常理解的双framebuffer都是和显示区域一样大小的画布,而这第二种双framebuffer不是真正意义的双缓存,所以处理机制不同,它小于area绘图区间,用多次操作part方式绘制每个area,每次part区间的绘制都会调用flush_cb进行显示。注释如下

static void lv_refr_areas(void)
{
    px_num = 0;

    if(disp_refr->inv_p == 0) return;

    /*Find the last area which will be drawn*/
    int32_t i;
    int32_t last_i = 0;
    /* 因为之前已经合并排序过,把大的区域放前面,所以用倒叙搜索最后一个index值速度会比较快 */
    for(i = disp_refr->inv_p - 1; i >= 0; i--) {
        /* 若标记为1,已经被合并,不绘制,只有标记为0的才参与重绘 */
        if(disp_refr->inv_area_joined[i] == 0) {
            last_i = i;
            break;
        }
    }

    disp_refr->driver.buffer->last_area = 0;
    disp_refr->driver.buffer->last_part = 0;
    /* 依次重绘 */
    for(i = 0; i < disp_refr->inv_p; i++) {
        /*Refresh the unjoined areas*/
        if(disp_refr->inv_area_joined[i] == 0) {
            /* 已经绘制到最后一个,则标记last_area为1 */
            if(i == last_i) disp_refr->driver.buffer->last_area = 1;
            /* 标记为此区域的第一个part开始咯! */
            disp_refr->driver.buffer->last_part = 0;
            /* 进行当个区间的重绘 */
            lv_refr_area(&disp_refr->inv_areas[i]);
            /* px_num变量是做log用的,统计检测帧率的时候会用到,返回的是这一帧的像素大小值 */
            px_num += lv_area_get_size(&disp_refr->inv_areas[i]);
        }
    }
}

lv_refr_area就是对区间填充绘图像素内容,将来用于flush_cb显示的。若是真正意义的双缓存则last_part直接直接为1了。如下第一个if语句中,然后就填充绘图区域了,但是更新显示不在此函数中。但是对于非真正意义的双缓冲就会进入else分支获取每次要更新的显示行数,通过for循环几行几行的进行绘制。rounder_cb我先不分析。

static void lv_refr_area(const lv_area_t * area_p)
{
    /*True double buffering: there are two screen sized buffers. Just redraw directly into a
     * buffer*/
    /* 进入真正意义的双缓存,即第三种初始化方式对应的的绘制方法 */
    if(lv_disp_is_true_double_buf(disp_refr)) {
        lv_disp_buf_t * vdb = lv_disp_get_buf(disp_refr);
        vdb->area.x1        = 0;
        vdb->area.x2        = lv_disp_get_hor_res(disp_refr) - 1;
        vdb->area.y1        = 0;
        vdb->area.y2        = lv_disp_get_ver_res(disp_refr) - 1;
        disp_refr->driver.buffer->last_part = 1;
        /* 1个area就一个part,重绘 */
        lv_refr_area_part(area_p);
    }
    /* 进入非真正意义的双缓存,即第二种初始化方式对应的的绘制方法 */
    /*The buffer is smaller: refresh the area in parts*/
    else {
        lv_disp_buf_t * vdb = lv_disp_get_buf(disp_refr);
        /*Calculate the max row num*/
        lv_coord_t w = lv_area_get_width(area_p);
        lv_coord_t h = lv_area_get_height(area_p);
        lv_coord_t y2 =
            area_p->y2 >= lv_disp_get_ver_res(disp_refr) ? lv_disp_get_ver_res(disp_refr) - 1 : area_p->y2;
        /* 进行分割,每max_row当做一个part进行绘制及更新显示 */
        int32_t max_row = (uint32_t)vdb->size / w;

        if(max_row > h) max_row = h;
        /* 这个回调函数官网port章节中介绍将2*2可以改成2*8,我先不分析 */
        /*Round down the lines of VDB if rounding is added*/
        if(disp_refr->driver.rounder_cb) {
            lv_area_t tmp;
            tmp.x1 = 0;
            tmp.x2 = 0;
            tmp.y1 = 0;

            lv_coord_t h_tmp = max_row;
            do {
                tmp.y2 = h_tmp - 1;
                disp_refr->driver.rounder_cb(&disp_refr->driver, &tmp);

                /*If this height fits into `max_row` then fine*/
                if(lv_area_get_height(&tmp) <= max_row) break;

                /*Decrement the height of the area until it fits into `max_row` after rounding*/
                h_tmp--;
            } while(h_tmp > 0);

            if(h_tmp <= 0) {
                LV_LOG_WARN("Can't set VDB height using the round function. (Wrong round_cb or to "
                            "small VDB)");
                return;
            }
            else {
                max_row = tmp.y2 + 1;
            }
        }

        /*Always use the full row*/
        lv_coord_t row;
        lv_coord_t row_last = 0;
        /* 每隔max_row行进行区间内绘制 */
        for(row = area_p->y1; row + max_row - 1 <= y2; row += max_row) {
            /*Calc. the next y coordinates of VDB*/
            vdb->area.x1 = area_p->x1;
            vdb->area.x2 = area_p->x2;
            vdb->area.y1 = row;
            vdb->area.y2 = row + max_row - 1;
            if(vdb->area.y2 > y2) vdb->area.y2 = y2;
            row_last = vdb->area.y2;
            /* 若是整除,则标记已完成last_part绘制 */
            if(y2 == row_last) disp_refr->driver.buffer->last_part = 1;
            /* 重绘 */
            lv_refr_area_part(area_p);
        }
        /* 不是整除,则将剩余行数进行绘制,标记已完成last_part */
        /*If the last y coordinates are not handled yet ...*/
        if(y2 != row_last) {
            /*Calc. the next y coordinates of VDB*/
            vdb->area.x1 = area_p->x1;
            vdb->area.x2 = area_p->x2;
            vdb->area.y1 = row;
            vdb->area.y2 = y2;

            disp_refr->driver.buffer->last_part = 1;
            /* 重绘 */
            lv_refr_area_part(area_p);
        }
    }
}

在来展开里面的lv_refr_area_part函数分析下。这个里面出现了一个概念,就是mask,我以前自己玩ps图片的时候经常用到mask的概念,不过那个mask是掩模的概念,打掩模的区域我无法上色起到保护作用。而lvgl的mask参数是用来说明只能在mask区域进行redraw。其中比较关键就是_lv_area_intersect函数,提前part area和当前激活的画布的公共部分放入mask,说白了就只能重绘mask公共部分的意思。
但是这个函数中prev_scr概念我不清楚,为什么要重绘之前的帧,另外,lv_refr_obj_and_children和obj及元素样式等相关,我先不展开了。而对每个part的更新显示则是在lv_refr_vdb_flush函数中实现。

static void lv_refr_area_part(const lv_area_t * area_p)
{
    lv_disp_buf_t * vdb = lv_disp_get_buf(disp_refr);

    /*In non double buffered mode, before rendering the next part wait until the previous image is
     * flushed*/
    if(lv_disp_is_double_buf(disp_refr) == false) {
        while(vdb->flushing) {
            if(disp_refr->driver.wait_cb) disp_refr->driver.wait_cb(&disp_refr->driver);
        }
    }

    lv_obj_t * top_act_scr = NULL;
    lv_obj_t * top_prev_scr = NULL;

    /*Get the new mask from the original area and the act. VDB
     It will be a part of 'area_p'*/
    lv_area_t start_mask;
    /* 提取公共部分设置mask区域 */
    _lv_area_intersect(&start_mask, area_p, &vdb->area);
    /* 提取当前的屏幕中的顶层元素 */
    /*Get the most top object which is not covered by others*/
    top_act_scr = lv_refr_get_top_obj(&start_mask, lv_disp_get_scr_act(disp_refr));
    if(disp_refr->prev_scr) {
        top_prev_scr = lv_refr_get_top_obj(&start_mask, disp_refr->prev_scr);
    }
    /* 这段满足if条件像是第一次绘制,都没有背景,我先不分析 */
    /*Draw a display background if there is no top object*/
    if(top_act_scr == NULL && top_prev_scr == NULL) {
        if(disp_refr->bg_img) {
            lv_draw_img_dsc_t dsc;
            lv_draw_img_dsc_init(&dsc);
            dsc.opa = disp_refr->bg_opa;
            lv_img_header_t header;
            lv_res_t res;
            res = lv_img_decoder_get_info(disp_refr->bg_img, &header);
            if(res == LV_RES_OK) {
                lv_area_t a;
                lv_area_set(&a, 0, 0, header.w - 1, header.h - 1);
                lv_draw_img(&a, &start_mask, disp_refr->bg_img, &dsc);
            }
            else {
                LV_LOG_WARN("Can't draw the background image")
            }
        }
        else {
            lv_draw_rect_dsc_t dsc;
            lv_draw_rect_dsc_init(&dsc);
            dsc.bg_color = disp_refr->bg_color;
            dsc.bg_opa = disp_refr->bg_opa;
            lv_draw_rect(&start_mask, &start_mask, &dsc);

        }
    }
    /* 还要重新绘制之前的屏幕 */
    /*Refresh the previous screen if any*/
    if(disp_refr->prev_scr) {
        /*Get the most top object which is not covered by others*/
        if(top_prev_scr == NULL) {
            top_prev_scr = disp_refr->prev_scr;
        }
        /*Do the refreshing from the top object*/
        lv_refr_obj_and_children(top_prev_scr, &start_mask);

    }

    if(top_act_scr == NULL) {
        top_act_scr = disp_refr->act_scr;
    }
    /* 这个区间内的元素内容绘制到当前的屏幕buffer中 */
    /*Do the refreshing from the top object*/
    lv_refr_obj_and_children(top_act_scr, &start_mask);

    /*Also refresh top and sys layer unconditionally*/
    lv_refr_obj_and_children(lv_disp_get_layer_top(disp_refr), &start_mask);
    lv_refr_obj_and_children(lv_disp_get_layer_sys(disp_refr), &start_mask);

    /* In true double buffered mode flush only once when all areas were rendered.
     * In normal mode flush after every area */
    if(lv_disp_is_true_double_buf(disp_refr) == false) {
        /* 若非真正意义的双framebuffer,现在就刷新显示区域 */
        lv_refr_vdb_flush();
    }
}

lv_refr_vdb_flush函数来分析下,这里面涉及的内容不多,变量也都是flag类的,之前含义都理解过。最主要的就是
disp->driver.flush_cb(&disp->driver, &vdb->area, color_p);回调函数,就是用户接口,会调用stm32中的LCD绘制函数,最后交替下双framebuffer。然后说下wait_cb类回调函数,由于我没使用单framebuffer,所以用不到,按官网介绍单framebuffer就需要用到此类回调函数,等待上一次显示结束后才能更新绘图内容及做下一次显示。

static void lv_refr_vdb_flush(void)
{
    lv_disp_buf_t * vdb = lv_disp_get_buf(disp_refr);
    lv_color_t * color_p = vdb->buf_act;

    /*In double buffered mode wait until the other buffer is flushed before flushing the current
     * one*/
    if(lv_disp_is_double_buf(disp_refr)) {
        while(vdb->flushing) {
            if(disp_refr->driver.wait_cb) disp_refr->driver.wait_cb(&disp_refr->driver);
        }
    }
    /* 设置一些标志位 */
    vdb->flushing = 1;

    if(disp_refr->driver.buffer->last_area && disp_refr->driver.buffer->last_part) vdb->flushing_last = 1;
    else vdb->flushing_last = 0;

    /*Flush the rendered content to the display*/
    lv_disp_t * disp = _lv_refr_get_disp_refreshing();
    if(disp->driver.gpu_wait_cb) disp->driver.gpu_wait_cb(&disp->driver);
    /* 更新显示 */
    if(disp->driver.flush_cb) {
        /*Rotate the buffer to the display's native orientation if necessary*/
        if(disp->driver.rotated != LV_DISP_ROT_NONE && disp->driver.sw_rotate) {
            lv_refr_vdb_rotate(&vdb->area, vdb->buf_act);
        }
        else {
            disp->driver.flush_cb(&disp->driver, &vdb->area, color_p);
        }
    }
    /* 通过指针操作方便的替换缓存 */
    if(vdb->buf1 && vdb->buf2) {
        if(vdb->buf_act == vdb->buf1)
            vdb->buf_act = vdb->buf2;
        else
            vdb->buf_act = vdb->buf1;
    }
}

三,总结

关于渲染绘图区,刷新显示都分析完毕了,主要分析了第二类和第三类双缓存的使用机制。总的来说,目前看下来lvgl的双缓冲不是用来并行显示和渲染的,可能只能用来并行多线程的渲染。通过质疑lvgl源码中的设计,来思考它为什么要这样做,而我认为是怎么做,在对比中就能看出方案优劣,从而取其精华去其糟粕!

你可能感兴趣的:(littlevgl_7.11源码分析(2)--Apple的学习笔记)