基于ESP32与LVGL的实时航班雷达系统开发实战

发布时间:2026/8/8 1:56:22
基于ESP32与LVGL的实时航班雷达系统开发实战 在实际嵌入式开发项目中ESP32因其强大的Wi-Fi/蓝牙连接能力和丰富的外设接口常被用于制作各种物联网终端。将一块触摸屏与ESP32结合打造一个能够实时显示航班雷达信息的桌面设备是一个集网络通信、数据解析、图形界面和硬件交互于一体的综合性实践项目。这个项目不仅考验开发者对ESP32固件开发框架如ESP-IDF或Arduino Core for ESP32的掌握还涉及UI设计工具如SquareLine Studio的使用、网络API的调用以及多任务处理能力。本文将以一个“自制实时航班雷达”项目为背景带你从零开始完成硬件选型、环境搭建、数据获取、UI设计到最终集成烧录的全过程。无论你是想深入学习ESP32在图形化应用上的开发还是希望获得一个完整的、可展示的物联网项目经验这篇文章都将提供一条清晰的实现路径。我们将重点使用ESP-IDF框架和SquareLine Studio进行开发因为这套组合能提供更接近工业级的开发体验和性能。1. 理解项目核心硬件选型与软件架构在动手写代码之前必须先明确整个系统的构成。一个实时航班雷达的核心是获取数据、处理数据并最终在屏幕上绘制出来。这决定了我们的硬件和软件栈。1.1 硬件组件清单与选型依据本项目需要以下核心硬件选型时需考虑兼容性和易用性。组件推荐型号/规格选型原因与注意事项主控MCUESP32-S3-WROOM-1相比经典ESP32S3系列主频更高240MHzPSRAM更大可选8MB更适合驱动高分辨率屏和处理图形数据。触摸显示屏带SPI接口的IPS屏如2.8寸/3.5寸分辨率320x240或480x320SPI屏接线简单对ESP32 GPIO占用少。分辨率不宜过高否则帧率会受限于SPI速度和ESP32性能。务必确认屏幕驱动芯片如ILI9341, ST7789有成熟的ESP-IDF驱动库。触摸控制器通常与屏幕集成如XPT2046电阻式或GT911电容式购买时需确认触摸芯片型号以便寻找或编写驱动。电容屏体验更好但驱动稍复杂。电源模块5V/2A Micro USB接口供电屏幕背光和ESP32全速运行时功耗较高需保证电源充足稳定避免因电压跌落导致系统重启。关键连接屏幕的SPI总线SCLK, MOSI, MISO连接ESP32的硬件SPI引脚如VSPI_HOST: GPIO 18, 23, 19。屏幕的DC数据/命令、RESET、CS引脚以及触摸芯片的INT和CS引脚可以连接到ESP32的其他任意GPIO但需要在代码中正确配置。1.2 软件架构与工作流程软件部分需要多个任务协同工作一个清晰的分层架构能避免代码混乱。网络任务负责通过Wi-Fi连接互联网并以一定时间间隔如每10秒向航班数据API发送HTTP/HTTPS请求获取原始JSON数据。数据解析任务接收网络任务获取的原始数据解析JSON提取关键信息如航班号、经纬度、高度、航向并将其转换为便于UI绘制的内部数据结构如结构体数组。UI渲染任务这是最核心的任务。它需要初始化屏幕驱动建立双缓冲如果支持以减少闪烁。绘制静态背景元素地图网格、机场位置、图例。从数据解析任务获取最新的航班数据根据经纬度换算为屏幕坐标并绘制代表飞机的图标三角形或小飞机图形及航向线。处理触摸事件实现点击飞机显示详情、拖动地图等交互。系统服务Wi-Fi连接管理、NTP对时、任务间通信如使用FreeRTOS队列传递数据、电源管理等。考虑到ESP32的双核特性可以将网络请求和数据解析放在一个核心如Core 0将UI渲染和触摸处理放在另一个核心Core 1充分利用硬件性能。2. 搭建开发环境ESP-IDF与SquareLine Studio工欲善其事必先利其器。一个稳定高效的开发环境是项目成功的基础。2.1 安装与配置ESP-IDFESP-IDF是乐鑫官方的开发框架功能最全对ESP32-S3等新芯片支持最好。安装依赖根据你的操作系统Windows/macOS/Linux安装必要的工具链。乐鑫官方推荐使用一键安装工具。# 对于Linux/macOS可以运行以下命令安装依赖 # Ubuntu/Debian: # sudo apt-get install git wget flex bison gperf python3 python3-pip python3-setuptools cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0获取ESP-IDF使用官方安装脚本或直接从GitHub克隆。# 进入你的工作目录如 ~/esp cd ~/esp # 克隆ESP-IDF仓库推荐使用指定版本如v5.1.2 git clone -b v5.1.2 --recursive https://github.com/espressif/esp-idf.git设置工具链运行安装脚本它会下载并安装编译器、调试器等所有必要工具。cd ~/esp/esp-idf ./install.sh esp32s3 # 针对ESP32-S3芯片激活环境每次打开新的终端窗口都需要激活ESP-IDF环境变量。# 在esp-idf目录下 . ./export.sh激活后终端提示符前会出现(esp-idf)字样表示环境已就绪。2.2 安装与配置SquareLine StudioSquareLine Studio是一个强大的LVGL图形库可视化编辑器能极大简化嵌入式UI设计。下载安装从SquareLine Studio官网下载对应操作系统的安装包并安装。创建新项目启动后选择“Create Project”。Project Name:FlightRadarUIDisplay Driver: 选择ESP32-ESP32-S3。Display Resolution: 填入你屏幕的分辨率如480x320。Color Depth: 通常选择16 bit(RGB565)兼顾色彩和内存。Touch Controller: 根据你的触摸芯片选择如GT911或XPT2046。Framework: 选择ESP-IDF。设计UI在编辑器中你可以从左侧组件库拖拽控件如Label、Image、Panel到画布上并右侧属性面板调整样式、位置和事件。对于航班雷达核心是一个全屏的“Canvas”控件或一个作为地图容器的“Panel”控件。生成代码设计完成后点击“Export”按钮选择“Export UI Files”。这将生成一个包含所有UI资源图片、字体和ui.c/ui.h文件的文件夹。你需要将这些文件复制到你的ESP-IDF项目目录中。2.3 创建ESP-IDF项目并集成UI现在我们将创建一个纯净的ESP-IDF项目并把SquareLine Studio生成的UI集成进来。创建项目cd ~/esp # 使用ESP-IDF的示例项目模板创建 cp -r $IDF_PATH/examples/get-started/hello_world my_flight_radar cd my_flight_radar集成UI文件将SquareLine Studio导出的ui文件夹整个复制到项目根目录的main文件夹下。将ui文件夹中的ui.c和ui.h也放在main目录中或者保持原有结构但需在CMakeLists.txt中正确添加源文件路径。修改主程序打开main/main.c清空原有内容开始编写我们的主逻辑。首先需要包含必要的头文件和初始化LVGL。#include stdio.h #include freertos/FreeRTOS.h #include freertos/task.h #include esp_system.h #include esp_log.h #include lvgl.h #include ui.h // SquareLine Studio生成的头文件 // 屏幕驱动和触摸驱动头文件需要根据你的屏幕型号引入 #include esp_lcd_panel_io.h #include esp_lcd_panel_vendor.h #include esp_lcd_panel_ops.h #include esp_lcd_touch_gt911.h // 示例GT911触摸驱动 static const char *TAG FlightRadar; // 声明屏幕和触摸初始化函数需根据具体硬件实现 void lvgl_driver_init(void);配置项目运行idf.py set-target esp32s3设置目标芯片然后运行idf.py menuconfig进行详细配置。在Component config-LVGL configuration中可以配置颜色深度、缓冲区大小等。建议将LV_MEM_SIZE设置为至少(屏幕宽*屏幕高*颜色深度/8)*2以上例如(480*320*2)*2 614400字节约600KB。在Example Configuration中配置Wi-Fi的SSID和密码。3. 实现核心功能从数据获取到图形绘制环境就绪后我们开始实现项目的三大核心模块网络通信与数据获取、数据解析与处理、LVGL图形绘制与交互。3.1 网络通信与航班数据获取航班实时数据通常需要通过公开的ADS-B数据接口或特定的航空数据API获取。这里以一个假设的模拟API为例。配置Wi-Fi连接ESP-IDF提供了便捷的Wi-Fi连接库。#include esp_wifi.h #include esp_event.h #include nvs_flash.h void wifi_init_sta(void) { ESP_ERROR_CHECK(nvs_flash_init()); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); esp_netif_create_default_wifi_sta(); wifi_init_config_t cfg WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(cfg)); wifi_config_t wifi_config { .sta { .ssid CONFIG_WIFI_SSID, .password CONFIG_WIFI_PASSWORD, .threshold.authmode WIFI_AUTH_WPA2_PSK, }, }; ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, wifi_config)); ESP_ERROR_CHECK(esp_wifi_start()); ESP_LOGI(TAG, Wi-Fi started, connecting to %s..., CONFIG_WIFI_SSID); ESP_ERROR_CHECK(esp_wifi_connect()); }创建HTTP任务获取数据使用esp_http_client组件定期请求数据。#include esp_http_client.h #include cJSON.h static void http_fetch_task(void *pvParameters) { esp_http_client_config_t config { .url http://api.example.com/flights?areayour_area, // 替换为真实API .method HTTP_METHOD_GET, }; esp_http_client_handle_t client esp_http_client_init(config); char buffer[4096]; // 根据API返回数据大小调整 while (1) { esp_err_t err esp_http_client_perform(client); if (err ESP_OK) { int content_len esp_http_client_get_content_length(client); int read_len esp_http_client_read(client, buffer, sizeof(buffer) - 1); if (read_len 0) { buffer[read_len] \0; // 将获取到的原始数据通过队列发送给解析任务 xQueueSend(flight_data_queue, buffer, portMAX_DELAY); } } else { ESP_LOGE(TAG, HTTP request failed: %s, esp_err_to_name(err)); } vTaskDelay(10000 / portTICK_PERIOD_MS); // 每10秒请求一次 esp_http_client_cleanup(client); client esp_http_client_init(config); // 重新初始化以进行下一次请求 } vTaskDelete(NULL); }3.2 数据解析与任务间通信解析任务从队列中取出原始JSON字符串解析并转换为绘图所需的结构体。定义数据结构typedef struct { char flight_number[10]; float latitude; float longitude; int altitude; // 英尺 int heading; // 航向0-360度 int speed; // 节 } flight_info_t; #define MAX_FLIGHTS 50 flight_info_t flight_list[MAX_FLIGHTS]; int flight_count 0; SemaphoreHandle_t flight_list_mutex; // 用于保护共享数据创建解析任务static void data_parse_task(void *pvParameters) { char raw_data[4096]; while (1) { if (xQueueReceive(flight_data_queue, raw_data, portMAX_DELAY)) { cJSON *root cJSON_Parse(raw_data); if (root) { cJSON *flights cJSON_GetObjectItem(root, flights); if (cJSON_IsArray(flights)) { xSemaphoreTake(flight_list_mutex, portMAX_DELAY); flight_count 0; cJSON *flight; cJSON_ArrayForEach(flight, flights) { if (flight_count MAX_FLIGHTS) break; cJSON *num cJSON_GetObjectItem(flight, number); cJSON *lat cJSON_GetObjectItem(flight, lat); cJSON *lon cJSON_GetObjectItem(flight, lon); cJSON *alt cJSON_GetObjectItem(flight, alt); cJSON *hdg cJSON_GetObjectItem(flight, hdg); if (cJSON_IsString(num) cJSON_IsNumber(lat) cJSON_IsNumber(lon)) { strncpy(flight_list[flight_count].flight_number, num-valuestring, sizeof(flight_list[flight_count].flight_number)-1); flight_list[flight_count].latitude lat-valuedouble; flight_list[flight_count].longitude lon-valuedouble; flight_list[flight_count].altitude alt ? (int)alt-valuedouble : 0; flight_list[flight_count].heading hdg ? (int)hdg-valuedouble : 0; flight_count; } } xSemaphoreGive(flight_list_mutex); // 通知UI任务数据已更新 lv_obj_invalidate(lv_scr_act()); } cJSON_Delete(root); } } } }3.3 LVGL图形绘制与交互这是UI任务的核心负责在地图背景上绘制所有航班。创建地图画布在SquareLine Studio中我们可能已经创建了一个名为ui_MapPanel的面板。在代码中我们需要为其添加一个“绘制事件”回调函数。// 在ui.c生成的文件中或在main.c里声明 void draw_flight_radar(lv_event_t * e) { lv_obj_t * canvas lv_event_get_target(e); lv_draw_ctx_t * draw_ctx lv_event_get_draw_ctx(e); // 1. 绘制静态地图背景网格、海岸线等 // 这里可以预先将地图背景图作为LVGL图片对象加载并绘制 // lv_canvas_draw_img(canvas, map_img, coords, clip_area); // 2. 绘制动态航班 xSemaphoreTake(flight_list_mutex, portMAX_DELAY); for (int i 0; i flight_count; i) { flight_info_t *f flight_list[i]; // 将经纬度转换为屏幕坐标需要实现一个映射函数 lv_point_t screen_pos geo_to_screen(f-latitude, f-longitude); // 创建绘制描述符 lv_draw_rect_dsc_t rect_dsc; lv_draw_rect_dsc_init(rect_dsc); rect_dsc.bg_color lv_color_hex(0xFF0000); // 红色图标 rect_dsc.radius LV_RADIUS_CIRCLE; // 绘制一个代表飞机的小圆点 lv_area_t area; area.x1 screen_pos.x - 3; area.y1 screen_pos.y - 3; area.x2 screen_pos.x 3; area.y2 screen_pos.y 3; lv_draw_rect(draw_ctx, rect_dsc, area); // 可选绘制航向线 if (f-heading 0) { lv_draw_line_dsc_t line_dsc; lv_draw_line_dsc_init(line_dsc); line_dsc.color lv_color_hex(0x00AA00); line_dsc.width 2; lv_point_t points[2]; points[0] screen_pos; // 根据航向计算线段的另一个端点 points[1].x screen_pos.x 10 * cos(f-heading * M_PI / 180.0); points[1].y screen_pos.y 10 * sin(f-heading * M_PI / 180.0); lv_draw_line(draw_ctx, line_dsc, points, 2); } } xSemaphoreGive(flight_list_mutex); } // 在应用初始化时将此回调绑定到地图面板 lv_obj_add_event_cb(ui_MapPanel, draw_flight_radar, LV_EVENT_DRAW_MAIN, NULL);实现坐标转换函数这是一个简化的墨卡托投影转换示例实际项目可能需要更复杂的地图投影库或使用预计算的瓦片。lv_point_t geo_to_screen(float lat, float lon) { // 简化版假设显示一个固定经纬度范围的区域 static float map_lat_min 30.0, map_lat_max 40.0; static float map_lon_min 110.0, map_lon_max 120.0; static int screen_width 480, screen_height 320; lv_point_t p; p.x (int)((lon - map_lon_min) / (map_lon_max - map_lon_min) * screen_width); p.y screen_height - (int)((lat - map_lat_min) / (map_lat_max - map_lat_min) * screen_height); // Y轴翻转 return p; }处理触摸事件实现点击飞机显示详情。static void map_panel_event_handler(lv_event_t * e) { lv_event_code_t code lv_event_get_code(e); lv_obj_t * obj lv_event_get_target(e); if(code LV_EVENT_CLICKED) { lv_point_t point; lv_indev_get_point(lv_indev_get_act(), point); // 遍历航班列表判断点击位置是否接近某个航班图标 xSemaphoreTake(flight_list_mutex, portMAX_DELAY); for (int i 0; i flight_count; i) { lv_point_t screen_pos geo_to_screen(flight_list[i].latitude, flight_list[i].longitude); if(abs(point.x - screen_pos.x) 5 abs(point.y - screen_pos.y) 5) { // 点击命中在UI上创建一个信息窗口显示航班详情 char info[100]; snprintf(info, sizeof(info), 航班: %s\n高度: %d ft\n航向: %d°, flight_list[i].flight_number, flight_list[i].altitude, flight_list[i].heading); lv_obj_t * mbox lv_msgbox_create(NULL, 航班信息, info, NULL, true); lv_obj_center(mbox); break; } } xSemaphoreGive(flight_list_mutex); } } // 绑定事件 lv_obj_add_event_cb(ui_MapPanel, map_panel_event_handler, LV_EVENT_CLICKED, NULL);4. 系统集成、烧录与调试所有模块开发完成后需要将它们整合到主函数中并进行编译烧录。4.1 主函数与任务创建在app_main()函数中初始化所有组件并创建任务。void app_main(void) { // 1. 初始化LVGL和硬件驱动 lv_init(); lvgl_driver_init(); // 这个函数需要你实现用于初始化屏幕和触摸 // 2. 初始化UISquareLine Studio生成的部分 ui_init(); // 3. 初始化Wi-Fi ESP_LOGI(TAG, Initializing Wi-Fi...); wifi_init_sta(); // 4. 创建互斥锁和队列 flight_list_mutex xSemaphoreCreateMutex(); flight_data_queue xQueueCreate(5, sizeof(char[4096])); // 队列深度5元素大小4KB // 5. 创建任务 xTaskCreatePinnedToCore(http_fetch_task, HTTP_Task, 4096*2, NULL, 5, NULL, 0); // Core 0 xTaskCreatePinnedToCore(data_parse_task, Parse_Task, 4096*2, NULL, 4, NULL, 0); // Core 0 xTaskCreatePinnedToCore(lvgl_task, LVGL_Task, 4096*4, NULL, 3, NULL, 1); // Core 1 // 6. 启动LVGL任务定时器在lvgl_task中处理 } // LVGL任务需要在循环中调用lv_timer_handler()和lv_task_handler() static void lvgl_task(void *pvParameters) { while (1) { lv_timer_handler(); // 处理LVGL定时器 vTaskDelay(pdMS_TO_TICKS(10)); // 10ms延迟约100FPS } }4.2 编译、烧录与监视使用ESP-IDF提供的命令行工具完成后续步骤。编译项目cd ~/esp/my_flight_radar idf.py build如果编译成功最后会显示项目大小并生成build/flight_radar.bin等固件文件。烧录固件将ESP32开发板通过USB连接到电脑确认端口如COM3或/dev/ttyUSB0。idf.py -p /dev/ttyUSB0 flash监视串口输出烧录完成后可以打开串口监视器查看日志这是最重要的调试手段。idf.py -p /dev/ttyUSB0 monitor按下Ctrl]可以退出监视器。4.3 验证运行结果成功烧录并启动后你应该在串口监视器中看到以下顺序的日志ESP-IDF版本信息和芯片信息。Wi-Fi连接成功获取到IP地址。LVGL和屏幕驱动初始化成功。HTTP任务开始周期性获取数据。解析任务开始输出解析到的航班数量。屏幕上应出现你设计的UI界面并逐渐绘制出代表航班的小图标。如果地图区域是空白的首先检查Wi-Fi是否连接成功。HTTP请求是否返回了有效数据查看日志中打印的原始JSON或错误码。坐标转换函数geo_to_screen是否正确可以尝试打印几个转换后的坐标点看是否在屏幕范围内。LVGL的绘制回调函数draw_flight_radar是否被正确触发可以在函数开头加日志。5. 常见问题排查与性能优化在实际开发中你几乎一定会遇到下面这些问题。5.1 编译与烧录问题问题现象可能原因检查与解决idf.py build失败提示CMake或组件找不到1. ESP-IDF环境未激活。2. 项目CMakeLists.txt中未正确添加组件或源文件路径。1. 在项目目录下执行. $IDF_PATH/export.sh。2. 检查main/CMakeLists.txt确保包含了ui.c和屏幕驱动源文件。例如target_sources(${COMPONENT_LIB} PRIVATE ui.c lvgl_interface.c)。烧录时提示Failed to connect to ESP321. 串口被占用。2. 开发板未进入下载模式。3. 驱动未安装如CH340/CP2102。1. 关闭其他串口工具。2. 按住开发板的BOOT键再按一下EN/RST键然后松开EN/RST最后松开BOOT键进入下载模式。3. 在设备管理器中检查端口是否存在并安装对应驱动。屏幕白屏或花屏1. 屏幕初始化序列或参数错误。2. SPI时钟频率过高。3. 电源不稳定。1. 对照屏幕数据手册检查lvgl_driver_init中的初始化命令序列。2. 尝试降低SPI时钟频率如从40MHz降到20MHz。3. 使用万用表测量给屏幕供电的3.3V或5V引脚电压是否稳定。5.2 运行时问题问题现象可能原因检查与解决Wi-Fi连接失败1. SSID/密码错误。2. 路由器设置了MAC过滤或隐藏SSID。3. 信号太弱。1. 通过idf.py menuconfig重新确认配置。2. 查看串口日志中的Wi-Fi连接状态码。3. 尝试连接手机热点进行测试。HTTP请求失败返回错误码1. API地址错误或网络不通。2. 未处理HTTPS证书如果API是HTTPS。3. 服务器返回错误如403 404。1. 用电脑浏览器或curl命令先测试API是否可用。2. 对于HTTPS在menuconfig中启用Component config - ESP-TLS - Skip server certificate verification(仅用于测试)。3. 在代码中打印出esp_http_client_get_status_code(client)查看具体HTTP状态码。内存不足系统重启1. LVGL缓冲区或JSON解析缓冲区过大。2. 内存泄漏如未释放cJSON对象。3. 任务栈空间不足。1. 使用esp_get_free_heap_size()监控内存变化。2. 确保每次cJSON_Parse后都有对应的cJSON_Delete。3. 在menuconfig中增大FreeRTOS相关任务的栈大小或使用xTaskCreatePinnedToCore时分配更大的栈如8192。触摸无反应或坐标错乱1. 触摸芯片I2C地址或引脚配置错误。2. 触摸中断引脚未正确配置或接线松动。3. 触摸校准数据错误。1. 检查触摸芯片数据手册确认I2C地址如GT911通常是0x5D或0x14。2. 确认INT和RST引脚接线并在代码中正确配置为输入上拉和输出模式。3. 实现一个触摸校准界面将原始坐标映射到屏幕坐标。界面卡顿刷新慢1. 单次绘制元素过多。2. LVGL缓冲区太小或未使用双缓冲。3. 网络或解析任务阻塞了UI任务。1. 限制屏幕上同时显示的航班数量或使用更简单的图形用点代替图标。2. 增大LVGL的缓冲区并启用双缓冲如果屏幕驱动支持。3. 确保UI任务运行在独立核心且优先级设置合理。网络/解析任务不要长时间占用CPU。5.3 性能与稳定性优化建议当基本功能跑通后可以考虑以下优化来提升体验和稳定性数据更新策略增量更新不要每次重绘全部航班。只清除上一帧的图标绘制新一帧的图标。这需要记录上一帧每个航班的位置。按需请求根据当前地图显示的范围经纬度边界向API请求数据而不是固定区域减少数据量和处理负担。UI渲染优化使用LVGL的图层将静态地图背景设为一个单独的图层lv_obj并设置LV_OBJ_FLAG_HIDDEN以外的标志避免每次重绘。图标缓存将飞机图标预先渲染为LVGL的图片对象lv_img_dsc_t而不是每次用绘图函数画圆和线。降低刷新率如果数据更新频率是10秒一次UI刷新率设为1-2秒一次即可没必要60FPS。系统健壮性Wi-Fi重连机制监听Wi-Fi断开事件实现自动重连。API容错HTTP请求失败后进行指数退避重试而不是固定间隔。看门狗为长时间运行的任务如解析任务定期喂看门狗防止系统因某个任务卡死而重启。功能扩展方向离线地图将地图切片预存到SPIFFS文件系统中避免依赖网络地图服务。更多数据解析并显示航班起降机场、机型、速度、高度变化趋势等。语音播报通过ESP32的I2S接口连接一个小型音频解码模块和喇叭对特定航班如附近低空航班进行语音提示。数据存储使用MicroSD卡模块定时将航班数据存储为CSV或JSON文件用于后续分析。这个项目从硬件连接到软件架构从数据获取到图形渲染覆盖了嵌入式物联网开发的多个关键环节。成功实现后你不仅得到了一个有趣的桌面航班雷达更重要的是掌握了ESP32在复杂应用中的多任务调度、网络通信、图形界面开发以及软硬件调试的全套流程。在实际部署时可以考虑为设备设计一个3D打印外壳让它成为一个既实用又有科技感的桌面摆件。