前言

说到Android视图大家第一反应肯定是Activity以及View,毕竟这是我们从入门开始接触最多的两个组件。但提到ActivityView之间联系以及设计背景可能会难道一大片人。其实关于视图系统还有一个重要概念Window不那么经常被提起,Android的设计者为了让开发者更容易上手,基于迪米特法则Window屏蔽在内部。本文将从设计背景为出发点阐述Activity、Window、View的实现流程,帮你换一种角度看Android视图系统,相信读完会让你耳目一新。

目录

  • 1. 设计背景

    • 1.1 View存在的意义
    • 1.2 如何管理错综复杂的View?
    • 1.3 如何优雅的衔接各窗口之间关系?
  • 2. 实现流程
    • 2.1 Activity的由来
    • 2.2 PhoneWindow不等价于"Window(窗口)"
    • 2.3 DecorView的创建时机
    • 2.4 ViewRootImpl如何协调View和Window的关系?

1. 设计背景

1.1 View存在的意义

View直译过来就是视图的意思,是用来展示各种各样的视图。关于View的绘制流程相信大家都略知一二,共分为measure、layout、draw,前两步用来决定宽高和位置, 真正绘制视图是在draw过程,通过onDraw方法传入的Canvas可以绘制各种各样的图形,而Canvas内部会通过JNI调用底层来实现真正的绘制操作。

既然Canvas就可以完成绘制那View存在的意义是什么呢?

想绘制出五彩斑斓的效果光有Canvas还远远不够,它还得配合PaintMatrix等,这一系列操作让本就不简单的Canvas上手难度越来越高,复用率降也越来越低,绘制各种复杂界面几乎成了不可完成的任务。面对这种痛点Android系统通过模板设计模式封装了一个用来管理绘制的组件View,屏蔽大量细节的同时提供三个模板方法measure、layout、draw,开发者可以通过View的三大模板方法自行定义视图的宽高、位置、形状,解决了大量模板代码以及复用率低的问题。

一个复杂的界面通常会包含很多元素比如文字、图片等,根据单一设计原则Android将其封装为TextView、ImageView。看起来万事大吉,但摆放这些View的时候又是一个大工程,各种坐标计算不 一会就晕头转向的,实际上摆放规则无非就那几种,所以Android利用Viewlayout特性封装了RelativeLayout、LinearLayoutlayout用来控制各View之间的位置关系,进一步提升开发者效率。

1.2 如何管理错综复杂的View?

通过自定义View可以绘制出我们任意想要的效果,一切看似很美好。正当你盯着屏幕欣赏自己的作品时,“啪”糊上来一个其他界面,一通分析得知,原来其他app也通过View操控了屏幕,你也不甘示弱通过相同操作重新竞争到屏幕,如此反复进行 不可开交时屏幕黑了,得,还是换回塞班系统吧~~~

玩笑归玩笑,回归到问题本身。由于对View的管理不当造成了屏幕很混乱的情况。按常理来讲当用户在操作一个app时肯定不希望其他app蹦出来,所以在此背景下急需一套机制来管理错综复杂的View。于是Android在系统进程中创建了一个系统服务WindowManagerService(WMS)专门用来管理屏幕上的窗口,而View只能显示在对应的窗口上,如果不符合规定就不开辟窗口进而对应的View也无法显示

为什么WMS需要运行在系统进程?

由于每个app都对应一个进程,想要管理所有的应用进程,WMS需要找一个合适的地方能凌驾于所有应用进程之上,系统进程是最合适的选择

1.3 如何优雅的衔接各窗口之间关系?

自定义View可以定制各种视图效果,窗口可以让View有条不紊的显示,一切又美好了起来。但问题又来了,每个App都会有很多个界面(窗口),仅靠窗口/View来控制窗口和视图会面临一个很致命的问题:"不具备跳转和回退功能"

按照我们的惯性思维,界面即可以跳转又可以回退,比如界面A跳转到界面B,按返回键理应退到界面A,这是一个标准的栈数据结构。

但关于界面的启动以及返回键的监听似乎与窗口不太搭嘎,所以Android基于单一设计原则封装了Activity,赋予窗口启动和回退功能,并由AMS统一调度Activity栈和生命周期。 又通过迪米特法则窗口的管理屏蔽在内部并暴露出onCreate、onStart、onResume...等模版方法, 让开发者只专注于视图排版(View)生命周期,无需关心窗口以及栈结构的存在

所以,单纯说通过Activity创建一个界面似乎又不那么准确,一切窗口均源自于WMS,而窗口中内容由View进行填充,Activity只是在内部"间接"通过WMS管理窗口并协调好窗口View的关系,最后再赋予栈、生命周期 等 功能而已。

关于Activity如何管理窗口/View ? 请看第二小节

2. 实现流程

读源码的目的是为了理清设计流程,千万不要因果倒置陷入到代码细节当中,所以要懂得挑重点,讲究点到为止。本文为了提供更好的阅读体验,会将源码中大部分无用信息删掉,只保留精华。

2.1 Activity的由来

Activity从何而来?想追溯到源头,恐怕要到从开天辟地时造就第一个受精卵开始

开天辟地造就的Zygote从何而来

Android系统会在开机时由Native层创建第一个进程init进程,随后init进程会解析一个叫init.rc的本地文件创建出Zygote进程

字如其名,Zygote的职责就是孵化进程。当孵化出的第一个进程SystemServer进程后退居幕后,通过Socket静等创建进程的呼唤,一切应用进程均由Zygote进程孵化

SystemServer进程的职责

SystemServerZygote自动创建的进程,并且会长时间驻留在内存中,该进程内部会注册各种Service 如:

  • ActivityManagerService(AMS):用来创建应用进程(通过socket ipc通知zygote进程)、管理四大组件
  • WindowManagerService(WMS):用来开辟和管理屏幕上的窗口,让视图有条不紊的显示
  • InputManagerService(IMS):用来处理和分发各种事件
  • 等等…

为什么要将这些系统服务放在单独进程?

AMS、WMS、IMS都是用来处理一些系统级别的任务,比如Activity存在任务栈/返回栈的概念,如果在通过Activity进行应用间跳转时,需要协调好任务栈/返回栈的关系,而不同应用又属于不同进程,所以需要一个地方能凌驾于所有应用进程之上,而单独进程是最好的选择。关于WMS、IMS等其他Service同理,就不再赘述

应用进程的创建过程

前面说到AMS可以通知Zygote进程孵化应用进程,那究竟何时通知呢?其实大家应该已经猜到了,通过点击桌面上应用图标可以开启一个应用,所以AMS就是在此时通知Zygote创建应用进程。但桌面又是什么东西它从何而来?其实桌面也是一个Activity,它由AMS自动创建

回归正题,点击应用图标到Activity的启动 这之间经历了什么流程?下面我简单列一下:

  • 当点击一个App图标时,如果对应的应用进程还没有创建则会通过Binder IPC通知到AMS创建应用进程

  • 应用进程启动后会执行我们所熟悉的main方法,而这个main方法则位于ActivityThread这个类中,main方法对应的就是Android主线程

  • ActivityThreadmain方法首先会调用Looper.loop(),用来循环处理主线程Hanlder分发的消息。

  • 接下来的main方法会发送一个BIND_APPLICATION的消息,Looper收到后会通过Binder IPC通知AMS创建App进程对应的Application

  • Application创建后会再次通过Binder IPC通知AMS要创建ActivityAMS验证后会回到App进程

  • 回到App进程后会间接调用ActivityThread#performLaunchActivity()来真正启动创建Activity,并且执行attach()onCreate()

tips

ApplicationActivity并不是通过AMS直接创建的,AMS只是负责管理和验证,真正创建具体对象还得到App进程

Android视图系统是一个很庞大的概念,几乎贯穿了整个Java Framework,由于作者能力以及篇幅的原因,无法一文将Java Framework讲解清楚。所以就描述式的说了下系统进程、应用进程以及Activity的由来,尽可能你更清晰的认识Android视图系统。

2.2 PhoneWindow不等价于"Window(窗口)"

我之所以第一小节没有将窗口描述成Window是怕大家将二者混淆,因为应用进程的Window/PhoneWindow和真正的窗口根本就是两个概念,作者也曾在阅读源码时就这个问题困惑了很久。

Android SDK中的Window是一个抽象类,它有一个唯一实现类PhoneWindowPhoneWindow内部会持有一个DecorView(根View),它的职责就是对DecorView做一些标准化的处理,比如标题、背景、导航栏、事件中转等,很显然与我们前面所说的窗口概念不符合

PhoneWindow何时被创建?

2.1小结我提到可以通过ActivityThread#performLaunchActivity()创建Activity,来看下其代码:

#ActivityThreadprivate Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {...Activity activity = null;//注释1activity = mInstrumentation.newActivity(cl, component.getClassName(), r.intent);...if (activity != null) {...//注释2.activity.attach(...);...//注释3.if (r.isPersistable()) {mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);} else {mInstrumentation.callActivityOnCreate(activity, r.state);}}...return activity;
}复制代码

首先通过注释1处创建一个Activity对象,然后在注释2处执行其attach(..)方法,最后在通过callActivityOnCreate()执行ActivityonCreate()方法

先来看attach做了什么事情:

#Activityfinal void attach(...){...mWindow = new PhoneWindow(this, window, activityConfigCallback);...mWindow.setWindowManager(...);mWindowManager = mWindow.getWindowManager();...
}
复制代码

Activity会在attach()方法中创建一个PhoneWindow对象并复制给成员变量mWindow,随后执行WindowManagersetter、getter。来重点看一下setter方法:

#Windowpublic void setWindowManager(...) {...if (wm == null) {//注释1wm = (WindowManager)mContext.getSystemService(Context.WINDOW_SERVICE);}//注释2mWindowManager = ((WindowManagerImpl)wm).createLocalWindowManager(this);}
复制代码

注释1处会通过系统服务获取一个WindowManager类型对象,用来管理Window

注释2会通过WindowManager创建一个WindowManagerImpl对象,实际上WindowManager是一个接口,它继承自ViewManager接口,而WindowManagerImpl是它的一个实现类

绕来绕去原来是通过WindowManager创建了另一个WindowManager,看起来多此一举,那Android为什么要这样设计呢?

首先WindowManager具备两个职责,管理Window创建WindowManager。系统服务获取的WindowManager具备创建Window功能,但此时并未与任何Window关联。而通过createLocalWindowManager创建的WindowManager会与对应的Window一对一绑定。所以前者用于创建WindowManager,后者用于与Window一对一绑定,二者职责明确,但让作者费解的是为什么不基于单一设计原则创建过程抽取至另一个类?如果有知道的同学可以评论区留言,事先谢过~

关于WindowManagerImpl如何管理Window先暂且不提,下面文章会说到

PhoneWindow已经创建完毕,但还没有跟Activity/View做任何关联。扒一扒PhoneWindow的源码你会发现,它内部只是设置了标题、背景以及事件的中转等工作,与窗口完全不搭嘎,所以切勿将二者混淆

2.3 DecorView的创建时机

通过2.2可知 Activityattach()运行完毕后会执行onCreate(),通常我们需要在onCreate()中执行stContentView()才能显示的XML Layout。关于stContentView() 顾名思义就是设置我们的Content View嘛,内部代码如下:

#Activitypublic void setContentView(@LayoutRes int layoutResID) {getWindow().setContentView(layoutResID);...
}public Window getWindow() {return mWindow;
}
复制代码

首先通过getWindow()获取到attach()阶段创建的PhoneWindow,随后将layoutResID(XML Layout)传递进去,继续跟:

#PhoneWindowViewGroup mContentParent;public void setContentView(int layoutResID) {//注释1if (mContentParent == null) {installDecor();} else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {mContentParent.removeAllViews();}if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {...} else {//注释2mLayoutInflater.inflate(layoutResID, mContentParent);}}
复制代码

注释1处会判断mContentParent是否为空,如果为空会通过installDecor()对其实例化,否则移除所有子View。

注释2处会将layoutResID对应的XML加载到mContentParent。到此为止唯一的疑问是mContentParent如何被创建的,跟一下installDecor()

#PhoneWindowprivate void installDecor() {if (mDecor == null) {mDecor = generateDecor(-1);...} else {mDecor.setWindow(this);}if (mContentParent == null) {mContentParent = generateLayout(mDecor);...}}
复制代码

首先创建DecorView类型对象并赋值给引用mDecor。那什么是DecorView

DecorView继承自FrameLayout,内部有一个垂直布局的LinearLayout用来摆放状态栏、TitleBar、ContentView、导航栏,其中ContentView就是用来存放由Activity#setContentView传入的Layout。之所以设计出DecorView是因为状态栏、导航栏等需要做到系统统一,并将其管控操作屏蔽在内部,只暴露出ContentView由开发者填充,符合迪米特法则

再回到mDecor的创建过程,跟一下generateDecor(-1)代码:

#PhoneWindowprotected DecorView generateDecor(int featureId) {...return new DecorView(context, featureId, this, getAttributes());
}
复制代码

直接new出来了一个DecorView。再回到我们最初的疑问,mContentParent从何而来?installDecor()创建出DecorView会通过generateLayout(mDecor)创建mContentParentgenerateLayout(mDecor)代码很长就不贴了,内部会通过mDecor获取到mContentParent并为其设置主题、背景等

到此阶段DecorView创建完毕并与XML Layout建立了关联,但此时根View(DecorView)还未与窗口建立关联,所以是看不到的。

为什么要在onCreate执行setContentView?

通过setContentView可以创建DecorView,而一个Activity通常只有一个DecorView(撇去Dialog等),如若将setContentView放在start、resume可能会创建多个DecorView,进而会造成浪费。所以onCreate是创建DecorView的最佳时机

2.4 ViewRootImpl如何协调View和Window的关系?

Activity启动后会在不同时机通过ActivityThread调用对应的生命周期方法onResume是一个特殊的时机它通过ActivityThread#handleResumeActivity被调用,代码如下:

#PhoneWindowpublic void handleResumeActivity(...) {//注释1final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason);...final Activity a = r.activity;...//注释2r.window = r.activity.getWindow();View decor = r.window.getDecorView();decor.setVisibility(View.INVISIBLE);ViewManager wm = a.getWindowManager();WindowManager.LayoutParams l = r.window.getAttributes();...//注释3wm.addView(decor, l);...}
复制代码
  • 注释1处 会间接调用ActivityonResume方法
  • 注释2处 通过Activity获取PhoneWindow、DecorView、WindowManager,它们的创建时机前面小结有写,忘记的可以回翻阅读。
  • 注释3处 调用了WindowManageraddView方法,顾名思义就是将DecorView添加至Window当中,这一步非常关键

关于WindowManager的概念2.2小结提到过,它是一个接口有一个实现类WindowManagerImp,跟一下其addView()方法

#WindowManagerImpprivate final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();public void addView(...) {...mGlobal.addView(view, params, mContext.getDisplayNoVerify(), mParentWindow, mContext.getUserId());...}复制代码

内部调用了mGlobaladdView()方法,其实不光addView几乎所有WindowManager方法都是通过委托mGlobal去实现,这种写法看似很奇怪,但实际上这种设计不仅不奇怪而且还很精妙,具体精妙在何处?我列出以下三点:

  • WindowManager提供的功能全局通用不会与某个View/Window单独绑定,为了节省内存理应设计出一个单例
  • WindowManagerImp具备多个职责如Token管理、WindowManager功能等,所以通过单一设计原则WindowManager功能拆分到另一个类中即WindowManagerGlobal,并将其定义为单例。
  • 为了不违背迪米特法则又通过组合模式将WindowManagerGlobal屏蔽在内部。

回归正题,来看mGlobaladdView()方法:

#WindowManagerGlobal
/*** 用来存储所有的DecorView*/
private final ArrayList<View> mViews = new ArrayList<View>();
/*** 用来存储所有的ViewRootImpl*/
private final ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>();
/*** 用来存储所有的LayoutParams*/
private final ArrayList<WindowManager.LayoutParams> mParams =new ArrayList<WindowManager.LayoutParams>();public void addView(...) {...ViewRootImpl root;synchronized (mLock) {root = new ViewRootImpl(view.getContext(), display);mViews.add(view);mRoots.add(root);mParams.add(wparams);...root.setView(view, wparams, panelParentView, userId);...}}
复制代码

首先创建一个ViewRootImpl类型对象root,然后将view、root、wparams加入到对应的集合,由WindowManagerGlobal的单例对象统一管理,最后执行rootsetView()。 根据我多年阅读源码的经验 答案应该就在root.setView()里,继续跟

ViewRootImpl
public void setView(...) {synchronized (this) {if (mView == null) {...mView = view;...//注释1requestLayout();//注释2res = mWindowSession.addToDisplayAsUser(mWindow, mSeq, mWindowAttributes,getHostVisibility(), mDisplay.getDisplayId(), userId, mTmpFrame,mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,mAttachInfo.mDisplayCutout, inputChannel,mTempInsets, mTempControls);...//注释3view.assignParent(this);}}}void assignParent(ViewParent parent) {if (mParent == null) {mParent = parent;} else if (parent == null) {mParent = null;}...
}
复制代码

ViewRootImpl#setView()方法很长,我做了下精简列出几个关键步骤

  • 注释1,requestLayout()通过一系列调用链最终会开启mView(DecorView)绘制(measure、layout、draw)。这一流程很复杂,由于篇幅原因本文就不提了,感兴趣的可查阅Choreographer相关知识
  • 注释2,mWindowSession是一个IWindowSession类型的AIDL文件,它会通过Binder IPC通知WMS在屏幕上开辟一个窗口,关于WMS的实现流程也非常庞大,我们点到为止。这一步执行完我们的View就可以显示到屏幕上了
  • 注释3,最后一步执行了View#assignParent,内部将mParent设置为ViewRootImpl。所以,虽然ViewRootImpl不是一个View,但它是所有View的顶层Parent

小结开头我有提到,好多人将API中的Window/PhoneWindow等价于窗口,但实际上操作开辟窗口的是ViewRootImpl,并且负责管理View的绘制,是整个视图系统最关键的一环。

疑惑

经常听到有人说onStart阶段处于可见模式,对此我感到疑惑。通过源码的分析可知onResume执行完毕后才会创建窗口并开启DecorView的绘制,所以在onStart连窗口都没有何谈可见

注意点:

初学Android时经常在onCreate时机获取View宽高而犯错,原因是View是在onResume后才开始绘制,所以在此之前无法获取到View宽高状态,此时可以通过View.post{}或者addOnGlobalLayoutListener来获取宽高

Java Framework层面视图系统的实现非常复杂,为了方便大家理解,我列出提到的几个关键类和对应的职责

  • Window是一个抽象类,通过控制DecorView提供了一些标准的UI方案,比如背景、标题、虚拟按键等
  • PhoneWindowWindow的唯一实现类,完善了Window的功能,并提供了事件的中转
  • WindowManager是一个接口,继承自ViewManager接口,提供了View的基本操作方法
  • WindowManagerImp实现了WindowManager接口,内部通过组合方式持有WindowManagerGlobal,用来操作View
  • WindowManagerGlobal是一个全局单例,内部可以通过ViewRootImplView添加至窗口
  • ViewRootImpl是所有ViewParent,用来管理View的绘制以及窗口的开辟
  • IWindowSessionIWindowSession类型的AIDL接口,可以通过Binder IPC通知WMS开辟窗口

至此关于Java Framework层面视图系统的设计与实现梳理完毕

综上所述

  • 一切视图均由Canvas而来
  • View的出现是为了提供视图模板,用来提升开发效率
  • 窗口可以让View有条不紊的显示
  • Activity给每个窗口增加生命周期,让窗口切换更加优雅
  • PhoneWindow只是提供些标准的UI方案,与窗口不等价
  • 可通过WindowManagerView添加到窗口
  • ViewRootImpl才是开辟窗口的那个角色,并管理View的绘制,是视图系统最关键的一环
  • 错综复杂的视图系统基本都隐藏Activity内部,开发者只需基于模板方法即可开发

学了又忘?通俗易懂 Android视图系统的设计与实现!相关推荐

  1. Android 视图系统的设计与实现 | 通俗易懂

    Bezier | 作者 承香墨影 | 编辑 https://juejin.cn/post/6865625913309511687 | 原文 郭霖(ID:guolin_blog) | 转自 前言 Hi, ...

  2. 通俗易懂,Android视图系统的设计与实现

    前言 说到Android视图大家第一反应肯定是Activity以及View,毕竟这是我们从入门开始接触最多的两个组件.但提到Activity和View之间联系以及设计背景可能会难道一大片人.其实关于视 ...

  3. Android视图系统的设计与实现

    前言 说到Android视图大家第一反应肯定是Activity以及View,毕竟这是我们从入门开始接触最多的两个组件.但提到Activity和View之间联系以及设计背景可能会难道一大片人.其实关于视 ...

  4. 一篇通俗易懂的Android视图系统设计与实现

    好文推荐 作者:Bezier 前言 说到Android视图大家第一反应肯定是Activity以及View,毕竟这是我们从入门开始接触最多的两个组件.但提到Activity和View之间联系以及设计背景 ...

  5. andy学java系列之J2ME的移动支付系统的设计与实现

    andy学java系列 J2ME的移动支付系统的设计与实现 ----三星SDK支付API介绍 移动支付是移动电子商务中的最重要的部分之一.安全性.私密性.易用性是移动支付的最重要的几个问题.目前有许多 ...

  6. 基于SSM的家教系统的设计与实现毕业设计-附源码221752

    摘 要 信息化社会内需要与之针对性的信息获取途径,但是途径的扩展基本上为人们所努力的方向,由于站在的角度存在偏差,人们经常能够获得不同类型信息,这也是技术最为难以攻克的课题.针对家教系统服务等问题,对 ...

  7. 基于Vue和SpringBoot的网上蛋糕销售系统的设计和实现

    作者主页:Designer 小郑 作者简介:Java全栈软件工程师一枚,来自浙江宁波,负责开发管理公司OA项目,专注软件前后端开发(Vue.SpringBoot和微信小程序).系统定制.远程技术指导. ...

  8. java系列之J2ME的移动支付系统的设计与实现

    andy学java系列 J2ME的移动支付系统的设计与实现 ----三星SDK支付API介绍 移动支付是移动电子商务中的最重要的部分之一.安全性.私密性.易用性是移动支付的最重要的几个问题.目前有许多 ...

  9. 被称为“2022大热门”的Android车载系统开发,到底应该怎么学?

    前言: 随着汽车智能化的速度不断加快,车载系统目前已经进入了混战的阶段,国产车载系统纷纷加入布局,很多车企也基于Android车载系统来开发自己的新系统,不过想要打造像安卓一样的汽车生态,还有很大的发 ...

最新文章

  1. 004_ZooKeeper客户端基础命令
  2. Vue 动态路由的实现以及 Springsecurity 按钮级别的权限控制
  3. 小赋诗歌一首,以感学生时代结束
  4. 网管笔记(1)8.22
  5. 实战|全程分析js到getshell
  6. PowerDesigner表结构和字段大小写转换
  7. cocos2d-x 发动机分析:程序如何开始和结束?
  8. 工作说明书(Statement of Work,简称SOW)
  9. hdu-2032杨辉三角
  10. C#正则表达式 — 正则表达式类
  11. 数据结构笔记(二十九)--最小生成树(prim算法思想)
  12. Exchange Server 2010高可用性配置
  13. mysql银行储蓄额度格式_mysql创建表用于银行储蓄系统
  14. 暴几个用明文在网上传输用户名密码的网站
  15. 针对 VOC2007和VOC2012 的具体用法
  16. 苹果手机人脸识别不了是什么原因_iPhone和安卓手机的人脸识别有什么区别?
  17. Hbase报错解决方法ERROR: KeeperErrorCode = NoNode for /hbase/master
  18. 一站式:虚拟机安装Linux系统(CentOS_7),多节点Linux环境打造,XShell的使用
  19. HTML5期末大作业:旅游酒店网站设计——旅游酒店服务预订(1页) web网页设计—— 出游
  20. nginx配置Router

热门文章

  1. 基于stm32通过HC_05(ZS-040)蓝牙模块用手机控制stm32开发板上led灯的亮灭
  2. 微信小程序中,Bmob的入门(基于官方文档的补充)
  3. flashcharge充电协议_iQOO 5充电评测:55W FlashCharge超快闪充加持
  4. 密码框点击眼睛显示隐藏
  5. (点、6)精配准Iterative Closest Point(ICP)
  6. 【Mecanum wheel】初理解
  7. 问卷星python自动提交
  8. 如何在应用中跳转到 设置系统锁屏的界面
  9. 阿里云混合云首席架构师张晓丹:政企混合云技术架构的演进和发展
  10. JSON 三级联动 高校专业分类