《Vuejs设计与实现》第 18 章(同构渲染)(上)

目录

[18.1 CSR、SSR 以及同构渲染](#18.1 CSR、SSR 以及同构渲染)

[18.2 将虚拟 DOM 渲染为 HTML 字符串](#18.2 将虚拟 DOM 渲染为 HTML 字符串)

[18.3 将组件渲染为 HTML 字符串](#18.3 将组件渲染为 HTML 字符串)


Vue.js 可以用于构建客户端应用程序,组件的代码在浏览器中运行,并输出 DOM 元素。

同时,Vue.js 还可以在 Node.js 环境中运行,它可以将同样的组件渲染为字符串并发送给浏览器。

这实际上描述了 Vue.js 的两种渲染方式,即客户端渲染(client-side rendering,CSR),以及服务端渲染(server-side rendering,SSR)。

另外,Vue.js 作为现代前端框架,不仅能够独立地进行 CSR 或 SSR,还能够将两者结合,形成所谓的同构渲染(isomorphicrendering)。

本章,我们将讨论 CSR、SSR 以及同构渲染之间的异同,以及 Vue.js 同构渲染的实现机制。

18.1 CSR、SSR 以及同构渲染

服务端渲染并不是一项新技术,也不是一个新概念。

在 Web 2.0 之前,网站主要负责提供各种各样的内容,通常是一些新闻站点、个人博客、小说站点等。这些站点主要强调内容本身,而不强调与用户之间具有高强度的交互。

当时的站点基本采用传统的服务端渲染技术来实现。例如,比较流行的 PHP/JSP 等技术。下面给出服务端渲染的工作流程图:

  1. 用户通过浏览器请求站点。
  2. 服务器请求 API 获取数据。
  3. 接口返回数据给服务器。
  4. 服务器根据模板和获取的数据拼接出最终的 HTML 字符串。
  5. 服务器将 HTML 字符串发送给浏览器,浏览器解析 HTML 内容并渲染。

当用户再次通过超链接进行页面跳转,会重复上述 5 个步骤。

传统的服务端渲染的用户体验非常差,任何一个微小的操作都可能导致页面刷新。

后来以 AJAX 为代表,催生了 Web 2.0。在这个阶段,大量的 SPA(single-page application)诞生,也就是接下来我们要介绍的 CSR 技术。

与 SSR 在服务端完成模板和数据的融合不同,CSR 是在浏览器中完成模板与数据的融合,并渲染出最终的 HTML 页面。CSR 工作流程图:

客户端向服务器或 CDN 发送请求,获取静态的 HTML 页面。

注意,此时获取的 HTML 页面通常是空页面。在 HTML 页面中,会包含 <style><link><script> 等标签。例如:

复制代码
<!DOCTYPE html>
<html lang="zh">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>My App</title>
		<link rel="stylesheet" href="/dist/app.css" />
	</head>
	<body>
		<div id="app"></div>

		<script src="/dist/app.js"></script>
	</body>
</html>

是一个包含 <link rel="stylesheet"><script> 标签的空 HTML 页面。

浏览器在得到该页面后,不会渲染出任何内容,所以从用户的视角看,此时页面处于"白屏"阶段。

解析 HTML 内容。通过 <link rel="stylesheet"><script> 等标签加载引用的资源。

因为页面的渲染任务是由 JavaScript 来完成的,所以当 JavaScript 被解释和执行后,才会渲染出页面内容,即"白屏"结束。

但初始渲染出来的内容通常是一个"骨架",因为还没有请求 API 获取数据。
客户端再通过 AJAX 技术请求 API 获取数据,一旦接口返回数据,客户端就会完成动态内容的渲染,并呈现完整的页面。

当用户再次通过点击"跳转"到其他页面时,浏览器并不会真正的进行跳转动作,即不会进行刷新,而是通过前端路由的方式动态地渲染页面,这对用户的交互体验会非常友好。

但很明显的是,与 SSR 相比,CSR 会产生所谓的"白屏"问题。并且它对 SEO(搜索引擎优化)也不友好。

下图从多个方面比较了 SSR 与 CSR:

可以看到,无论是 SSR 还是 CSR,都不可以作为"银弹",我们需要从项目的实际需求出发,决定到底采用哪一个。例如你的项目非常需要 SEO,那么就应该采用 SSR。

那么,我们能否融合 SSR 与 CSR 两者的优点于一身呢?答案是"可以的",这就是接下来我们要讨论的同构渲染。

同构渲染分为首次渲染(即首次访问或刷新页面)以及非首次渲染。下图是同构渲染首次渲染的工作流程:

实际上,同构渲染中的首次渲染与 SSR 的工作流程是一致的。

当首次访问或者刷新页面时,整个页面的内容是在服务端完成渲染的,浏览器最终得到的是渲染好的 HTML 页面。

但是该页面是纯静态的,这意味着用户还不能与页面进行任何交互,因为整个应用程序的脚本还没有加载和执行。

另外,该静态的 HTML 页面中也会包含 <link><script> 等标签。

同构渲染所产生的 HTML 页面会包含当前页面所需要的初始化数据。而 SSR 不会。

服务器通过 API 请求的数据会被序列化为字符串,并拼接到静态的 HTML 字符串中,最后一并发送给浏览器。这么做实际上是为了后续的激活操作,后文讲解。

假设浏览器已经接收到初次渲染的静态 HTML 页面,接下来浏览器会解析并渲染该页面。

在解析过程中,浏览器会发现 HTML 代码中存在 <link><script> 标签,于是会从 CDN 或服务器获取相应的资源,这一步与 CSR 一致。

当 JavaScript 资源加载完毕后,会进行激活操作,这里的激活就是我们在 Vue.js 中常说的 "hydration"。激活包含两部分工作内容。

  • Vue.js 在当前页面已经渲染的 DOM 元素以及 Vue.js 组件所渲染的虚拟 DOM 之间建立联系。
  • Vue.js 从 HTML 页面中提取由服务端序列化后发送过来的数据,用以初始化整个 Vue.js 应用程序。

激活完成后,整个应用程序已经完全被 Vue.js 接管为 CSR 应用程序了。

后续操作都会按照 CSR 应用程序的流程来执行。当然,如果刷新页面,仍然会进行服务端渲染,然后再进行激活,如此往复。

下图对比了 SSR、CSR 和同构渲染的优劣:

可以看到,同构渲染除了也需要部分服务端资源外,其他方面的表现都非常棒。

由于同构渲染方案在首次渲染时和浏览器刷新时仍然需要服务端完成渲染工作,所以也需要部分服务端资源。

但相比所有页面跳转都需要服务端完成渲染来说,同构渲染所占用的服务端资源相对少一些。

注意理论上同构渲染无法提升可交互时间(TTI)。还是需要像 CSR 那样等待 JavaScript 资源加载完成,并且客户端激活完成后,才能响应用户操作。

同构渲染的"同构"一词的含义是,同样一套代码既可以在服务端运行,也可以在客户端运行。

例如,我们用 Vue.js 编写一个组件,该组件既可以在服务端运行,被渲染为 HTML 字符串;也可以在客户端运行,就像普通的 CSR 应用程序一样。

18.2 将虚拟 DOM 渲染为 HTML 字符串

既然"同构"指的是,同样的代码既能在服务端运行,也能在客户端运行,我们来说说如何在服务端将虚拟 DOM 渲染为 HTML 字符串。

给出如下虚拟节点对象,它用来描述一个普通的 div 标签:

复制代码
const ElementVNode = {
  type: 'div',
  props: {
    id: 'foo',
  },
  children: [{ type: 'p', children: 'hello' }],
}

为了将虚拟节点 ElementVNode 渲染为字符串,我们需要实现 renderElementVNode 函数。

该函数接收用来描述普通标签的虚拟节点作为参数,并返回渲染后的 HTML 字符串:

复制代码
function renderElementVNode(vnode) {
	// 返回渲染后的结果,即 HTML 字符串
}

在不考虑任何边界条件的情况下,实现 renderElementVNode 非常简单,如下所示:

复制代码
function renderElementVNode(vnode) {
	// 取出标签名称 tag 和标签属性 props,以及标签的子节点
	const { type: tag, props, children } = vnode
	// 开始标签的头部
	let ret = `<${tag}`
	// 处理标签属性
	if (props) {
		for (const k in props) {
			// 以 key="value" 的形式拼接字符串
			ret += ` ${k}="${props[k]}"`
		}
	}
	// 开始标签的闭合
	ret += `>`

	// 处理子节点
	// 如果子节点的类型是字符串,则是文本内容,直接拼接
	if (typeof children === 'string') {
		ret += children
	} else if (Array.isArray(children)) {
		// 如果子节点的类型是数组,则递归地调用 renderElementVNode 完成渲染
		children.forEach(child => {
			ret += renderElementVNode(child)
		})
	}

	// 结束标签
	ret += `</${tag}>`

	// 返回拼接好的 HTML 字符串
	return ret
}

接着,我们可以调用 renderElementVNode 函数完成对 ElementVNode 的渲染:

复制代码
console.log(renderElementVNode(ElementVNode)) // <div id="foo"><p>hello</p></div>

可以看到,输出结果是我们所期望的 HTML 字符串。实际上,将一个普通标签类型的虚拟节点渲染为 HTML 字符串,本质上是字符串的拼接。

不过,上面给出的 renderElementVNode 函数的实现仅仅用来展示将虚拟 DOM 渲染为 HTML 字符串的核心原理,并不满足生产要求,因为它存在以下几点缺陷:

  • renderElementVNode 函数在渲染标签类型的虚拟节点时,还需要考虑该节点是否是自闭合标签。
  • 对于属性(props)的处理会比较复杂,要考虑属性名称是否合法,还要对属性值进行 HTML 转义。
  • 子节点的类型多种多样,可能是任意类型的虚拟节点,如 Fragment、组件、函数式组件、文本等,这些都需要处理。
  • 标签的文本子节点也需要进行 HTML 转义。

上述这些问题都属于边界条件,接下来我们逐个处理。首先处理自闭合标签,它的术语叫作 void element,它的完整列表如下:

复制代码
const VOID_TAGS = 'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'

对于 void element,由于它无须闭合标签,所以在为此类标签生成 HTML 字符串时,无须为其生成对应的闭合标签,如下面的代码所示:

复制代码
const VOID_TAGS = 'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'.split(',')

function renderElementVNode2(vnode) {
	const { type: tag, props, children } = vnode
	// 判断是否是 void element
	const isVoidElement = VOID_TAGS.includes(tag)

	let ret = `<${tag}`

	if (props) {
		for (const k in props) {
			ret += ` ${k}="${props[k]}"`
		}
	}

	// 如果是 void element,则自闭合
	ret += isVoidElement ? `/>` : `>`
	// 如果是 void element,则直接返回结果,无须处理 children,因为 void element 没有 children
	if (isVoidElement) return ret

	if (typeof children === 'string') {
		ret += children
	} else {
		children.forEach(child => {
			ret += renderElementVNode2(child)
		})
	}

	ret += `</${tag}>`

	return ret
}
复制代码

接下来,我们需要更严谨地处理 HTML 属性。处理属性需要考虑多个方面,首先是对 boolean attribute 的处理。

所谓 boolean attribute,并不是说这类属性的值是布尔类型,而是指,如果这类指令存在,则代表 true,否则代表 false。

例如 <input/> 标签的 checked 属性和 disabled 属性:

复制代码
<!-- 选中的 checkbox -->
<input type="checkbox" checked />
<!-- 未选中的 checkbox -->
<input type="checkbox" />

当渲染 boolean attribute 时,通常无须渲染它的属性值。

另外一点需要考虑的是安全问题,WHATWG 规范的 13.1.2.3 节中明确定义了属性名称的组成。

属性名称必须由一个或多个非以下字符组成。

  • 控制字符集(control character)的码点范围是:0x01, 0x1f0x7f,0x9f
  • U+0020 (SPACE)、U+0022 (")、U+0027 (')、U+003E (>)、U+002F (/)以及 U+003D (=)。
  • noncharacters,这里的 noncharacters 代表 Unicode 永久保留的码点,这些码点在 Unicode 内部使用,它的取值范围是:0xFDD0, 0xFDEF,还包括:0xFFFE、0xFFFF、0x1FFFE、0x1FFFF、0x2FFFE、0x2FFFF、0x3FFFE、0x3FFFF、0x4FFFE、0x4FFFF、0x5FFFE、0x5FFFF、0x6FFFE、0x6FFFF、0x7FFFE、0x7FFFF、0x8FFFE、0x8FFFF、0x9FFFE、0x9FFFF、0xAFFFE、0xAFFFF、0xBFFFE、0xBFFFF、0xCFFFE、0xCFFFF、0xDFFFE、0xDFFFF、0xEFFFE、0xEFFFF、0xFFFFE、0xFFFFF、0x10FFFE、0x10FFFF。

考虑到 Vue.js 的模板编译器在编译过程中已经对 noncharacters 以及控制字符集进行了处理,所以我们只需要小范围处理即可,任何不满足上述条件的属性名称都是不安全且不合法的。

另外,在虚拟节点中的 props 对象中,通常会包含仅用于组件运行时逻辑的相关属性。

例如,key 属性仅用于虚拟 DOM 的 Diff 算法,ref 属性仅用于实现 template ref 的功能等。在进行服务端渲染时,应该忽略这些属性。

除此之外,服务端渲染也无须考虑事件绑定。因此,也应该忽略 props 对象中的事件处理函数。

更加严谨的属性处理方案如下:

复制代码
function renderElementVNode(vnode) {
	const { type: tag, props, children } = vnode
	const isVoidElement = VOID_TAGS.includes(tag)

	let ret = `<${tag}`

	if (props) {
		// 调用 renderAttrs 函数进行严谨处理
		ret += renderAttrs(props)
	}

	ret += isVoidElement ? `/>` : `>`

	if (isVoidElement) return ret

	if (typeof children === 'string') {
		ret += children
	} else {
		children.forEach(child => {
			ret += renderElementVNode(child)
		})
	}

	ret += `</${tag}>`

	return ret
}

对应 renderAttrs 函数对 props 处理,具体实现如下:

复制代码
// 应该忽略的属性
const shouldIgnoreProp = ['key', 'ref']

function renderAttrs(props) {
	let ret = ''
	for (const key in props) {
		if (
			// 检测属性名称,如果是事件或应该被忽略的属性,则忽略它
			shouldIgnoreProp.includes(key) ||
			/^on[^a-z]/.test(key)
		) {
			continue
		}
		const value = props[key]
		// 调用 renderDynamicAttr 完成属性的渲染
		ret += renderDynamicAttr(key, value)
	}
	return ret
}

renderDynamicAttr 函数的实现如下:、

复制代码
// 用来判断属性是否是 boolean attribute
const isBooleanAttr = key =>
	(
		`itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly` +
		`,async,autofocus,autoplay,controls,default,defer,disabled,hidden,` +
		`loop,open,required,reversed,scoped,seamless,` +
		`checked,muted,multiple,selected`
	)
		.split(',')
		.includes(key)

// 用来判断属性名称是否合法且安全
const isSSRSafeAttrName = key => !/[>/="'\u0009\u000a\u000c\u0020]/.test(key)

function renderDynamicAttr(key, value) {
	if (isBooleanAttr(key)) {
		// 对于 boolean attribute,如果值为 false,则什么都不需要渲染,否则只需要渲染 key 即可
		return value === false ? `` : ` ${key}`
	} else if (isSSRSafeAttrName(key)) {
		// 对于其他安全的属性,执行完整的渲染,
		// 注意:对于属性值,我们需要对它执行 HTML 转义操作
		return value === '' ? ` ${key}` : ` ${key}="${escapeHtml(value)}"`
	} else {
		// 跳过不安全的属性,并打印警告信息
		console.warn(`[@vue/server-renderer] Skipped rendering unsafe attribute name: ${key}`)
		return ``
	}
}

这样我们就实现了对普通元素类型的虚拟节点的渲染。

实际上,在 Vue.js中,由于 class 和 style 这两个属性可以使用多种合法的数据结构来表示,例如 class 的值可以是字符串、对象、数组,所以理论上我们还需要考虑这些情况。

不过原理都是相通的,对于使用不同数据结构表示的 class 或 style,我们只需要将不同类型的数据结构序列化成字符串表示即可。

另外,观察上面代码中的 renderDynamicAttr 函数的实现能够发现,在处理属性值时,我们调用了 escapeHtml 对其进行转义处理,这对于防御 XSS 攻击至关重要。HTML 转义指的是将特殊字符转换为对应的 HTML 实体。其转换规则很简单。

  • 如果该字符串作为普通内容被拼接,则应该对以下字符进行转义。
    • 将字符 & 转义为实体 &。
    • 将字符 < 转义为实体 <。
    • 将字符 > 转义为实体 >。
  • 如果该字符串作为属性值被拼接,那么除了上述三个字符应该被转义之外,还应该转义下面两个字符。
    • 将字符 " 转义为实体 "。
    • 将字符 ' 转义为实体 '。

具体实现如下:

复制代码
const escapeRE = /["'&<>]/
function escapeHtml(string) {
	const str = '' + string
	const match = escapeRE.exec(str)

	if (!match) {
		return str
	}

	let html = ''
	let escaped
	let index
	let lastIndex = 0
	for (index = match.index; index < str.length; index++) {
		switch (str.charCodeAt(index)) {
			case 34: // "
				escaped = '&quot;'
				break
			case 38: // &
				escaped = '&amp;'
				break
			case 39: // '
				escaped = '&#39;'
				break
			case 60: // <
				escaped = '&lt;'
				break
			case 62: // >
				escaped = '&gt;'
				break
			default:
				continue
		}

		if (lastIndex !== index) {
			html += str.substring(lastIndex, index)
		}

		lastIndex = index + 1
		html += escaped
	}

	return lastIndex !== index ? html + str.substring(lastIndex, index) : html
}

原理很简单,只需要在给定字符串中查找需要转义的字符,然后将其替换为对应的 HTML 实体即可。

18.3 将组件渲染为 HTML 字符串

在上节,我们讨论了如何将普通标签类型的虚拟节点渲染为 HTML 字符串。

本节,我们将在此基础上,讨论如何将组件类型的虚拟节点渲染为 HTML 字符串。

假设我们有如下组件,以及用来描述组件的虚拟节点:

复制代码
// 组件
const MyComponent = {
	setup() {
		return () => {
			// 该组件渲染一个 div 标签
			return {
				type: 'div',
				children: 'hello',
			}
		}
	},
}

// 用来描述组件的 VNode 对象
const CompVNode = {
	type: MyComponent,
}

我们将实现 renderComponentVNode 函数,并用它把组件类型的虚拟节点渲染为 HTML 字符串:

  • subTree 本身可能是任意类型的虚拟节点,包括组件类型。因此,我们不能直接使用 renderElementVNode 来渲染它。
  • 执行 setup 函数时,也应该提供 setupContext 对象。而执行渲染函数 render 时,也应该将其 this 指向 renderContext 对象。实际上,在组件的初始化和渲染方面,其完整流程与第 13 章讲解的客户端的渲染流程一致。例如,也需要初始化 data,也需要得到 setup 函数的执行结果,并检查 setup 函数的返回值是函数还是 setupState 等。

对于第一个问题,我们可以通过封装通用函数来解决,如下所示:

复制代码
function renderVNode(vnode) {
	const type = typeof vnode.type
	if (type === 'string') {
		return renderElementVNode(vnode)
	} else if (type === 'object' || type === 'function') {
		return renderComponentVNode(vnode)
	} else if (vnode.type === Text) {
		// 处理文本...
	} else if (vnode.type === Fragment) {
		// 处理片段...
	} else {
		// 其他 VNode 类型
	}
}

有了 renderVNode 后,我们就可以在 renderComponentVNode 中使用它来渲染 subTree 了:

复制代码
function renderComponentVNode(vnode) {
	let {
		type: { setup },
	} = vnode
	const render = setup()
	const subTree = render()
	// 使用 renderVNode 完成对 subTree 的渲染
	return renderVNode(subTree)
}

第二个问题则涉及组件的初始化流程。我们先回顾一下组件在客户端渲染时的整体流程:

在进行服务端渲染时,组件的初始化流程与客户端渲染时组件的初始化流程基本一致,但有两个重要的区别:

  • 服务端渲染的是应用的当前快照,它不存在数据变更后重新渲染的情况。因此,所有数据在服务端都无须是响应式的。利用这一点,我们可以减少服务端渲染过程中创建响应式数据对象的开销。
  • 服务端渲染只需要获取组件要渲染的 subTree 即可,无须调用渲染器完成真实 DOM 的创建。因此,在服务端渲染时,可以忽略"设置 render effect 完成渲染"这一步。

下图给出了服务端渲染时初始化组件的流程:

可以看到,只需要对客户端初始化组件的逻辑稍作调整,即可实现组件在服务端的渲染。

另外,由于组件在服务端渲染时,不需要渲染真实 DOM 元素,所以无须创建并执行 render effect。

这意味着,组件的 beforeMount 以及 mounted 钩子不会被触发。

而且,由于服务端渲染不存在数据变更后的重新渲染逻辑,所以 beforeUpdate 和 updated 钩子也不会在服务端执行。完整的实现如下:

复制代码
function renderComponentVNode(vnode) {
	const isFunctional = typeof vnode.type === 'function'
	let componentOptions = vnode.type
	if (isFunctional) {
		componentOptions = {
			render: vnode.type,
			props: vnode.type.props,
		}
	}
	let { render, data, setup, beforeCreate, created, props: propsOption } = componentOptions

	beforeCreate && beforeCreate()

	// 无须使用 reactive() 创建 data 的响应式版本
	const state = data ? data() : null
	const [props, attrs] = resolveProps(propsOption, vnode.props)

	const slots = vnode.children || {}

	const instance = {
		state,
		props, // props 无须 shallowReactive
		isMounted: false,
		subTree: null,
		slots,
		mounted: [],
		keepAliveCtx: null,
	}

	function emit(event, ...payload) {
		const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
		const handler = instance.props[eventName]
		if (handler) {
			handler(...payload)
		} else {
			console.error('事件不存在')
		}
	}

	// setup
	let setupState = null
	if (setup) {
		const setupContext = { attrs, emit, slots }
		const prevInstance = setCurrentInstance(instance)
		const setupResult = setup(shallowReadonly(instance.props), setupContext)
		setCurrentInstance(prevInstance)
		if (typeof setupResult === 'function') {
			if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略')
			render = setupResult
		} else {
			setupState = setupContext
		}
	}

	vnode.component = instance

	const renderContext = new Proxy(instance, {
		get(t, k, r) {
			const { state, props, slots } = t

			if (k === '$slots') return slots

			if (state && k in state) {
				return state[k]
			} else if (k in props) {
				return props[k]
			} else if (setupState && k in setupState) {
				return setupState[k]
			} else {
				console.error('不存在')
			}
		},
		set(t, k, v, r) {
			const { state, props } = t
			if (state && k in state) {
				state[k] = v
			} else if (k in props) {
				props[k] = v
			} else if (setupState && k in setupState) {
				setupState[k] = v
			} else {
				console.error('不存在')
			}
		},
	})

	created && created.call(renderContext)

	const subTree = render.call(renderContext, renderContext)

	return renderVNode(subTree)
}
复制代码

观察上面的代码可以发现,该实现与客户端渲染的逻辑基本一致。

这段代码与第 13 章给出的关于组件渲染的代码也非常相似。

唯一的区别在于,在服务端渲染时,无须使用 reactive 函数为 data 数据创建响应式版本,并且 props 数据也无须是浅响应的。

相关推荐
summerkissyou198714 分钟前
Android 16 架构图
android
神龙天舞200140 分钟前
MySQL 备库为什么会延迟好几个小时
android·数据库·mysql
蜡台2 小时前
Flutter 环境搭建 Dart配置
flutter·dart
码农coding4 小时前
android12 systemUI 之锁屏
android
圆山猫4 小时前
[Virtualization](三):RISC-V H-extension 与 Guest 执行模式
android·java·risc-v
爱笑鱼4 小时前
Binder(七):getCallingUid() 读到的是谁?clearCallingIdentity() 又清掉了什么?
android
落叶飘飘s10 小时前
餐饮服务与软件创新的融合:解析海底捞 APP 的 Flutter 鸿蒙开发之路
flutter·华为·harmonyos
2501_9159090610 小时前
iOS 应用反调试技详解术 检测调试器的原理与防护实践
android·ios·小程序·https·uni-app·iphone·webview
Lvan的前端笔记10 小时前
AndroidX 完全入门指南
android·androidx
帅次11 小时前
Android 应用高级面试:Window 近1年高频追问 18 题
android·面试·职场和发展