【Android】Android Framework系列--Launcher3桌面图标加载流程

Launcher3桌面加载流程

Android Launcher3(简称Launcher)启动后会加载桌面。基于Android12代码,分析一下桌面加载的流程。
一些相关的概念:

  • WorkSpace:桌面。在桌面上可以添加快捷方式、Hoseat或Dock(就是手机或者车机系统在桌面底部的图标栏)、Widet小组件(比如天气)等。
  • AllApp:App List,呈现所有App。点击任意App图标可以启动该App。
  • DeepShortcuts: 桌面上的应用快捷方式。
  • Widget:小组件,一般添加到桌面上,比如天气、闹钟、股票之类。

Launcher桌面加载

Launcher被Android AMS拉起后,进入自己的生命流程。Launcher.java 中的onCreate函数被调用,准备开始加载桌面。

//packages/apps/Launcher3/src/com/android/launcher3/Launcher.java
@Override
protected void onCreate(Bundle savedInstanceState) {Object traceToken = TraceHelper.INSTANCE.beginSection(ON_CREATE_EVT,TraceHelper.FLAG_UI_EVENT);LauncherAppState app = LauncherAppState.getInstance(this);mOldConfig = new Configuration(getResources().getConfiguration());mModel = app.getModel();if (!mModel.addCallbacksAndLoad(this)) {// 省略}
}

addCallbacksAndLoad在LauncherModel.java中实现,在这个函数中调用了startLoader函数,该函数中会创建LoaderResults对象。如果是首次启动情况下,调用函数startLoaderForResults,在startLoaderForResults函数中创建LoaderTask并利用之前创建的LoaderResults开始加载桌面。

//packages/apps/Launcher3/src/com/android/launcher3/LauncherModel.java
/*** Adds a callbacks to receive model updates* @return true if workspace load was performed synchronously*/
public boolean addCallbacksAndLoad(Callbacks callbacks) {synchronized (mLock) {addCallbacks(callbacks);return startLoader();}
}/*** Starts the loader. Tries to bind {@params synchronousBindPage} synchronously if possible.* @return true if the page could be bound synchronously.*/
public boolean startLoader() {ItemInstallQueue.INSTANCE.get(mApp.getContext()).pauseModelPush(ItemInstallQueue.FLAG_LOADER_RUNNING);synchronized (mLock) {final Callbacks[] callbacksList = getCallbacks();if (callbacksList.length > 0) {for (Callbacks cb : callbacksList) {MAIN_EXECUTOR.execute(cb::clearPendingBinds);}//  如果直接在加载了,请停止掉。// If there is already one running, tell it to stop.stopLoader();LoaderResults loaderResults = new LoaderResults(mApp, mBgDataModel, mBgAllAppsList, callbacksList);if (mModelLoaded && !mIsLoaderTaskRunning) {// 非首次启动。Launcher直接从数据库中同步加载。loaderResults.bindWorkspace();loaderResults.bindAllApps();loaderResults.bindDeepShortcuts();loaderResults.bindWidgets();return true;} else {// 首次启动走这里。startLoaderForResults(loaderResults);}}}return false;
}public void startLoaderForResults(LoaderResults results) {synchronized (mLock) {stopLoader();// 创建LoaderTask,通过loadTask加载桌面。mLoaderTask = new LoaderTask(mApp, mBgAllAppsList, mBgDataModel, mModelDelegate, results);MODEL_EXECUTOR.post(mLoaderTask);}}

从startLoader这个函数中,可以看出来。Launcher启动时加载的流程是:

  1. Workspace
  2. AllApps
  3. DeepShortcuts
  4. Widgets
    因为Workspace(直观上就是用户看到的桌面)是第一个呈现给用户的,并且桌面也是快捷方式、Widget的容器,所以肯定会第一个加载。
    接下来,LoaderTask被执行,调用其run函数。
//packages/apps/Launcher3/src/com/android/launcher3/model/LoaderTask.java
public void run() {synchronized (this) {// 退出阶段,直接返回// Skip fast if we are already stopped.if (mStopped) {return;}}try (LauncherModel.LoaderTransaction transaction = mApp.getModel().beginLoader(this)) {//顺序也是先加载 workspace,然后AllApp,然后DeepShortcuts,然后WidgetsList<ShortcutInfo> allShortcuts = new ArrayList<>();// 加载WorkSpace数据loadWorkspace(allShortcuts);logASplit(logger, "loadWorkspace");verifyNotStopped();// 绑定WorkSpace数据(绑定图标之类的数据,桌面及其上内容开始呈现)mResults.bindWorkspace();logASplit(logger, "bindWorkspace");mModelDelegate.workspaceLoadComplete();// Notify the installer packages of packages with active installs on the first screen.// 发送第一个Screen(开机后默认显示的第一个Screen,如果图标多的话,会被分成多个Screen,通过左右滑动显示其他的)sendFirstScreenActiveInstallsBroadcast();logASplit(logger, "sendFirstScreenActiveInstallsBroadcast");// Take a breakwaitForIdle();logASplit(logger, "step 1 complete");verifyNotStopped();// second stepList<LauncherActivityInfo> allActivityList = loadAllApps();logASplit(logger, "loadAllApps");verifyNotStopped();mResults.bindAllApps();logASplit(logger, "bindAllApps");// 省略// Take a breakwaitForIdle();logASplit(logger, "step 2 complete");verifyNotStopped();// third stepList<ShortcutInfo> allDeepShortcuts = loadDeepShortcuts();logASplit(logger, "loadDeepShortcuts");verifyNotStopped();mResults.bindDeepShortcuts();logASplit(logger, "bindDeepShortcuts");// Take a breakwaitForIdle();logASplit(logger, "step 3 complete");verifyNotStopped();// fourth stepList<ComponentWithLabelAndIcon> allWidgetsList =mBgDataModel.widgetsModel.update(mApp, null);logASplit(logger, "load widgets");verifyNotStopped();mResults.bindWidgets();logASplit(logger, "bindWidgets");verifyNotStopped();mModelDelegate.modelLoadComplete();transaction.commit();} 
}

上面的代码中,开始加载Launcher中的workspace、allapp、deepshortcut、Widget。先加载其数据,然后一步步绑定这些数据(桌面上就开始呈现出内容)。因为代码比较多且流程相似,这里主要关注WorkSpace的加载。

//packages/apps/Launcher3/src/com/android/launcher3/model/LoaderTask.java
private void loadWorkspace(List<ShortcutInfo> allDeepShortcuts) {loadWorkspace(allDeepShortcuts, LauncherSettings.Favorites.CONTENT_URI,null /* selection */);
}protected void loadWorkspace(List<ShortcutInfo> allDeepShortcuts, Uri contentUri,String selection) {boolean clearDb = false;// 省略if (clearDb) {Log.d(TAG, "loadWorkspace: resetting launcher database");LauncherSettings.Settings.call(contentResolver,LauncherSettings.Settings.METHOD_CREATE_EMPTY_DB);}Log.d(TAG, "loadWorkspace: loading default favorites");LauncherSettings.Settings.call(contentResolver,LauncherSettings.Settings.METHOD_LOAD_DEFAULT_FAVORITES);synchronized (mBgDataModel) {final HashMap<PackageUserKey, SessionInfo> installingPkgs =mSessionHelper.getActiveSessions();installingPkgs.forEach(mApp.getIconCache()::updateSessionCache);mFirstScreenBroadcast = new FirstScreenBroadcast(installingPkgs);final LoaderCursor c = new LoaderCursor(contentResolver.query(contentUri, null, selection, null, null), contentUri,mApp, mUserManagerState);final Bundle extras = c.getExtras();mDbName = extras == null? null : extras.getString(LauncherSettings.Settings.EXTRA_DB_NAME);try {// 省略while (!mStopped && c.moveToNext()) {// 循环刚刚创建的DB文件。读取DB中的信息(呈现哪些应用图标之类)try {if (c.user == null) {// User has been deleted, remove the item.c.markDeleted("User has been deleted");continue;}boolean allowMissingTarget = false;switch (c.itemType) {case LauncherSettings.Favorites.ITEM_TYPE_SHORTCUT:case LauncherSettings.Favorites.ITEM_TYPE_APPLICATION:case LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT:// 获取对应的Intent(比如快捷方式对应的intent)intent = c.parseIntent();// If it's a deep shortcut, we'll use pinned shortcuts to restore itif (cn != null && validTarget && c.itemType!= LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT) {// If the component is already present// 如果应用没有被Disable掉,那就么会显示它(通过pm可以disable应用)if (mLauncherApps.isActivityEnabled(cn, c.user)) {// no special handling necessary for this itemc.markRestored();} else {//否则不会显示这个应用。// Gracefully try to find a fallback activity.intent = pmHelper.getAppLaunchIntent(targetPkg, c.user);if (intent != null) {c.restoreFlag = 0;c.updater().put(LauncherSettings.Favorites.INTENT,intent.toUri(0)).commit();cn = intent.getComponent();} else {c.markDeleted("Unable to find a launch target");continue;}}}// 省略,后面就是从数据库遍历出数据,缓存到各个对象中了(内存中)break;// 省略}} catch (Exception e) {Log.e(TAG, "Desktop items loading interrupted", e);}}// Load delegate itemsmModelDelegate.loadItems(mUserManagerState, shortcutKeyToPinnedShortcuts);// Break early if we've stopped loadingif (mStopped) {mBgDataModel.clear();return;}// Remove dead itemsmItemsDeleted = c.commitDeleted();}
}

loadWorkspace函数中通过LauncherSettings创建了Launcher中的数据。并加载了默认的布局数据到 创建的DB中。然后遍历DB,将数据赋给对应的对象。初次启动时,加载默认布局数据,会按如下顺序进行:

  1. 找launcher3.layout.provider这个key对应的value(contentprovider),然后通过这个value值读取到配置的launcher_layout的信息。
  2. 如果第一步没找到。那么找系统中包含“android.autoinstalls.config.action.PLAY_AUTO_INSTALL”的应用,通过它获取launcher_layout信息。
  3. 如果第二步没找到。找系统中com.android.launcher3.action.PARTNER_CUSTOMIZATION对应的应用,通过它获取launcher_layout信息。
  4. 如果第三步没找到。加载Launcher中默认的workspace布局( /packages/apps/Launcher3/res/xml/这个目录下的default_workspace_*.xml文件)

关于查找默认布局的实现,可以参考LauncherProvider中的loadDefaultFavoritesIfNecessary函数。
在这里插入图片描述
到此,Launcher桌面需要的数据加载完成。下面将数据绑定(显示出来)

Launcher桌面数据绑定

回到LauncherTask的run函数中loadWorkspace函数执行完成后,调用LoaderResultsbindWorkspace函数完成WorkSpace的数据绑定。绑定数据后,后面Activity渲染时就会用这些数据呈现出桌面上的元素。

//packages/apps/Launcher3/src/com/android/launcher3/model/LoaderTask.java
public void run() {synchronized (this) {// Skip fast if we are already stopped.if (mStopped) {return;}}Object traceToken = TraceHelper.INSTANCE.beginSection(TAG);TimingLogger logger = new TimingLogger(TAG, "run");try (LauncherModel.LoaderTransaction transaction = mApp.getModel().beginLoader(this)) {List<ShortcutInfo> allShortcuts = new ArrayList<>();loadWorkspace(allShortcuts);logASplit(logger, "loadWorkspace");verifyNotStopped();// 绑定WorkSpace中的数据mResults.bindWorkspace();logASplit(logger, "bindWorkspace");
}

LoaderResults的bindWorkspace函数,在其父类BaseLoaderResults中定义。该函数中,创建workspaceItems、appWidgets、orderedScreenIds (屏幕数)等信息的数组。然后创建WorkspaceBinder,调用其bind函数开始绑定。

//packages/apps/Launcher3/src/com/android/launcher3/model/BaseLoaderResults.java
public void bindWorkspace() {// Save a copy of all the bg-thread collectionsArrayList<ItemInfo> workspaceItems = new ArrayList<>();ArrayList<LauncherAppWidgetInfo> appWidgets = new ArrayList<>();final IntArray orderedScreenIds = new IntArray();ArrayList<FixedContainerItems> extraItems = new ArrayList<>();synchronized (mBgDataModel) {workspaceItems.addAll(mBgDataModel.workspaceItems);appWidgets.addAll(mBgDataModel.appWidgets);//可能有多个屏幕(比如图标很多,一个屏幕放不下的情况)orderedScreenIds.addAll(mBgDataModel.collectWorkspaceScreens());mBgDataModel.extraItems.forEach(extraItems::add);mBgDataModel.lastBindId++;mMyBindingId = mBgDataModel.lastBindId;}for (Callbacks cb : mCallbacksList) {// callback对象是Launcher这个类new WorkspaceBinder(cb, mUiExecutor, mApp, mBgDataModel, mMyBindingId,workspaceItems, appWidgets, extraItems, orderedScreenIds).bind();}
}

WorkspaceBinder的bind函数中,首先拿到当前屏幕(就是呈现给用户的第一个屏幕)ID,然后优先往第一个屏幕上绑定内容。之后再绑定其他屏幕的内容。

private void bind() {final int currentScreen;{// Create an anonymous scope to calculate currentScreen as it has to be a// final variable.int currScreen = mCallbacks.getPageToBindSynchronously();if (currScreen >= mOrderedScreenIds.size()) {// There may be no workspace screens (just hotseat items and an empty page).currScreen = PagedView.INVALID_PAGE;}currentScreen = currScreen;}final boolean validFirstPage = currentScreen >= 0;// 拿到当前的屏幕IDfinal int currentScreenId =validFirstPage ? mOrderedScreenIds.get(currentScreen) : INVALID_SCREEN_ID;// Separate the items that are on the current screen, and all the other remaining itemsArrayList<ItemInfo> currentWorkspaceItems = new ArrayList<>();ArrayList<ItemInfo> otherWorkspaceItems = new ArrayList<>();ArrayList<LauncherAppWidgetInfo> currentAppWidgets = new ArrayList<>();ArrayList<LauncherAppWidgetInfo> otherAppWidgets = new ArrayList<>();filterCurrentWorkspaceItems(currentScreenId, mWorkspaceItems, currentWorkspaceItems,otherWorkspaceItems);filterCurrentWorkspaceItems(currentScreenId, mAppWidgets, currentAppWidgets,otherAppWidgets);final InvariantDeviceProfile idp = mApp.getInvariantDeviceProfile();sortWorkspaceItemsSpatially(idp, currentWorkspaceItems);sortWorkspaceItemsSpatially(idp, otherWorkspaceItems);// Tell the workspace that we're about to start binding itemsexecuteCallbacksTask(c -> {c.clearPendingBinds();c.startBinding();}, mUiExecutor);// Bind workspace screens// 先绑定屏幕executeCallbacksTask(c -> c.bindScreens(mOrderedScreenIds), mUiExecutor);Executor mainExecutor = mUiExecutor;// Load items on the current page.// 往当前的屏幕上绑定数据内容bindWorkspaceItems(currentWorkspaceItems, mainExecutor);bindAppWidgets(currentAppWidgets, mainExecutor);mExtraItems.forEach(item ->executeCallbacksTask(c -> c.bindExtraContainerItems(item), mainExecutor));// In case of validFirstPage, only bind the first screen, and defer binding the// remaining screens after first onDraw (and an optional the fade animation whichever// happens later).// This ensures that the first screen is immediately visible (eg. during rotation)// In case of !validFirstPage, bind all pages one after other.final Executor deferredExecutor =validFirstPage ? new ViewOnDrawExecutor() : mainExecutor;executeCallbacksTask(c -> c.finishFirstPageBind(validFirstPage ? (ViewOnDrawExecutor) deferredExecutor : null), mainExecutor);// 绑定非当前屏幕上的内容bindWorkspaceItems(otherWorkspaceItems, deferredExecutor);bindAppWidgets(otherAppWidgets, deferredExecutor);// Tell the workspace that we're done binding itemsexecuteCallbacksTask(c -> c.finishBindingItems(currentScreen), deferredExecutor);if (validFirstPage) {executeCallbacksTask(c -> {// We are loading synchronously, which means, some of the pages will be// bound after first draw. Inform the mCallbacks that page binding is// not complete, and schedule the remaining pages.c.onPageBoundSynchronously(currentScreen);c.executeOnNextDraw((ViewOnDrawExecutor) deferredExecutor);}, mUiExecutor);}
}

通过调用Launcher类的bindScreens函数,绑定屏幕(添加屏幕)后,调用bindWorkspaceItemsbindAppWidgets等函数往屏幕上绑定数据。实际上这些函数,最终会调用Launcher类中的bindItems,根据图标信息创建View并addView,并且给各个View设置其TouchListener。感兴趣的可以顺着这些函数继续看下。
另外Launcher中的数据库(就是上面首次启动时创建的空数据库,并加载了布局数据。一般名称为Launcher.db)用于保存桌面相关数据信息,其创建在LauncherProvider中实现。
在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/191854.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

项目中遇到的半导体公司

作为一个技术人&#xff0c;我并不是亲美&#xff0c;从技术的实事求是角度讲&#xff0c;不得不感叹欧美的半导体技术。他们的datasheet能学到的东西太多太多&#xff1b;我甚至佩服他们缜密的逻辑。从他们的文章中领悟我们技术到底有多low&#xff0c;没办法一个一个了解所有…

【重点】【双指针】11. 盛最多水的容器

题目 注意&#xff1a;二维接雨水&#xff0c;有墙的&#xff0c;有线的&#xff0c;着这个属于线的。 class Solution {public int maxArea(int[] height) {if (height.length < 2) {return 0;}int left 0, right height.length - 1, res 0;while (left < right) {…

avue-crud中时间范围选择默认应该是0点却变成了12点

文章目录 一、问题二、解决三、最后 一、问题 在avue-crud中时间范围选择&#xff0c;正常默认应该是0点&#xff0c;但是不知道怎么的了&#xff0c;选完之后就是一直是12点。具体问题如下动图所示&#xff1a; <template><avue-crud :option"option" /&g…

Linux文件系统 -- inode和block

目录 重要参数目录项fsck软连接&#xff0c;硬链接 重要参数 dumpe2fs /dev/sda1|more查看ext4文件元数据&#xff08;描述文件系统的数据&#xff09;&#xff0c;xfs_info查看xfs文件系统 superblock&#xff1a;超级块&#xff0c;记录此file system的整体信息&#xff0c…

每日一练:冒泡排序

1. 概述 冒泡排序&#xff08;Bubble Sort&#xff09;也是一种简单直观的排序算法。它重复地走访过要排序的数列&#xff0c;一次比较两个元素&#xff0c;如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换&#xff0c;也就是说该数列已经排…

Vue---Echarts

项目需要用echarts来做数据展示&#xff0c;现记录vue3引入并使用echarts的过程。 1. 使用步骤 安装 ECharts&#xff1a;使用 npm 或 yarn 等包管理工具安装 ECharts。 npm install echarts 在 Vue 组件中引入 ECharts&#xff1a;在需要使用图表的 Vue 组件中&#xff0c;引入…

深入理解Java中的锁机制

引言 大家好&#xff0c;我是小黑。今天咱们来聊聊Java中的锁机制&#xff0c;这可是并发编程的核心。你知道吗&#xff0c;在并发编程的世界里&#xff0c;正确地使用锁就像是掌握了一把神奇的钥匙&#xff0c;它能帮咱们在多线程的混战中保持秩序&#xff0c;防止数据被乱改…

opencv知识库:基于cv2.flip()函数对图像进行随机翻转(水平/垂直)

需求场景 欲对RGB格式的lena图像进行随机翻转&#xff0c;要求这些图像不翻转、水平翻转、垂直翻转的概率都为1/3。 功能代码 import cv2 import random# 读取并展示图像 img cv2.imread("lena.jpg") cv2.imshow(lena, img) cv2.waitKey(0)for i in range(6): #…

Hdoop学习笔记(HDP)-Part.18 安装Flink

目录 Part.01 关于HDP Part.02 核心组件原理 Part.03 资源规划 Part.04 基础环境配置 Part.05 Yum源配置 Part.06 安装OracleJDK Part.07 安装MySQL Part.08 部署Ambari集群 Part.09 安装OpenLDAP Part.10 创建集群 Part.11 安装Kerberos Part.12 安装HDFS Part.13 安装Ranger …

头歌JUnit单元测试相关实验入门

一、入门实验 1.1第一个Junit测试程序 任务描述 请学员写一个名为testSub()的测试函数&#xff0c;来测试给定的减法函数是否正确。 相关知识 Junit编写原则 1、简化测试的编写&#xff0c;这种简化包括测试框架的学习和实际测试单元的编写。 2、测试单元保持持久性。 3、利用…

短线买入卖出有哪些交易技巧?

前面两节课&#xff0c;我们认识了短线交易&#xff0c;知道了短线交易常见的买入卖出时机&#xff0c;这节课&#xff0c;我们来讲解一下短线买入卖出的一些交易技巧。话不多时&#xff0c;直接进入重点&#xff01; 一、短线交易要果断 短线波动快&#xff0c;在出现买卖信号…

排序算法总结(Python、Java)

Title of Content 1 冒泡排序 Bubble sort&#xff1a;两两交换&#xff0c;大的冒到最后概念排序可视化代码实现Python - 基础实现Python - 优化实现Java - 优化实现C - 优化实现C - 优化实现 2 选择排序 Selection sort&#xff1a;第i轮遍历时&#xff0c;将未排序序列中最小…

反序列化漏洞详解(一)

目录 一、php面向对象 二、类 2.1 类的定义 2.2 类的修饰符介绍 三、序列化 3.1 序列化的作用 3.2 序列化之后的表达方式/格式 ① 简单序列化 ② 数组序列化 ③ 对象序列化 ④ 私有修饰符序列化 ⑤ 保护修饰符序列化 ⑥ 成员属性调用对象 序列化 四、反序列化 …

unity学习笔记

一、线段渲染器 在Unity中&#xff0c;线段渲染器&#xff08;Line Renderer&#xff09;是一种用于在场景中绘制线段的组件。线段渲染器非常适合用于创建轨迹、路径、光束等效果。 1. 创建Line Renderer&#xff1a;在Unity编辑器中&#xff0c;你可以通过创建空对象 -> …

Linux - 动态库的加载 和 重谈进程地址空间 - vscode 当中的 Remote - SSH 插件

推书&#xff1a;《现代操作系统》《操作系统--精髓于设计原理》《UNIX环境高级编程》 目录 前言 程序的加载 程序没有加载之前的地址&#xff08;此时还是程序&#xff09; 程序被加载到内存之后&#xff08;此时是进程&#xff09; 动态库的地址 静态库的不加载&#xff…

数据结构——堆排序的topk问题

呀哈喽&#xff0c;我是结衣 前言 今天给大家带来的堆排序的topk问题。topk就是在许多数中&#xff0c;找出前k个大的数&#xff0c;可能是几十个数&#xff0c;也可能是几千万个数中找。今天我们将要在1000000&#xff08;一百万&#xff09;个数中找出前10大的数。 知识点 C…

【c】角谷猜想

#include<stdio.h> int coll(int x)//定义函数 {int count0;while(x>1){if(x%20){xx/2;count;}else{x3*x1;count;}}return count; } int main() {int n,num;scanf("%d",&n);int arr[n1];for(int i1;i<n;i)//输入n组数据保存到数组中{scanf("%d&…

数据结构之哈希表

数据结构之哈希表 文章目录 数据结构之哈希表一、哈希概念二、哈希冲突三、哈希函数常见哈希函数 四、哈希冲突解决闭散列闭散列的思考线性探测线性探测的实现 二次探测 开散列开散列概念开散列的思考开散列实现 五、开散列与闭散列比较 一、哈希概念 顺序结构以及平衡树中&am…

MidJourney笔记(6)-Niji模式

Niji模式 回顾一下,在讲解settings命令时,我们可以看到一个Niji字眼。 而且是在Midjourney V4之后才有的,那Niji到底是什么? Niji是MidJourney中用于绘制二次元/动漫风格的模型,那Niji的V4和V5有什么区别呢?

竞赛选题 : 题目:基于深度学习的水果识别 设计 开题 技术

1 前言 Hi&#xff0c;大家好&#xff0c;这里是丹成学长&#xff0c;今天做一个 基于深度学习的水果识别demo 这是一个较为新颖的竞赛课题方向&#xff0c;学长非常推荐&#xff01; &#x1f9ff; 更多资料, 项目分享&#xff1a; https://gitee.com/dancheng-senior/pos…