开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Flutter 上字体的另类玩法:FontFeature

发表于 2022-03-24

在以前的 《Flutter 上默认的文本和字体知识点》 和 《带你深入理解 Flutter 中的字体“冷”知识》 中,已经介绍了很多 Flutter 上关于字体有趣的知识点,而本篇讲继续介绍 Flutter 上关于 Text 的一个属性:FontFeature , 事实上相较于 Flutter ,本篇内容可能和前端或者设计关系更密切。

相信本篇绝对是你能看到关于 Flutter FontFeature 相关的少数资料之一。

什么是 FontFeature? 简单来说就是影响字体形状的一个属性 ,在前端的对应领域里应该是 font-feature-settings,它有别于 FontFamily ,是用于指定字体内字的形状的一个参数。

如下图所示是 frac 分数和 tnum 表格数字的对比渲染效果,这种效果可以在不增加字体库时实现特殊的渲染,另外 Feature 也有特征的意思,所以也可以理解为字体特征。

我们知道 Flutter 默认在 Android 上使用的是 Roboto 字体,而在 iOS 上使用的是 SF 字体,但是其实 Roboto 字体也是分很多类型的,比如你去查阅手机的 system/fonts 目录,就会发现很多带有 Roboto 字样的字体库存在。

所以 Roboto 之类的字体库是一个很大的字体集,不同的 font-weight 其实对应着不同的 ttf ,例如默认情况下的 Roboto 是不支持 font-weight 为 600 的配置:

所以如下图所示,如果我们设置了 w400 - w700 的 weight ,可以很明显看到中间的 500 和 600 其实是一样的粗细,所以在设置 weight 或者设计 UI 时,就需要考虑不同平台上的 weight 是否支持想要的效果。

回归到 FontFeature 上,那 Roboto 自己默认支持多少种 features 呢? 答案是 26 种,它们的编码如下所示,运行后效果也如下图所示,从日常使用上看,这 26 种 Feature 基本满足开发的大部分需求。

“c2sc”、 “ccmp”、 “dlig”、 “dnom”、 “frac”、 “liga”、 “lnum”、 “locl”、 “numr”、 “onum”、 “pnum”、 “salt”、 “smcp”、 “ss01”、 “ss02”、 “ss03”、 “ss04”、 “ss05”、 “ss06”、 “ss07”、 “tnum”、 “unic”、 “cpsp”、 “kern”、 “mark”、 “mkmk”

而 iOS 上的 SF pro 默认支持 39 种 Features , 它们的编码如下所示,运行后效果也如下图所示,可以看到 SF pro 支持的 Features 更多。

“c2sc”、 “calt”、 “case”、 “ccmp”、 “cv01”、 “cv02”、 “cv03”、 “cv04”、 “cv05”、 “cv06”、 “cv07”、 “cv08”、 “cv09”、 “cv10”、 “dnom”、 “frac”、 “liga”、 “locl”、 “numr”、 “pnum”、 “smcp”、 “ss01”、 “ss02”、 “ss03”、 “ss05”、 “ss06”、 “ss07”、 “ss08”、 “ss09”、 “ss12”、 “ss13”、 “ss14”、 “ss15”、 “ss16”、 “ss17”、 “subs”、 “sups”、 “tnum”、 “kern”

所以可以看到,并不是所有字体支持的 Features 都是一样的,比如 iOS 上支持 sups 上标显示和 subs 下标显示,但是 Android 上的 Roboto 并不支持,甚至很多第三方字体其实并不支持 Features 。

同样在 Web 上也存在各种限制,比如 swsh(花体)默认下基本不支持浏览器,fwid 、 nlck 不支持 Safari 浏览器等。

有趣的是,在 Flutter Web 有一个渲染文本时会变模糊的问题#58159,这个问题目前官方还没有修复,但是你可以通过给 Text 设置任意 FontFeatures 来解决这个问题。

因为出现模糊的情况一般都是因为使用了 canvas 标签绘制文本,而如果 Text 控件具有 fontFeatures 时,就会被设置为 <p> + <span> 进行渲染,从而避免问题。

最后,如果对 FontFeature 还感兴趣的朋友,可以通过一下资料深入了解,如果你还有什么关于字体上的问题,欢迎留言讨论。

  • 如果你想了解更多的 features 类型,可以通过 en.wikipedia.org/wiki/List_o… 了解更多;
  • 如果你对自己的使用的字体支持什么 features 感兴趣,可以通过 wakamaifondue.com 了解更多;

补充内容

基于网友的问题再补充一下拓展知识,毕竟这方面内容也不多。

事实上在 dart 里就可以看到对应 FontWeight 约定俗称用的是字体集里的什么字体:

名称 值
Thin w100
Extra w200
Light w300
Normal/regular/plain w400(默认)
Medium w500
Semi-bold w600
Bold w700
Extra-bold- w800
Black 900

所以如果对于默认字体有疑问,可以在你的手机字体找找是否有对应的字体,比如虽然我们说 roboto 没有 600 ,但是如果是 roboto mono 字体集是有 600 的 fontweight,甚至还有 600 斜体: fonts.google.com/specimen/Ro… 。

这里可以用 Android Studio 的 Device File Explorer 查看/system/etc/fonts.xml 下当前手机的字体编码情况,右键该文件 save as 到电脑上,下图是华为上的 fonts.xml 截图:

你也可以通过如下原生代码,获取到对应现在 Android 系统支持的字体 Typeface ,但是这个 Typeface 并不是真正的字体名,还是要对应在 fonts.xml 下查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码protected Map<String, Typeface> getSSystemFontMap() {
Map<String, Typeface> sSystemFontMap = null;
try {
//Typeface typeface = Typeface.class.newInstance();
Typeface typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL);
Field f = Typeface.class.getDeclaredField("sSystemFontMap");
f.setAccessible(true);
sSystemFontMap = (Map<String, Typeface>) f.get(typeface);
for (Map.Entry<String, Typeface> entry : sSystemFontMap.entrySet()) {
Log.e("FontMap", entry.getKey() + " ---> " + entry.getValue() + "\n");
}
} catch (Exception e) {
e.printStackTrace();
}
return sSystemFontMap;
}

private static List<String> getKeyWithValue(Map map, Typeface value) {
Set set = map.entrySet();
List<String> arr = new ArrayList<>();
for (Object obj : set) {
Map.Entry entry = (Map.Entry) obj;
if (entry.getValue().equals(value)) {
String str = (String) entry.getKey();
arr.add(str);
}
}
return arr;
}

例如前面我们说过 Roboto 没有 w600 , 但是通过输出比对,华为上有 source-sans-pro 是支持 w600 :

另外注意这是 Flutter 而不是原生,具体实现调用是在 Engine 的 paragraph_skia.cc 和 paragraph_builder_skia.cc 下对应的 setFontFamilies 相关逻辑,当然默认字体库指定在 typography.dart 下就看到,例如 'Roboto' 、 '.SF UI Display' 、'.SF UI Text' 、'.AppleSystemUIFont' 、 'Segoe UI' :

名称 值
Android,Fuchsia,Linux Roboto
iOS .SF UI Display,.SF UI Text
MacOS .AppleSystemUIFont
Windows Segoe UI

例如:.SF Text 适用于更小的字体;.SF Display 则适用于偏大的字体,我记得分水岭好像是 20pt 左右,不过 SF(San Francisco) 属于动态字体,系统会动态匹配。

另外如果你在 Mac 的 Web 上使用 Flutter Web,可以看到指定的是 .AppleSystemUIFont ,而对于 .AppleSystemUIFont 它其实不算是一种字体,而是苹果上字体的一种集合别称:

还有,如果你去看 Flutter 默认自带的 cupertino/context_menu_action.dart ,就可以看到一个有趣的情况:

为了强调和 iOS 上的样式尽量一直,当开发者配置 isDefaultAction == true 时,会强行指定 '.SF UI Text' 并指定为 FontWeight.w600。

当然,前面我们说了那么多,主要是针对英文的情况下,而在中文下还是有差异的,之前的文章也介绍过:

  • 默认在 iOS 上:
+ 中文字体:`PingFang SC`
+ 英文字体:`.SF UI Text` 、`.SF UI Display`
  • 默认在 Android 上:
+ 中文字体:`Source Han Sans` / `Noto`
+ 英文字体:`Roboto`

例如,在苹果上的简体中文其实会是 PingFang SC 字体,对应还有PingFang TC 和 PingFang HK 的繁体集,而关于这个问题在 Flutter 上之前还出现过比较有意思的 bug :

用户在输入拼音时,iOS 会在中文拼音之间添加额外的 unicode \u2006 字符,比如输入 "nihao" ,iOS 系统会在 skia 中添加文字 “ni\u2006hao ”,从而导致字体无效的情况。

当然后续的 #16709 修复了这个问题 ,而在以前的文章我也讲过,当时我遇到了 “Flutter 在 iOS 系统上,系统语言是韩文时,在和中文一起出现会导致字体显示异常” 的问题 :

解决方法也很简单,就是给 fontFamilyFallback 配置上 ["PingFang SC" , "Heiti SC"] 就可以了,这是因为韩文在苹果手机上使用的应该是 Apple SD Gothic Neo 这样的超集字体库,【广】这个字符在这个字体集上是不存在的,所以就变成了中文的【广】;

所以可以看到,字体相关是一个平时很少会深入接触的东西,但是一旦涉及多语言和绘制,就很容易碰到问题的领域。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Threejs 火焰效果实现艾尔登法环动态logo 🔥

发表于 2022-03-22

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

背景

《艾尔登法环》是最近比较火的一款游戏,观察可以发现它的 Logo 是由几个圆弧和线段构成。本文使用 React + Three.js 技术栈,实现具有火焰效果艾尔登法环 Logo,本文中涉及到的知识点包括:Fire.js 基本使用方法及 Three.js 的其他基础知识。

效果

实现效果如 👆 banner 图所示,页面主体由 Logo 图形构成,Logo 具有由远及近的加载效果,加载完毕后具有上下缓动动画效果。

在线预览:

  • 👀 地址1:3d-dragonir.vercel.app/#/ring
  • 👀 地址2:dragonir.github.io/3d/#/ring

已适配:

  • 💻 PC 端
  • 📱 移动端

实现

Logo 的火焰效果主要是通过 Fire.js 实现的, 开始实现之前先来了解一下它的基本用法。

💡 Fire.js

Threejs 提供了一个可以实现火焰和烟雾效果的扩展包,通过引用并设置参数可以实现非常逼真的火焰和厌恶效果。【不过该扩展包已经从新版中移除】

火焰设置可选属性:

  • color1:内焰颜色
  • color2:外焰颜色
  • color3:烟雾颜色
  • colorBias:颜色偏差
  • burnRate:燃烧率
  • diffuse:扩散
  • viscosity:粘度
  • expansion:膨胀
  • swirl:旋转
  • drag:拖拽
  • airSpeed:空气速度
  • windX:X 轴风向
  • windY:Y 轴风向
  • speed:火焰速度
  • massConservation:质量守恒

常用方法:

  • 添加资源:addSource(u, v, radius, density, windX, windY)
  • 清除资源:clearSources()
  • 设置贴图:setSourceMap(texture)

基本用法:

通过简单几步:创建载体、使用Fire构造函数初始化、添加火焰、添加到场景等简单几步,就可实现火焰效果。可以创建多个火源,多种火焰效果也可以叠加到同一个载体上。

1
2
3
4
5
6
7
8
js复制代码const geometry = new THREE.PlaneBufferGeometry(10, 10);
const fire = new THREE.Fire(geometry,{
textureWidth: 10,
textureHeight: 10,
debug:false
});
fire.addSource(0.5, 0.1, 0.1, 1.0, 0.0, 1.0);
scene.add(fire);

实现效果:

🔗 在线亲手尝试调整火焰各种参数效果:threejs/examples/webgl_fire.html

资源引入

引入开发所需的的模块资源,注意 Three.js 和 Fire.js 是从当前目录引入的旧版本,新版本已删除 Fire.js。TWEEN 用于实现简单的镜头补间动画、ringTexture 是需要显示火焰效果轮廓的贴图。

1
2
3
4
5
js复制代码import React from 'react';
import * as THREE from './libs/three.module.js';
import { Fire } from './libs/Fire.js';
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";
import ringTexture from './images/ring.png';

页面 DOM 结构非常简单,只包含一个渲染 WEBGL 的容器 #container。

1
js复制代码<div className='ring_page' id="container"></div>

场景初始化

初始化渲染场景、相机和光源。(如若需要详细了解这部分知识可翻阅我往期的文章或阅读官网文档,本文不再赘述)

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码const container = document.getElementById('container');
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
renderer.setClearAlpha(0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 100);
camera.lookAt(new THREE.Vector3(0, 0, 0));
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambientLight);

💡 设置渲染背景透明度

  • alpha:canvas 是否开启透明度,默认为 false。
  • renderer.setClearAlpha(alpha : Float):设置 alpha 透明度值,合法参数是一个 0.0 到 1.0 之间的浮点数。

以上代码中,通过设置 new THREE.WebGLRenderer({ antialias: true, alpha: true }) 和 renderer.setClearAlpha(0) 可以将 canvas 背景设置为透明,这样就可以通过 CSS 设置背景样式。本例中的背景图片就是通过 CSS 设置的,而不是 Sence.background。

🌵 当开启 alpha: true 时,透明度默认为 0,可以不用写 renderer.setClearAlpha(0)。

添加Logo主体

创建一个 PlaneBufferGeometry 平面作为火焰 Logo 载体,Logo 形状通过调用 setSourceMap 使用贴图生成,然后添加 Fire.js 的各种参数,调整平面的位置,最后将它添加到场景中即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
js复制代码const ring = new Fire(new THREE.PlaneBufferGeometry(20, 25), {
textureWidth: 800,
textureHeight: 1000,
debug: false,
});
ring.setSourceMap(new THREE.TextureLoader().load(ringTexture));
ring.color1 = new THREE.Color(0xffffff);
ring.color2 = new THREE.Color(0xf59e00);
ring.color3 = new THREE.Color(0x08120a);
ring.colorBias = .6;
ring.burnRate = 10;
ring.diffuse = 1;
ring.viscosity = .5;
ring.expansion = -1.6;
ring.swirl = 10;
ring.drag = 0.4;
ring.airSpeed = 18;
ring.windX = 0.1;
ring.windY = 0.2;
ring.speed = 100;
ring.massConservation = false;
ring.position.y = 4;
ring.position.z = -6;
scene.add(ring)

🌵 Logo 形状也可直接使用圆环等几何体拼接生成,本文为了简单省时并且更加逼真,直接使用了自己在 Photoshop 中绘制的贴图。注意贴图主体部分实际应用中要使用白色,为了便于展示我改成了黑色。

页面缩放适配

1
2
3
4
5
js复制代码window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}, false);

镜头补间动画

页面刚开始加载完成时由远及近的镜头补间动画。

1
2
3
4
js复制代码const controls = new OrbitControls(camera, renderer.domElement);
Animations.animateCamera(camera, controls, { x: 0, y: 0, z: 22 }, { x: 0, y: 0, z: 0 }, 2400, () => {
controls.enabled = false;
});

页面重绘动画

图案上线往复运动的缓动动画及渲染更新。

1
2
3
4
5
6
7
8
9
js复制代码let step = 0;
const animate = () => {
requestAnimationFrame(animate);
renderer.render(scene, camera);
stats && stats.update();
TWEEN && TWEEN.update();
step += .03;
ring && (ring.position.y = Math.abs(2.2 + Math.sin(step)));
}

到这里,一个低配版的艾尔登法环 Logo 所有效果都全部实现了 😂,希望随着自己图形学方面知识的积累,后续可以通过 shader 实现更加炫酷的效果 🔥。 完整代码可通过下方链接查看。

🔗 完整代码:github.com/dragonir/3d…

总结

本文知识点主要包含的的新知识:

  • Fire.js 基本使用
  • 设置渲染背景透明度

想了解场景初始化、光照、阴影、基础几何体、网格、材质及其他Three.js的相关知识,可阅读我往期文章。转载请注明原文地址和作者。如果觉得文章对你有帮助,不要忘了一键三连哦 👍。

附录

  • [1]. Three.js 实现神奇的3D文字悬浮效果
  • [2]. Three.js 实现让二维图片具有3D效果
  • [3]. Three.js 实现2022冬奥主题3D趣味页面,冰墩墩 🐼
  • [4]. Three.js 制作一个专属3D奖牌
  • [5]. Three.js 实现虎年春节3D创意页面
  • [6]. Three.js 实现脸书元宇宙3D动态Logo
  • [7]. Three.js 实现3D全景侦探小游戏
  • [8]. Three.js 实现炫酷的酸性风格3D页面

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android Jetpack 开发套件

发表于 2022-03-20

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

Android Jetpack 开发套件是 Google 推出的 Android 应用开发编程范式,为开发者提供了解决应用开发场景中通用的模式化问题的最佳实践,让开发者可将时间精力集中于真正重要的业务编码工作上。

这篇文章是 Android Jetpack 系列文章的第 4 篇文章,完整目录可以移步至文章末尾~

前言

  • Kotlin Flow 是基于 Kotlin 协程基础能力搭建的一套数据流框架,从功能复杂性上看是介于 LiveData 和 RxJava 之间的解决方案。Kotlin Flow 拥有比 LiveData 更丰富的能力,但裁剪了 RxJava 大量复杂的操作符,做得更加精简。并且在 Kotlin 协程的加持下,Kotlin Flow 目前是 Google 主推的数据流框架。

  1. 为什么要使用 Flow?

LiveData、Kotlin Flow 和 RxJava 三者都属于 可观察的数据容器类,观察者模式是它们相同的基本设计模式,那么相对于其他两者,Kotlin Flow 的优势是什么呢?

LiveData 是 androidx 包下的组件,是 Android 生态中一个的简单的生命周期感知型容器。简单即是它的优势,也是它的局限,当然这些局限性不应该算 LiveData 的缺点,因为 LiveData 的设计初衷就是一个简单的数据容器。对于简单的数据流场景,使用 LiveData 完全没有问题。

  • LiveData 只能在主线程更新数据: 只能在主线程 setValue,即使 postValue 内部也是切换到主线程执行;
  • LiveData 数据重放问题: 注册新的订阅者,会重新收到 LiveData 存储的数据,这在有些情况下不符合预期(可以使用自定义的 LiveData 子类 SingleLiveData 或 UnPeekLiveData 解决,此处不展开);
  • LiveData 不防抖: 重复 setValue 相同的值,订阅者会收到多次 onChanged() 回调(可以使用 distinctUntilChanged() 解决,此处不展开);
  • LiveData 不支持背压: 在数据生产速度 > 数据消费速度时,LiveData 无法正常处理。比如在子线程大量 postValue 数据但主线程消费跟不上时,中间就会有一部分数据被忽略。

RxJava 是第三方组织 ReactiveX 开发的组件,Rx 是一个包括 Java、Go 等语言在内的多语言数据流框架。功能强大是它的优势,支持大量丰富的操作符,也支持线程切换和背压。然而 Rx 的学习门槛过高,对开发反而是一种新的负担,也会带来误用的风险。

Kotlin 是 kotlinx 包下的组件,不是单纯 Android 生态下的产物。那么,Flow 的优势在哪里呢?

  • Flow 支持协程: Flow 基于协程基础能力,能够以结构化并发的方式生产和消费数据,能够实现线程切换(依靠协程的 Dispatcher);
  • Flow 支持背压: Flow 的子类 SharedFlow 支持配置缓存容量,可以应对数据生产速度 > 数据消费速度的情况;
  • Flow 支持数据重放配置: Flow 的子类 SharedFlow 支持配置重放 replay,能够自定义对新订阅者重放数据的配置;
  • Flow 相对 RxJava 的学习门槛更低: Flow 的功能更精简,学习性价比相对更高。不过 Flow 是基于协程,在协程会有一些学习成本,但这个应该拆分来看。

当然 Kotlin Flow 也存在一些局限:

  • Flow 不是生命周期感知型组件: Flow 不是 Android 生态下的产物,自然 Flow 是不会关心组件生命周期。那么我们如何确保订阅者在监听 Flow 数据流时,不会在错误的状态更新 View 呢?这个问题在下文 第 6 节再说。

  1. 冷数据流与热数据流

Kotlin Flow 包含三个实体:数据生产方 - (可选的)中介者 - 数据使用方。数据生产方负责向数据流发射(emit)数据,而数据使用方从数据流中消费数据。根据生产方产生数据的时机,可以将 Kotlin Flow 分为冷流和热流两种:

  • 普通 Flow(冷流): 冷流是不共享的,也没有缓存机制。冷流只有在订阅者 collect 数据时,才按需执行发射数据流的代码。冷流和订阅者是一对一的关系,多个订阅者间的数据流是相互独立的,一旦订阅者停止监听或者生产代码结束,数据流就自动关闭。
  • SharedFlow / StateFlow(热流): 热流是共享的,有缓存机制的。无论是否有订阅者 collect 数据,都可以生产数据并且缓存起来。热流和订阅者是一对多的关系,多个订阅者可以共享同一个数据流。当一个订阅者停止监听时,数据流不会自动关闭(除非使用 WhileSubscribed 策略,这个在下文再说)。


  1. 普通 Flow(冷流)

普通 Flow 是冷流,数据是不共享的,也没有缓存机制。数据源会延迟到消费者开始监听时才生产数据(如终端操作 collect{}),并且每次订阅都会创建一个全新的数据流。 一旦消费者停止监听或者生产者代码结束,Flow 会自动关闭。

1
2
3
4
5
6
7
8
9
10
scss复制代码val coldFlow: Flow<Int> = flow {
// 生产者代码
while(true) {
// 执行计算
emit(result)
delay(100)
}
// 生产者代码结束,流将被关闭
}.collect{ data ->
}

冷流 Flow 主要的操作如下:

  • 创建数据流 flow{}: Flow 构造器会创建一个新的数据流。flow{} 是 suspend 函数,需要在协程中执行;
  • 发送数据 emit(): emit() 将一个新的值发送到数据流中;
  • 终端操作 collect{}: 触发数据流消费,可以获取数据流中所有的发出值。Flow 是冷流,数据流会延迟到终端操作 collect 才执行,并且每次在 Flow 上重复调用 collect,都会重复执行 flow{} 去触发发送数据动作(源码位置:AbstractFlow)。collect 是 suspend 函数,需要在协程中执行。
  • 异常捕获 catch{}: catch{} 会捕获数据流中发生的异常;
  • 协程上下文切换 flowOn(): 更改上流数据操作的协程上下文 CoroutineContext,对下流操作没有影响。如果有多个 flowOn 运算符,每个 flowOn 只会更改当前位置的上游数据流;
  • 状态回调 onStart: 在数据开始发送之前触发,在数据生产线程回调;
  • 状态回调 onCompletion: 在数据发送结束之后触发,在数据生产线程回调;
  • 状态回调 onEmpty: 在数据流为空时触发(在数据发送结束但事实上没有发送任何数据时),在数据生产线程回调。

普通 Flow 的核心代码在 AbstractFlow 中,可以看到每次调用终端操作 collect,collector 代码块都会执行一次,也就是重新执行一次数据生产代码:

AbstractFlow.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码public abstract class AbstractFlow<T> : Flow<T> {

@InternalCoroutinesApi
public final override suspend fun collect(collector: FlowCollector<T>) {
// 1. 对 flow{} 的包装
val safeCollector = SafeCollector(collector, coroutineContext)
try {
// 2. 执行 flow{} 代码块
collectSafely(safeCollector)
} finally {
// 3. 释放协程相关的参数
safeCollector.releaseIntercepted()
}
}

public abstract suspend fun collectSafely(collector: FlowCollector<T>)
}

private class SafeFlow<T>(private val block: suspend FlowCollector<T>.() -> Unit) : AbstractFlow<T>() {
override suspend fun collectSafely(collector: FlowCollector<T>) {
collector.block()
}
}

  1. SharedFlow —— 高配版 LiveData

下文要讲的 StateFlow 其实是 SharedFlow 的一个子类,所以我们先讲 SharedFlow。SharedFlow 和 StateFlow 都属于热流,无论是否有订阅者(collect),都可以生产数据并且缓存。 它们都有一个可变的版本 MutableSharedFlow 和 MutableStateFlow,这与 LiveData 和 MutableLiveData 类似,对外暴露接口时,应该使用不可变的版本。

4.1 SharedFlow 与 MutableSharedFlow 接口

直接对着接口讲不明白,这里先放出这两个接口方便查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码public interface SharedFlow<out T> : Flow<T> {
// 缓存的重放数据的快照
public val replayCache: List<T>
}

public interface MutableSharedFlow<T> : SharedFlow<T>, FlowCollector<T> {

// 发射数据(注意这是个挂起函数)
override suspend fun emit(value: T)

// 尝试发射数据(如果缓存溢出策略是 SUSPEND,则溢出时不会挂起而是返回 false)
public fun tryEmit(value: T): Boolean

// 活跃订阅者数量
public val subscriptionCount: StateFlow<Int>

// 重置重放缓存,新订阅者只会收到注册后新发射的数据
public fun resetReplayCache()
}

4.2 构造一个 SharedFlow

我会把 SharedFlow 理解为一个高配版的 LiveData,这点首先在构造函数就可以体现出来。SharedFlow 的构造函数允许我们配置三个参数:

SharedFlow.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码public fun <T> MutableSharedFlow(
// 重放数据个数
replay: Int = 0,
// 额外缓存容量
extraBufferCapacity: Int = 0,
// 缓存溢出策略
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow<T> {
val bufferCapacity0 = replay + extraBufferCapacity
val bufferCapacity = if (bufferCapacity0 < 0) Int.MAX_VALUE else bufferCapacity0 // coerce to MAX_VALUE on overflow
return SharedFlowImpl(replay, bufferCapacity, onBufferOverflow)
}

public enum class BufferOverflow {
// 挂起
SUSPEND,
// 丢弃最早的一个
DROP_OLDEST,
// 丢弃最近的一个
DROP_LATEST
}
参数 描述
reply 重放数据个数,当新订阅者时注册时会重放缓存的 replay 个数据
extraBufferCapacity 额外缓存容量,在 replay 之外的额外容量,SharedFlow 的缓存容量 capacity = replay + extraBufferCapacity(实在想不出额外容量有什么用,知道可以告诉我)
onBufferOverflow 缓存溢出策略,即缓存容量 capacity 满时的处理策略(SUSPEND、DROP_OLDEST、DROP_LAST)

SharedFlow 默认容量 capacity 为 0,重放 replay 为 0,缓存溢出策略是 SUSPEND,发射数据时已注册的订阅者会收到数据,但数据会立刻丢弃,而新的订阅者不会收到历史发射过的数据。

为什么我们可以把 SharedFlow 理解为 “高配版” LiveData,拿 SharedFlow 和 LiveData 做个简单的对比就知道了:

  • 容量问题: LiveData 容量固定为 1 个,而 SharedFlow 容量支持配置 0 个到 多个;
  • 背压问题: LiveData 无法应对背压问题,而 SharedFlow 有缓存空间能应对背压问题;
  • 重放问题: LiveData 固定重放 1 个数据,而 SharedFlow 支持配置重放 0 个到多个;
  • 线程问题: LiveData 只能在主线程订阅,而 SharedFlow 支持在任意线程(通过协程的 Dispatcher)订阅。

当然 SharedFlow 也并不是完胜,LiveData 能够处理生命周期安全问题,而 SharedFlow 不行(因为 Flow 本身就不是纯 Android 生态下的组件),不合理的使用会存在不必要的操作和资源浪费,以及在错误的状态更新 View 的风险。不过别担心,这个问题可以通过 第 6 节 的 Lifecycle API 来解决。

4.3 普通 Flow 转换为 SharedFlow

前面提到过,冷流是不共享的,也没有缓存机制。使用 Flow.shareIn 或 Flow.stateIn 可以把冷流转换为热流,一来可以将数据共享给多个订阅者,二来可以增加缓冲机制。

Share.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
kotlin复制代码public fun <T> Flow<T>.shareIn(
// 协程作用域范围
scope: CoroutineScope,
// 启动策略
started: SharingStarted,
// 控制数据重放的个数
replay: Int = 0
): SharedFlow<T> {
val config = configureSharing(replay)
val shared = MutableSharedFlow<T>(
replay = replay,
extraBufferCapacity = config.extraBufferCapacity,
onBufferOverflow = config.onBufferOverflow
)
@Suppress("UNCHECKED_CAST")
scope.launchSharing(config.context, config.upstream, shared, started, NO_VALUE as T)
return shared.asSharedFlow()
}
public companion object {
// 热启动式:立即开始,并在 scope 指定的作用域结束时终止
public val Eagerly: SharingStarted = StartedEagerly()
// 懒启动式:在注册首个订阅者时开始,并在 scope 指定的作用域结束时终止
public val Lazily: SharingStarted = StartedLazily()

public fun WhileSubscribed(
stopTimeoutMillis: Long = 0,
replayExpirationMillis: Long = Long.MAX_VALUE
): SharingStarted =
StartedWhileSubscribed(stopTimeoutMillis, replayExpirationMillis)
}

sharedIn 的参数 scope 和 replay 不需要过多解释,主要介绍下 started: SharingStarted 启动策略,分为三种:

  • Eagerly(热启动式): 立即启动数据流,并保持数据流(直到 scope 指定的作用域结束);
  • Lazily(懒启动式): 在首个订阅者注册时启动,并保持数据流(直到 scope 指定的作用域结束);
  • WhileSubscribed(): 在首个订阅者注册时启动,并保持数据流直到在最后一个订阅者注销时结束(或直到 scope 指定的作用域结束)。通过 WhildSubscribed() 策略能够在没有订阅者的时候及时停止数据流,避免引起不必要的资源浪费,例如一直从数据库、传感器中读取数据。

whileSubscribed() 还提供了两个配置参数:

+ **stopTimeoutMillis 超时时间(毫秒):** 最后一个订阅者注销订阅后,保留数据流的超时时间,默认值 0 表示立刻停止。这个参数能够帮助防抖,避免订阅者临时短时间注销就马上关闭数据流。例如希望等待 5 秒后没有订阅者则停止数据流,可以使用 whileSubscribed(5000)。
+ **replayExpirationMillis 重放过期时间(毫秒):** 停止数据流后,保留重放数据的超时时间,默认值 Long.MAX\_VALUE 表示永久保存(replayExpirationMillis 发生在停止数据流后,说明 replayExpirationMillis 时间是在 stopTimeoutMillis 之后发生的)。例如希望希望等待 5 秒后停止数据流,再等待 5 秒后的数据视为无用的陈旧数据,可以使用 whileSubscribed(5000, 5000)。

  1. StateFlow —— LiveData 的替代品

StateFlow 是 SharedFlow 的子接口,可以理解为一个特殊的 SharedFlow。不过它们的继承关系只是接口上有继承关系,内部的实现类 SharedFlowImpl 和 StateFlowImpl 其实是分开的,这里要留个印象就好。

5.1 StateFlow 与 MutableStateFlow 接口

这里先放出这两个接口方便查看:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码public interface StateFlow<out T> : SharedFlow<T> {
// 当前值
public val value: T
}

public interface MutableStateFlow<T> : StateFlow<T>, MutableSharedFlow<T> {
// 当前值
public override var value: T

// 比较并设置(通过 equals 对比,如果值发生真实变化返回 true)
public fun compareAndSet(expect: T, update: T): Boolean
}

5.2 构造一个 StateFlow

StateFlow 的构造函数就简单多了,有且仅有一个必选的参数,代表初始值:

1
kotlin复制代码public fun <T> MutableStateFlow(value: T): MutableStateFlow<T> = StateFlowImpl(value ?: NULL)

5.3 特殊的 SharedFlow

StateFlow 是 SharedFlow 的一种特殊配置,MutableStateFlow(initialValue) 这样一行代码本质上和下面使用 SharedFlow 的方式是完全相同的:

1
2
3
4
5
6
ini复制代码val shared = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
shared.tryEmit(initialValue) // emit the initial value
val state = shared.distinctUntilChanged() // get StateFlow-like behavior
  • 有初始值: StateFlow 初始化时必须传入初始值;
  • 容量为 1: StateFlow 只会保存一个值;
  • 重放为 1: StateFlow 会向新订阅者重放最新的值;
  • 不支持 resetReplayCache() 重置重放缓存: StateFlow 的 resetReplayCache() 方法抛出 UnsupportedOperationException
  • 缓存溢出策略为 DROP_OLDEST: 意味着每次发射的新数据会覆盖旧数据;

总的来说,StateFlow 要求传入初始值,并且仅支持保存一个最新的数据,会向新订阅者会重放一次最新值,也不允许重置重放缓存。说 StateFlow 是 LiveData 的替代品一点不为过。除此之外,StateFlow 还额外支持一些特性:

  • 数据防抖: 意味着仅在更新值并且发生变化才会回调,如果更新值没有变化不会回调 collect,其实就是在发射数据时加了一层拦截:

StateFlow.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码public override var value: T
get() = NULL.unbox(_state.value)
set(value) { updateState(null, value ?: NULL) }

override fun compareAndSet(expect: T, update: T): Boolean =
updateState(expect ?: NULL, update ?: NULL)

private fun updateState(expectedState: Any?, newState: Any): Boolean {
var curSequence = 0
var curSlots: Array<StateFlowSlot?>? = this.slots // benign race, we will not use it
synchronized(this) {
val oldState = _state.value
if (expectedState != null && oldState != expectedState) return false // CAS support
if (oldState == newState) return true // 如果新值 equals 旧值则拦截, 但 CAS 返回 true
_state.value = newState
...
return true
}
}
  • CAS 操作: 原子性的比较与设置操作,只有在旧值与 expect 相同时返回 ture。

5.4 普通 Flow 转换为 StateFlow

跟 SharedFlow 一样,普通 Flow 也可以转换为 StateFlow:

Share.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码public fun <T> Flow<T>.stateIn(
// 共享开始时所在的协程作用域范围
scope: CoroutineScope,
// 共享开始策略
started: SharingStarted,
// 初始值
initialValue: T
): StateFlow<T> {
val config = configureSharing(1)
val state = MutableStateFlow(initialValue)
scope.launchSharing(config.context, config.upstream, state, started, initialValue)
return state.asStateFlow()
}

  1. 安全地观察 Flow 数据流

前面也提到了,Flow 不具备 LiveData 的生命周期感知能力,所以订阅者在监听 Flow 数据流时,会存在生命周期安全的问题。Google 推荐的做法是使用 Lifecycle#repeatOnLifecycle API:

1
2
arduino复制代码// 从 2.4.0 开始支持 Lifecycle#repeatOnLifecycle API
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
  • LifecycleOwner#addRepeatingJob: 在生命周期到达指定状态时,自动创建并启动协程执行代码块,在生命周期低于该状态时,自动取消协程。因为 addRepeatingJob 不是挂起函数,所以不遵循结构化并发的规则。目前已经废弃,被下面的 repeatOnLifecycle() 替代了(废弃 addRepeatingJob 的考量见 设计 repeatOnLifecycle API 背后的故事 );
  • Lifecycle#repeatOnLifecycle: repeatOnLifecycle 的作用相同,区别在于它是一个 suspend 函数,需要在协程中执行;
  • Flow#flowWithLifecycle: Flow#flowWithLifecycle 的作用相同,内部基于 repeatOnLifecycle API。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
kotlin复制代码class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
locationProvider.locationFlow().collect {
// update UI
}
}
}
}

class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// repeatOnLifecycle 是 suspends 函数,所以需要在协程中执行
// 当 lifecycleScope 的生命周期高于 STARTED 状态时,启动一个新的协程并执行代码块
// 当 lifecycleScope 的生命周期低于 STARTED 状态时,取消该协程
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// 当前生命周期一定高于 STARTED 状态,可以安全地从数据流中取数据,并更新 View
locationProvider.locationFlow().collect {
// update UI
}
}
// 结构化并发:生命周期处于 DESTROYED 状态时,切换回调用 repeatOnLifecycle 的协程继续执行
}
}
}

class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

locationProvider.locationFlow()
.flowWithLifecycle(this, Lifecycle.State.STARTED)
.onEach {
// update UI
}
.launchIn(lifecycleScope)
}
}

如果不使用 Lifecycle#repeatOnLifecycle API,具体会出现什么问题呢?

  • Activity.lifecycleScope.launch: 立即启动协程,并在 Activity 销毁时取消协程;
  • Fragment.lifecycleScope.launch: 立即启动协程,并在 Fragment 销毁时取消协程;
  • Fragment.viewLifecycleOwner.lifecycleScope.launch: 立即启动协程,并在 Fragment 中视图销毁时取消协程。

可以看到,这些协程 API 只有在最后组件 / 视图销毁时才会取消协程,当视图进入后台时协程并不会被取消,Flow 会持续生产数据,并且会触发更新视图。

  • LifecycleContinueScope.launchWhenX: 在生命周期到达指定状态时立即启动协程执行代码块,在生命周期低于该状态时挂起(而不是取消)协程,在生命周期重新高于指定状态时,自动恢复该协程。

可以看到,这些协程 API 在视图离开某个状态时会挂起协程,能够避免更新视图。但是 Flow 会持续生产数据,也会产生一些不必要的操作和资源消耗(CPU 和内存)。 虽然可以在视图进入后台时手动取消协程,但很明显增写了模板代码,没有 repeatOnLifecycle API 来得简洁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码class LocationActivity : AppCompatActivity() {

// 协程控制器
private var locationUpdatesJob: Job? = null

override fun onStart() {
super.onStart()
locationUpdatesJob = lifecycleScope.launch {
locationProvider.locationFlow().collect {
// update UI
}
}
}

override fun onStop() {
// 在视图进入后台时取消协程
locationUpdatesJob?.cancel()
super.onStop()
}
}

回过头来看,repeatOnLifecycle 是怎么实现生命周期感知的呢?其实很简单,是通过 Lifecycle#addObserver 来监听生命周期变化:

RepeatOnLifecycle.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
kotlin复制代码suspendCancellableCoroutine<Unit> { cont ->
// Lifecycle observers that executes `block` when the lifecycle reaches certain state, and
// cancels when it falls below that state.
val startWorkEvent = Lifecycle.Event.upTo(state)
val cancelWorkEvent = Lifecycle.Event.downFrom(state)
val mutex = Mutex()
observer = LifecycleEventObserver { _, event ->
if (event == startWorkEvent) {
// Launch the repeating work preserving the calling context
launchedJob = this@coroutineScope.launch {
// Mutex makes invocations run serially,
// coroutineScope ensures all child coroutines finish
mutex.withLock {
coroutineScope {
block()
}
}
}
return@LifecycleEventObserver
}
if (event == cancelWorkEvent) {
launchedJob?.cancel()
launchedJob = null
}
if (event == Lifecycle.Event.ON_DESTROY) {
cont.resume(Unit)
}
}
this@repeatOnLifecycle.addObserver(observer as LifecycleEventObserver)
}

  1. Channel 通道

在协程的基础能力上使用数据流,除了上文提到到 Flow API,还有一个 Channel API。Channel 是 Kotlin 中实现跨协程数据传输的数据结构,类似于 Java 中的 BlockQueue 阻塞队列。不同之处在于 BlockQueue 会阻塞线程,而 Channel 是挂起线程。Google 的建议 是优先使用 Flow 而不是 Channel,主要原因是 Flow 会更自动地关闭数据流,而一旦 Channel 没有正常关闭,则容易造成资源泄漏。此外,Flow 相较于 Channel 提供了更明确的约束和操作符,更灵活。

Channel 主要的操作如下:

  • 创建 Channel: 通过 Channel(Channel.UNLIMITED) 创建一个 Channel 对象,或者直接使用 produce{} 创建一个生产者协程;
  • 关闭 Channel: Channel#close();
  • 发送数据: Channel#send() 往 Channel 中发送一个数据,在 Channel 容量不足时 send() 操作会挂起,Channel 默认容量 capacity 是 1;
  • 接收数据: 通过 Channel#receive() 从 Channel 中取出一个数据,或者直接通过 actor 创建一个消费者协程,在 Channel 中数据不足时 receive() 操作会挂起。
  • 广播通道 BroadcastChannel(废弃,使用 SharedFlow): 普通 Channel 中一个数据只会被一个消费端接收,而 BroadcastChannel 允许多个消费端接收。
1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码public fun <E> Channel(

// 缓冲区容量,当超出容量时会触发 onBufferOverflow 拒绝策略
capacity: Int = RENDEZVOUS,

// 缓冲区溢出策略,默认为挂起,还有 DROP_OLDEST 和 DROP_LATEST
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,

// 处理元素未能成功送达处理的情况,如订阅者被取消或者抛异常
onUndeliveredElement: ((E) -> Unit)? = null

): Channel<E>

  1. 浅尝一下

到这里,LiveData、Flow 和 Channel 我们都讲了一遍了,实际场景中怎么使用呢,浅尝一下。

  • 事件(Event): 事件是一次有效的,新订阅者不应该收到旧的事件,因此事件数据适合用 SharedFlow(replay=0);
  • 状态(State): 状态是可以恢复的,新订阅者允许收到旧的状态数据,因此状态数据适合用 StateFlow。

示例代码如下,不熟悉 MVI 模式的同学可以移步:Android Jetpack 开发套件 #5 Android UI 架构演进:从 MVC 到 MVP、MVVM、MVI

BaseViewModel.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
kotlin复制代码interface UiState

interface UiEvent

interface UiEffect

abstract class BaseViewModel<State : UiState, Event : UiEvent, Effect : UiEffect> : ViewModel() {

// 初始状态
private val initialState: State by lazy { createInitialState() }

// 页面需要的状态,对应于 MVI 模式的 ViewState
private val _uiState = MutableStateFlow<State>(initialState)
// 对外接口使用不可变版本
val uiState = _uiState.asStateFlow()

// 页面状态变更的 “副作用”,类似一次性事件,不需要重放的状态变更(例如 Toast)
private val _effect = MutableSharedFlow<Effect>()
// 对外接口使用不可变版本
val effect = _effect.asSharedFlow()

// 页面的事件操作,对应于 MVI 模式的 Intent
private val _event = MutableSharedFlow<Event>()

init {
viewModelScope.launch {
_event.collect {
handleEvent(it)
}
}
}

// 初始状态
protected abstract fun createInitialState(): State

// 事件处理
protected abstract fun handleEvent(event: Event)

/**
* 事件入口
*/
fun sendEvent(event: Event) {
viewModelScope.launch {
_event.emit(event)
}
}

/**
* 状态变更
*/
protected fun setState(newState: State) {
_uiState.value = newState
}

/**
* 副作用
*/
protected fun setEffect(effect: Effect) {
_effect.send(effect)
}
}

参考资料

  • 协程 Flow 最佳实践 | 基于 Android 开发者峰会应用 —— Android 官方文档
  • 设计 repeatOnLifecycle API 背后的故事 —— Android 官方文档
  • 使用更为安全的方式收集 Android UI 数据流 —— Android 官方文档
  • Flow 操作符 shareIn 和 stateIn 使用须知 —— Android 官方文档
  • 从 LiveData 迁移到 Kotlin 数据流 —— Android 官方文档
  • 用 Kotlin Flow 解决开发中的痛点 —— 都梁人 著
  • 抽丝剥茧Kotlin - 协程中绕不过的Flow —— 九心 著
  • Kotlin flow实践总结! —— 入魔的冬瓜 著
  • Android—kotlin-Channel超详细讲解 —— hqk 著

推荐阅读

Android Jetpack 系列文章目录如下(2023/07/08 更新):

  • #1 Lifecycle:生命周期感知型组件的基础
  • #2 为什么 LiveData 会重放数据,怎么解决?
  • #3 为什么 Activity 都重建了 ViewModel 还存在?
  • #4 有小伙伴说看不懂 LiveData、Flow、Channel,跟我走
  • #5 Android UI 架构演进:从 MVC 到 MVP、MVVM、MVI
  • #6 ViewBinding 与 Kotlin 委托双剑合璧
  • #7 AndroidX Fragment 核心原理分析
  • #8 OnBackPressedDispatcher:Jetpack 处理回退事件的新姿势
  • #9 食之无味!App Startup 可能比你想象中要简单
  • #10 从 Dagger2 到 Hilt 玩转依赖注入(一)

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

VSCode Webview 完美集成 Webpack 热更

发表于 2022-03-19

最近在写一个 VSCode 扩展时需要通过一个 Webview 去渲染一些网页内容,作为一个前端配置工程师,自然是忍受不了没有热更新的网页开发。经过了一番折腾,最终实现了让 VSCode Webview 完美集成 webpack 的热更新。

开发环境

在正式进入主题之前,先介绍下用来演示的项目开发环境。文章中演示代码仓库:vscode-webview-webpack-hmr-example。

技术栈

  • VSCode 扩展本身使用 typescript 开发,直接使用 tsc 编译,并未使用 webpack 打包。目前只提供一个命令用于打开测试用的 webview,代码存放在 src 目录
  • 前端代码存放在 web 目录下,使用 webpack 打包
  • 前端框架:react
  • 前端开发语言:typescript
  • React 集成组件热更新方式:react-refresh
  • 配置 webpack-dev-server 方式:node API

版本信息

  • VSCode: 1.66.0-insider
  • OS: MacOS 12.3
  • Webpack: 5.70.0
  • webpack-dev-server: 4.7.4
  • React: 17.0.2
  • react-refresh: 0.11.0
  • @pmmmwh/react-refresh-webpack-plugin: 0.5.4

演示项目起步情况

截止到第一次提交:实现加载 webpack 打包内容。已经实现打开 WebView 可以加载 Webpack 打包的 js bundle。如果你按照项目的说明正确的启动项目并打开 webview,不出意外可以看到一下内容:

实现加载 webpack 打包内容

VSCode 扩展中加载 Webview 网页内容的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typescript复制代码// src/MyWebview.ts
private setupHtmlForWebview() {
const webview = this.panel.webview;
const localPort = 3000;
const localServerUrl = `localhost:${localPort}`;
const scriptRelativePath = 'webview.js';
const scriptUri = `http://${localServerUrl}/${scriptRelativePath}`;
const nonce = getNonce()

this.html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${MyWebview.viewType}</title>
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
webview.html = this.html;
}

可以看到我们加载 bundle 的方式是直接将 script 的 scr 设置为 webpack-dev-server 托管的 js bundle 地址。

我们采用 node API 的方式配置 webpack-dev-server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
javascript复制代码// scripts/webpack.config.js
const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

/**@type {import('webpack').Configuration}*/
module.exports = {
mode: 'development',
entry: [resolve(__dirname, '../web/index.tsx')],
output: {
path: resolve(__dirname, '../dist/web'),
filename: 'webview.js',
},
resolve: {
extensions: ['.js', '.ts', '.tsx', '.json'],
},
module: {
rules: [
{
test: /\.(js|ts|tsx)$/,
loader: 'babel-loader',
options: { cacheDirectory: true },
exclude: /node_modules/,
},
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.svg$/],
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10 * 1024,
},
},
generator: {
filename: 'images/[hash]-[name][ext][query]',
},
},
],
},
devtool: 'eval-source-map',
plugins: [
new HtmlWebpackPlugin({
template: resolve(__dirname, '../web/index.html'),
}),
],
};

启动 webpack-dev-server 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
javascript复制代码// scripts/start.js
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');

const devConfig = require('./webpack.config');

function start() {
const compiler = webpack(devConfig);
const devServerOptions = {
hot: false,
client: false,
liveReload: false,
host: 'localhost',
port: 3000,
open: false,
devMiddleware: {
stats: 'minimal',
},
};
const server = new WebpackDevServer(devServerOptions, compiler);
server.start();
}

start();

截止至目前,并未在 webpack 配置中加入任何热更新的代码。接下来让我们一个一个解决在当前情况下集成 webpack 热更新你会碰到的各种问题。

WebSocket URL 不合法

当我们按照 webpack 官方文档和 react-refresh-webpack-plugin 集成 webpack 热更新和 react 组件的局部刷新后,首先会碰到下面的问题:

无法构造 WebScoket 对象

Uncaught DOMException: Failed to construct ‘WebSocket’: The URL’s scheme must be either ‘ws’ or ‘wss’. ‘vscode-webview’ is not allowed

提示已经告诉我们 new WebScoket() 的时候,URL 的协议必须是 ws 或者是 wss,但是你用的是 vscode-webview。通过 devtools 查看 VSCode 的 webview,我们可以清楚的看到 VSCode webview 是使用 iframe 实现的,协议是 vscode-webview:

VSCode Webview

我们知道,web-dev-server 会在 bundle 中注入 js 代码创建一个 WebSocket 链接用于与 webpack-dev-server 通信,创建 websocket URL 的源码在: node_modules/webpack-dev-server/client/utils/createSocketURL.js。 简言之,由于我们没有手动指定 websocket 链接协议,webpack-dev-server 根据当前协议 vscode-webview,推测出 new WebSocket(URL) 的 URL 协议也是 vscode-webview,而 WebSocket 对象是不允许只接收 ws 或者 wss 协议。

WebSocket URL

当我们使用 node API 配置 webpack-dev-server 时,集成热更新时可以在 entry 中配置 webpack-dev-server 创建 WebSocket Client 的各种选项。

为了解决这个问题,我们只需要手动指定我们建立 WebSocket 链接的协议是 ws。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
javascript复制代码const devServerClientOptions = {
hot: true,
// !: 指定构造 WebSocket 的协议是 ws
protocol: 'ws',
hostname: 'localhost',
port: 3000,
path: 'ws',
};
const devServerClientQuery = Object.entries(devServerClientOptions)
.map(([k, v]) => `${k}=${v}`)
.join('&');
const devEntries = [
'webpack/hot/dev-server.js',
`webpack-dev-server/client/index.js?${devServerClientQuery}`,
];

/**@type {import('webpack').Configuration}*/
module.exports = {
mode: 'development',
entry: [...devEntries, resolve(__dirname, '../web/index.tsx')],
output: {
publicPath: 'http://localhost:3000/',
path: resolve(__dirname, '../dist/web'),
filename: 'webview.js',
},
resolve: {
extensions: ['.js', '.ts', '.tsx', '.json'],
},
};

除了需要指定协议之外,包括 hostname, port 都需要指定,不然会出现各种各样的链接错误。

无效的 origin host

解决完 WebSocket URL 的问题后还会碰到 WebSocket 建立链接 origin 请求头中 host 不合法的问题:

无效的 origin host

打开 network 面板,查看我们 ws 建立链接时发送的请求头:

ws请求头

可以看到 origin 请求头值为:vscode-webview://180k16ne6bgriaem9878j8lt8el0qnj9uc9uodq31ah3fdgvvea8,vscode-webview 这个 host 对于 webpack-dev-server 的默认策略来说是不合法的,具体可以查看: What is the purpose of webpack-dev-server’s allowedHosts security mechanism?

解决办法也很简单,配置 devserver 的 allowedHosts 选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
javascript复制代码function start() {
const compiler = webpack(devConfig);
const devServerOptions = {
hot: false,
client: false,
liveReload: false,
host: 'localhost',
port: 3000,
open: false,
devMiddleware: {
stats: 'minimal',
},
// 允许任何 host
allowedHosts: 'all',
};
}

start();

跨域问题

到目前为止,可以说在 VSCode Webview 中的 webpack-dev-server 的 client 终于和 server 端顺利建立了链接:

ws顺利建立链接

但是当我们修改网页代码,例如修改 App 组件中的 Hello World 文本内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
react复制代码// web/App.tsx
import imgSrc from './xiaomai.gif';

export default function App() {
return (
<div className="app">
<img
src={imgSrc}
style={{
display: 'block',
marginBottom: 20,
}}
/>
<button>Hello World</button>
</div>
);
}

控制台就可以看到跨域错误:

CORS

解决 CORS 问题对于我们前端同学来说都是小 case 啦:

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码// scripts/start.js
function start() {
const compiler = webpack(devConfig);
const devServerOptions = {
// ...
allowedHosts: 'all',
// 允许任何域名访问
headers: {
'Access-Control-Allow-Origin': '*',
},
};
}

VSCode webview reload 限制

到目前为止,如果你修改前端代码不触发页面 reload 那么一切看起来会很美好:

不算完美

一旦触发 relaod, 例如我们删掉一个导入语句,webpack 在无法应用热更新的时候默认就会 relaod 页面,这会导致 webview 内容空白。

我们可以做个更简单的测试,直接在 index.tsx 中加入下面代码:

1
2
3
4
5
6
7
8
9
10
react复制代码import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.querySelector('#root'));

// 结果就是开始能看到 hello world,三秒后啥也看不到
setTimeout(() => {
console.log('ready to reload');
window.location.reload();
}, 3000);

测试 reload

其实只要你在 VSCode 的 Webview 中调用 window.location.reload 就会导致 Webview 空白。那这还搞毛,写前端代码虽然有热更新但还是时不时会触发 reload 的。

要解决这个问题,我们就要搞点骚操作了。

聊聊 webpack 和 webpack-dev-server

有些刚接触 webpack 的同学可能对于他俩各自的职责会没有清晰的认识。

webpack 包的定位是一个打包器,并且提供了热更新的接口给外部插件去实现具体的热更新逻辑,通过 webpack 可以打包出一个 bundle。

webpack-dev-server 定位是一个使用内存文件系统的静态服务器,用于托管 webpack 打包出的 bundle。同时。它还是一个 websocket 服务器,负责 webpack 和 bundle 代码的通信。

当我们访问 webpack-dev-server 托管的 SPA 时,修改网页代码,有时候会触发 relaod,那么这部分 relaod 相关的源代码是在 webpack 中还是 webpack-dev-server 中呢?

其实前面已经说了是 webpack 负责提供热更新的接口,那么在无法应用热更新时,webpack 注入 bundle 中的源代码就会触发 relaod。

还记得我们前面配置热更新时需要配置额外的 entry 吗?

1
2
3
4
javascript复制代码const devEntries = [
'webpack/hot/dev-server.js',
`webpack-dev-server/client/index.js?${devServerClientQuery}`,
];

其实 webpack 触发 reload 的逻辑就在这个文件 webpack/hot/dev-server.js,代码不多,也就 60 几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
javascript复制代码/*
MIT License http://www.opensource.org/licenses/mit-license.php
Author Tobias Koppers @sokra
*/
/* globals __webpack_hash__ */
if (module.hot) {
var lastHash;
var upToDate = function upToDate() {
return lastHash.indexOf(__webpack_hash__) >= 0;
};
var log = require('./log');
var check = function check() {
module.hot
.check(true)
.then(function (updatedModules) {
if (!updatedModules) {
log('warning', '[HMR] Cannot find update. Need to do a full reload!');
log('warning', '[HMR] (Probably because of restarting the webpack-dev-server)');
window.location.reload();
return;
}

if (!upToDate()) {
check();
}

require('./log-apply-result')(updatedModules, updatedModules);

if (upToDate()) {
log('info', '[HMR] App is up to date.');
}
})
.catch(function (err) {
var status = module.hot.status();
if (['abort', 'fail'].indexOf(status) >= 0) {
log('warning', '[HMR] Cannot apply update. Need to do a full reload!');
log('warning', '[HMR] ' + log.formatError(err));
window.location.reload();
} else {
log('warning', '[HMR] Update failed: ' + log.formatError(err));
}
});
};
var hotEmitter = require('./emitter');
hotEmitter.on('webpackHotUpdate', function (currentHash) {
lastHash = currentHash;
if (!upToDate() && module.hot.status() === 'idle') {
log('info', '[HMR] Checking for updates on the server...');
check();
}
});
log('info', '[HMR] Waiting for update signal from WDS...');
} else {
throw new Error('[HMR] Hot Module Replacement is disabled.');
}

可以看到代码中在无法找到热更新代码或者热更新失败就会调用 window.location.reload();。

骚操作

为了解决 webpack 自动 relaod 导致页面空白的问题。

首先我们就得不让 webpack 自动 relaod,这好办,直接把 webpack/hot/dev-server.js copy 一份,删掉 window.location.reload(); 就行了。需要注意的时注意要同时修改里面 require 的相对路径为 webpack-dev-server 包下的路径。

1
2
3
4
5
6
7
javascript复制代码// scripts/webpack.config.js
const webpackHotDevServer = resolvePath(__dirname, './webpack-hot-dev-server.js');
const devEntries = [
// 替换成改过的文件
webpackHotDevServer,
`webpack-dev-server/client/index.js?${devServerClientQuery}`,
];

但是没有 relaod 也不行啊!既然自己没法 reload,我们可以让 VSCode 去 reload。

具体来说,我们可以修改 webpack/hot/dev-server.js,将中 reload 操作改成向我们的 VSCode 扩展通信,让它去 relaod Webview。

修改 webpack/hot/dev-server.js,加入下面的代码,这个 window.__reload__ 才是真正可用的 reload。

1
2
3
4
5
6
7
8
9
10
javascript复制代码if (!window.__vscode__) {
window.__vscode__ = acquireVsCodeApi();
window.__reload__ = function () {
console.log('post message to vscode to reload!');
window.__vscode__.postMessage({
command: 'reload',
text: 'from web view',
});
};
}

再将其中 relaod 代码都替换成我们自己实现的 window.__reload__ 完美集成 webpack 热更新啦!

哦,对了,还要在扩展中要处理 reload 事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
javascript复制代码// src/MyWebView.ts
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
this.panel = panel;
this.extensionUri = extensionUri;

this.setupHtmlForWebview();

this.panel.onDidDispose(() => this.dispose(), null, this.disposables);

// Handle messages from the webview
this.panel.webview.onDidReceiveMessage(
(message) => {
switch (message.command) {
// 处理 relaod 实现
case 'reload':
// 需要修改 html 内容才会 relaod,所以每次都替换了 script 的 nonce 为一个随机字符串
this.html = this.html.replace(/nonce="\w+?"/, `nonce="${getNonce()}"`);
this.panel.webview.html = this.html;
return;
}
},
null,
this.disposables,
);
}

最终效果

完美实现.gif

总结

自己在折腾 VSCode Webview 和 Webpack 热更新的时候 debug 了很多代码,也翻了很多 webpack 和 webpack-dev-servr 的源码看。能够明显感觉到和刚入行前端时的不一样,那时候 debug 都用不利索,源码更是无从下手。其实阅读源码是一门技术活,我也是在看了很多开源项目源代码才变成现在碰到问题就看源码,debug 分析。刚入行那个时候,源码一看就头痛,看着不是自己写的代码就懵逼不知道咋下手。

这是时隔 2 年第一篇公开的博客,以后会陆续分享我在工作中和开源项目中的经验和思考。目前比较想分享的主题还有 ts 类型体操以及 VSCode 相关的一些东西。我感觉一周能写一篇就非常不容易了,有时间还要学习和写开源项目,最近为了写一个 VSCode 扩展又把 rust 的学习耽搁了。

写博客一方面是让自己在写博客中对遇到的问题能有时间更全面更清晰的思考。其实工作两年有非常多的东西都可以分享,但都没有去记录。之前工作了半年的 flutter,现在回想起来写一个 hello world 脑海里都没有清晰的代码,倒不是说写不了,只不过我要是现在去写一个 flutter 项目可能会重蹈以前犯过的很多错误。

最近越发想写博客的另一个原因是感觉自己通过别人写的博客确实学到了很多东西,以至于我都给他的博客打赏了 66 块钱。而且自己之前的一些博客还是能时不时收到一些感谢。也有可能是单身久了,想通过写博客在网络上提升下存在感,通过交流排解下空虚感。

全文完。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Kotlin 编程

发表于 2022-03-19

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

在 Android 生态中主要有 C++、Java、Kotlin 三种语言 ,它们的关系不是替换而是互补。其中,C++ 的语境是算法和高性能,Java 的语境是平台无关和内存管理,而 Kotlin 则融合了多种语言中的优秀特性,带来了一种更现代化的编程方式。

本文是 Kotlin 编程与跨平台系列的第 1 篇文章,完整文章目录请移步到文章末尾~

前言

  • 在 Android 面试中很重视基础知识的考察,其中语言基础主要包括 Java、Kotlin、C/C++ 三种编程语言。在小彭面试的经验中,发现很多同学的 Kotlin 语言能力只是停留在一些非常入门的语法使用上;
  • 在这篇文章里,我将为你浓缩总结 Kotlin 中最常用的知识点和原理。希望通过这篇文章能够帮助你扫除支持盲区,对于一些语法背后的原理也有所涉猎。

  1. 为什么要使用 Kotlin?

面试官问这个问题一方面可能是先想引入 Kotlin 这个话题,另一方面是想考察你的认知能力,是不是真的有思考过 Kotlin 的优势 / 价值,还是随波逐流别人用我也跟着用。你可以这么回答:

在 Android 生态中主要有 C++、Java、Kotlin 三种语言 ,它们的关系不是替换而是互补。其中,C++ 的语境是算法和高性能,Java 的语境是平台无关和内存管理,而 Kotlin 则融合了多种语言中的优秀特性,带来了一种更现代化的编程方式。 例如简化异步编程的协程(coroutines),提高代码质量的可空性(nullability),lambda 表达式等。

  1. 语法糖的味道

  • == 和 equal() 相同,=== 比较内存地址
  • 顶级成员(函数 & 属性)的原理: Kotlin 顶级成员的本质是 Java 静态成员,编译后会自动生成文件名Kt的类,可以使用@Jvm:fileName注解修改自动生成的类名。
  • 默认参数的原理: Kotlin 默认参数的本质是将默认值 固化 到调用位置,所以在 Java 中无法直接调用带默认参数的函数,需要在 Kotlin 函数上增加@JvmOverloads注解,指示编译器生成重载方法(@JvmOverloads会为默认参数提供重载方法)。
  • 解构声明的原理: Kotlin 解构声明可以把一个对象的属性分解为一组变量,所以解构声明的本质是局部变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码举例:
val (name, price) = Book("Kotlin入门", 66.6f)
println(name)
println(price)
-------------------------------------------
Kotlin 类需要声明`operator fun componentN()`方法来实现解构功能,否则是不具备解构声明的功能的,例如:
class Book(var name: String, var price: Float) {
operator fun component1(): String { // 解构的第一个变量
return name
}

operator fun component2(): Float { // 解构的第二个变量
return price
}
}
  • Sequences 序列的原理: Sequences 提升性能的关键在于多个操作共享同一个 Iterator 迭代器,只需要一次循环就可以完成数据操作。Sequences 又是懒惰的,需要遇到终端操作才会开始工作。
  • 扩展函数的原理: 扩展函数的语义是在不修改类 / 不继承类的情况下,向一个类添加新函数或者新属性。本质是静态函数,静态函数的第一个参数是接收者类型,调用扩展时不会创建适配对象或者任何运行时的额外消耗。在 Java 中,我们只需要像调用普通静态方法那样调用扩展即可。相关资料:Kotlin 编程 #3 扩展函数(终于知道为什么 with 用 this,let 用 it)
  • let、apply、with 的区别和应用场景: let、with、apply 都是标准库函数,它们的主要区别在 lambda 参数类型定义不同。apply、with 的 lambda 参数是 T 的扩展函数,因此在 lambda 内使用 this 引用接收者对象,而 let 的 lambda 参数是参数为 T 的高阶函数,因此 lambda 内使用 it 引用唯一参数。
  • 委托机制的原理: Kotlin 委托的语法关键字是 by,其本质上是面向编译器的语法糖,三种委托(类委托、对象委托和局部变量委托)在编译时都会转化为 “无糖语法”。例如类委托:编译器会实现基础接口的所有方法,并直接委托给基础对象来处理。例如对象委托和局部变量委托:在编译时会生成辅助属性(prop$degelate),而属性 / 变量的 getter() 和 setter() 方法只是简单地委托给辅助属性的 getValue() 和 setValue() 处理。相关资料:Kotlin 编程 #2 委托机制 & 原理 & 应用
  • 中缀函数: 声明 infix 关键字的函数是中缀函数,调用中缀函数时可以省略圆点以及圆括号等程序符号,让语句更自然。
1
2
3
4
5
6
7
8
9
10
kotlin复制代码中缀函数的要求:
- 1、成员函数或扩展函数
- 2、函数只有一个参数
- 3、不能使用可变参数或默认参数

举例:
infix fun String.吃(fruit: String): String {
return "${this}吃${fruit}"
}
调用: "小明" 吃 "苹果"

  1. 类型系统

  • 数值类型: Kotlin 将基本数据类型和引用型统一为:Byte、Short、Int、Long、Float、Double、Char 和 Boolean。需要注意的是,类型的统一并不意味着 Kotlin 所有的数值类型都是引用类型,大多数情况下,它们在编译后会变成基本数据类型,类型参数会被编译为引用类型。
  • 隐式转换: Kotlin 不存在隐式类型转换,即时是低级类型也需要显式转换为高级类型:
1
2
3
4
5
6
kotlin复制代码//隐式转换,编译器会报错
val anInt: Int = 5
val ccLong: Long = anInt

//需要去显式的转换,下面这个才是正确的
val ddLong: Long = anInt.toLong()
  • 平台类型: 当可空性注解不存在时,Java 类型会被转换为 Kotlin 的平台类型。平台类型本质上是 Kotlin 编译器无法确定其可空信息,既可以把它当作可空类型,也可以把它当作非空类型。

如果所有来自 Java 的值都被看成非空是不合理的,反之把 Java 值都当作可空的,由会引出大量 Null 检查。综合考量,平台类型是 Kotlin 为开发者选择的折中的设计方案。

  • 类型转换: 较小类型并不是较大类型的子类型,较小的类型不能隐式转换为较大的类型。
1
2
3
kotlin复制代码val b: Byte = 1 // OK
val i: Int = b // 编译错误
val i: Int = b.toInt() // OK
  • 只读集合和可变集合: 只读集合只可读,而可变集合可以增删该差(例如 List 只读,MutableList 可变)。需要注意,只读集合引用指向的集合不一定是不可变的,因为你使用的变量可能是众多指向同一个集合的其中一个。
  • Array 和 IntArray 的区别: Array 相当于引用类型数组 Integer[],IntArray 相当于数值类型数组 int[]。
  • Unit: Any 的子类,作为函数返回值时表示没有返回值,可以省略,与 Java void 类似。
  • Nothing: 表示表达式或者函数永远不会返回,Nothing? 唯一允许的值是 null。
  • Java Void: void 的包装类,与 void 类似表示一个函数没有有效的返回值,返回值只能是 null。

  1. 面向对象

  • 类修饰符: Kotlin 类 / 方法默认是 final 的,如果想让继承类 / 重写方法,需要在基类 / 基方法添加 open 修饰符。
1
2
3
kotlin复制代码final:不允许继承或重写
open:允许继承或重写
abstract:抽象类 / 抽象方法
  • 访问修饰符: Java 默认的访问修饰符是 protected,Kotlin 默认的访问修饰符是 public。
1
2
3
4
csharp复制代码public:所有地方可见
internal:模块中可见,一个模块就是一组编译的 Kotlin 文件
protected:子类中可见(与 Java 不同,相同包不可见,Kotlin 没有 default 包可见)
private:类中可见
  • 构造函数:
+ **默认构造函数:** class 默认有一个无参主构造函数,如果显式声明了构造函数,则默认的无参主构造函数失效;
+ **主构造函数:** 声明在 class 关键字后,其中 constructor 关键词可以省略;
+ **次级构造函数:** 如果声明了次级构造函数,则默认的无参主构造函数会失效。如果存在主构造函数,次级构造函数需要直接或间接委托给主构造函数。
  • init 函数执行顺序: 主构造函数 > init > 次级构造函数
  • 内部类: Kotlin 默认为静态内部类,如果想访问类中的成员方法和属性,需要添加 inner 关键字称为非静态内部类;Java 默认为非静态内部类。
  • data 关键字原理: data 关键字用于定义数据类型,编译器会自动从主构造函数中提取属性并生成一系列函数:equals()/hashCode()、toString()、componentN()、copy()。
  • sealed 关键字原理: 密封类用来表示受限的类继承结构,密封类可以有子类,但是所有子类都必须内嵌在该密封类中。
  • object 与 companion object 的区别 object 有两层语义:静态匿名内部类 + 单例对象 companion object 是伴生对象,一个类只能有一个,代表了类的静态成员(函数 / 属性)
  • 单例: Kotlin 可以使用 Java 相似的方法实现单例,也可以采用 Kotlin 特有的语法。相关资料:Kotlin下的5种单例模式
+ **object**
1
2
csharp复制代码// Kotlin实现
object SingletonDemo
+ **by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED)**
1
2
3
4
5
6
7
kotlin复制代码class SingletonDemo private constructor() {
companion object {
val instance: SingletonDemo by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
SingletonDemo()
}
}
}
  • 泛型: 关于泛型能问的都在这里了(含Kotlin)

  1. lambda 表达式

  • lambda 表达式本质上是「可以作为值传递的代码块」。在老版本 Java 中,传递代码块需要使用匿名内部类实现,而使用 lambda 表达式甚至连函数声明都不需要,可以直接传递代码块作为函数值。
  • it: 当 lambda 表达式只有一个参数,可以用 it 关键字来引用唯一的实参。
  • lambda 表达式的种类
+ 1、普通 Lambda 表达式:例如 ()->R
+ 2、带接收者对象的 Lambda 表达式:例如 T.()->R
  • lambda 表达式访问局部变量的原理: 在 Java 中,匿名内部类访问的局部变量必须是 final 修饰的,否则需要使用数组或对象做一层包装。在 Kotlin 中,lambda 表达式可以直接访问非 final 的局部变量,其原理是提供了一层包装类,修改局部变量本质上是修改包装类中的属性。
1
kotlin复制代码class Ref<T>(var value:T)
  • lambda 表达式编译优化: 在循环中使用 Java 8 与 Kotlin 中的 lambda 表达式时,会存在编译时优化,编译器会将 lambda 优化为一个 static 变量,除非 lambda 表达式中访问了外部的变量或函数。
  • inline 内联函数的原理:
+ **内联 lambda 表达式参数(主要优点):** 内联函数的参数如果是 lambda 表达式,则该参数默认也是 inline 的。lambda 表达式也会被固化的函数调用位置,从而减少了为 lambda 表达式创建匿名内部类对象的开销。当 lambda 表达式被经常调用时,可以减少内存开销。
+ **减少入栈出栈过程(次要优点):** 内联函数的函数体被固化到函数调用位置,执行过程中减少了栈帧创建、入栈和出栈过程。需要注意:如果函数体太大就不适合使用内联函数了,因为会大幅度增加字节码大小。
+ **@PublishApi 注解:** 编译器要求内联函数必须是 public 类型,使用 @PublishApi 注解可以实现 internal 等访问修饰的同时又实现内联
+ **noinline 非内联:** 如果在内联函数内部,lambda 表达式参数被其它非内联函数调用,会报编译时错误。这是因为 lambda 表达式已经被拉平而无法传递给其他非内联函数。可以给参数加上 noinline 关键字表示禁止内联。



1
2
3
kotlin复制代码inline fun test(noinline inlined: () -> Unit) {
otherNoinlineMethod(inlined)
}
+ **非局部返回(Non-local returns):** 一个不带标签的 return 语句只能用在 fun 声明的函数中使用,因此在 lambda 表达式中的 return 必须带标签,指明需要 return 的是哪一级的函数:
1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码fun song(f: (String) -> Unit) {
// do something
}

fun behavior() {
song {
println("song $it")
return //报错: 'return' is not allowed here
return@song // 局部返回
return@behavior // 非局部返回
}
}
唯一的例外是在内联函数中的 lambda 表达式参数,可以直接使用不带标签的 return,返回的是调用内联函数的外部函数,而不是内联函数本身,默认就是非局部返回。
1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码inline fun song(f: (String) -> Unit) {
// do something
}

fun behavior() {
song {
println("song $it")
return // 非局部返回
return@song // 局部返回
return@behavior // 非局部返回
}
}
+ **crossinline 非局部返回:** 禁止内联函数的 lambda 表达式参数使用非局部返回 + **实化类型参数 reified:** 因为泛型擦除的影响,运行期间不清楚类型实参的类型,Kotlin 中使用 **带实化类型参数的内联函数** 可以突破这种限制。实化类型参数在插入到调用位置时会使用类型实参的确切类型代替,因此可以确定实参类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
scss复制代码在这个函数里,我们传入一个List,企图从中过滤出 T 类型的元素:

Java:
<T> List<T> filter(List list) {
List<T> result = new ArrayList<>();
for (Object e : list) {
if (e instanceof T) { // compiler error
result.add(e);
}
}
return result;
}
---------------------------------------------------
Kotlin:
fun <T> filter(list: List<*>): List<T> {
val result = ArrayList<T>()
for (e in list) {
if (e is T) { // cannot check for instance of erased type: T
result.add(e)
}
}
return result
}

调用:
val list = listOf("", 1, false)
val strList = filter<String>(list)
---------------------------------------------------
内联后:
val result = ArrayList<String>()
for (e in list) {
if (e is String) {
result.add(e)
}
}

  1. DSL 领域特定语言

DSL 是专门用于解决某个问题的语言,虽然没有通用语言那么全面,但在解决特定问题时更加高效。案例:Compose 的 UI 代码也是采用了 DSL,使得 Compose 拥有了不输于 XML 的编码效率。实现 DSL 需要可以利用的 Kotlin 语法特性,相关资料:Kotlin DSL 实战:像 Compose 一样写代码

  • 高阶函数: 使得 lambda 参数脱离圆括号,减少一个参数;
  • 扩展函数: 传递 Receiver,减少一个参数;
  • Context Receivers: 传递多个 Receiver,在扩展函数的基础上减少多个参数;
  • 中缀函数: 让语法更简洁自然;
  • @DSLMarker: 用于限制 lambda 中不带标签的 this 只能访问到最近的 Receiver 类型,当调用更外层的 Receiver 时必须显式指定 this@XXX。
1
2
3
4
5
6
7
kotlin复制代码context(View)
val Float.dp
get() = this * this@View.resources.displayMetrics.density

class SomeView : View {
val someDimension = 4f.dp
}

  1. 总结

少部分比较聪明的小伙伴就会问了,你这怎么没有涉及协程、Flow 这些知识点?那是因为这些知识点比较多,小彭决定单独放在一篇文章里。一篇文章拆成两篇用,它不香吗?


2022/4/12 更新

上次留了两个坑,Flow 看这里,Android Jetpack 开发套件 #4 有小伙伴说看不懂 LiveData、Flow、Channel,跟我走。那么,协程呢?再等等吧您。

推荐阅读

Kotlin 编程与跨平台系列完整目录如下(2023/07/11 更新):

  • #1 金三银四必备,全面总结 Kotlin 面试知识点
  • #2 委托机制 & 原理 & 应用
  • #3 扩展函数(终于知道为什么 with 用 this,let 用 it)

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android Jetpack 开发套件

发表于 2022-03-07

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

Android Jetpack 开发套件是 Google 推出的 Android 应用开发编程范式,为开发者提供了解决应用开发场景中通用的模式化问题的最佳实践,让开发者可将时间精力集中于真正重要的业务编码工作上。

这篇文章是 Android Jetpack 系列文章的第 5 篇文章,完整目录可以移步至文章末尾~

前言

为了优化代码设计,业界先后提出了 MVC、MVP、MVVM 和 MVI 等架构设计。这四个模式讨论是 “如何管理 UI” 这个话题,采用的手段都是 “关注点分离”,只是实现的细节不同。最开始是没有采用任何模式的状态,不管是视图代码还是表现逻辑全都写在 Activity 里面,很明显这样的代码耦合度非常高,难以进行维护和测试,可读性也不好。

提示:耦合度高是现象,关注点分离是手段,易维护性和易测试性是结果,模式是可复用的经验。


  1. MVC

MVC 其实是 Android 默认的设计,MVC 里将代码分为三个部分:

  • View: Layout XML 文件;
  • Model: 负责管理业务数据逻辑,如网络请求、数据库处理;
  • Controller: Activity 负责处理表现逻辑。

MVC 初步解决了 Activity 代码太多的问题,但也有缺点:我们的初衷 Activity / Fragment 是只处理表现逻辑的部分 ,但现实是 Activity 天然不可避免要处理 UI,也要处理用户交互,说明 Activity 本身天然承担了 View 的角色。那么这个架构就会造成 Activity 里糅合了视图和业务的代码,分离程度不够。


  1. MVP

为了将 Activity 中的表现逻辑彻底分离出来,业界提出了 MVP 的设计。MVP 同样将代码划分为三个部分:

  • View: Activity 和 Layout XML 文件;
  • Model: 负责管理业务数据逻辑,如网络请求、数据库处理;
  • Presenter: 负责处理表现逻辑。

在实现细节上,View 和 Presenter 中间会定义一个协议接口 Contract,这个接口会约定 View 如何向 Presenter 发指令和 Presenter 如何 Callback 给 View。这样的架构里 Activity 不再有表现逻辑的部分,Activity 作为 View 的角色只处理和 UI 有关的事情。但还是存在一些缺点:

  • 双向依赖: View 和 Presenter 是双向依赖的,一旦 View 层做出改变,相应地 Presenter 也需要做出调整。在业务语境下,View 层变化是大概率事件;
  • 内存泄漏风险: Presenter 持有 View 层的引用,当用户关闭了 View 层,但 Model 层仍然在进行耗时操作,就会有内存泄漏风险。虽然有解决办法,但还是存在风险点和复杂度(弱引用 / onDestroy() 回收 Presenter)。
  • 协议接口类膨胀: View 层和 Presenter 层的交互需要定义接口方法,当交互非常复杂时,需要定义很多接口方法和回调方法,也不好维护。


  1. MVVM

MVVM 模式改动在于中间的 Presenter 改为 ViewModel,MVVM 同样将代码划分为三个部分:

  • View: Activity 和 Layout XML 文件,与 MVP 中 View 的概念相同;
  • Model: 负责管理业务数据逻辑,如网络请求、数据库处理,与 MVP 中 Model 的概念相同;
  • ViewModel: 存储视图状态,负责处理表现逻辑,并将数据设置给可观察数据容器。

在实现细节上,View 和 Presenter 从双向依赖变成 View 可以向 ViewModel 发指令,但 ViewModel 不会直接向 View 回调,而是让 View 通过观察者的模式去监听数据的变化,有效规避了 MVP 双向依赖的缺点。但 MVVM 本身也存在一些缺点:

  • 多数据流: View 与 ViewModel 的交互分散,缺少唯一修改源,不易于追踪;
  • LiveData 膨胀: 复杂的页面需要定义多个 MutableLiveData,并且都需要暴露为不可变的 LiveData。

DataBinding、ViewModel 和 LiveData 等组件是 Google 为了帮助我们实现 MVVM 模式提供的架构组件,它们并不是 MVVM 的本质,只是实现上的工具。

  • Lifecycle: 生命周期状态回调;
  • LiveData: 可观察的数据存储类;
  • databinding: 可以自动同步 UI 和 data,不用再 findviewById();
  • ViewModel: 存储界面相关的数据,这些数据不会在手机旋转等配置改变时丢失。

  1. MVI

MVI 模式的改动在于将 View 和 ViewModel 之间的多数据流改为基于 ViewState 的单数据流。MVI 将代码分为以下四个部分:

  • View: Activity 和 Layout XML 文件,与 MVVM 中 View 的概念相同;
  • Intent: 定义数据操作,是将数据传到 Model 的唯一来源,相比 MVVM 是新的概念;
  • ViewModel: 存储视图状态,负责处理表现逻辑,并将 ViewState 设置给可观察数据容器;
  • ViewState: 一个数据类,包含页面状态和对应的数据。

在实现细节上,View 和 ViewModel 之间的多个交互(多 LiveData 数据流)变成了单数据流。无论 View 有多少个视图状态,只需要订阅一个 ViewState 便可以获取所有状态,再根据 ViewState 去响应。当然,实践中应该根据状态之间的关联程度来决定数据流的个数,不应该为了使用 MVI 模式而强行将多个无关的状态压缩在同一个数据流中。

  • 唯一可信源: 数据只有一个来源(ViewModel),与 MVVM 的思想相同;
  • 单数据流: View 和 ViewModel 之间只有一个数据流,只有一个地方可以修改数据,确保数据是安全稳定的。并且 View 只需要订阅一个 ViewState 就可以获取所有状态和数据,相比 MVVM 是新的特性;
  • 响应式: ViewState 包含页面当前的状态和数据,View 通过订阅 ViewState 就可以完成页面刷新,相比于 MVVM 是新的特性。

但 MVI 本身也存在一些缺点:

  • State 膨胀: 所有视图变化都转换为 ViewState,还需要管理不同状态下对应的数据。实践中应该根据状态之间的关联程度来决定使用单流还是多流;
  • 内存开销: ViewState 是不可变类,状态变更时需要创建新的对象,存在一定内存开销;
  • 局部刷新: View 根据 ViewState 响应,不易实现局部 Diff 刷新,可以使用 Flow#distinctUntilChanged() 来刷新来减少不必要的刷新。

不过,MVI 并不是一个全新的设计模式,其背后设计理念与 Redux 模式如出一辙。在 Redux 里完全可以找到与 MVI 相同的各个要素,而且明显 Redux 的命名方式更加清晰无歧义,小伙伴们知道 Model - View - Intent 这个命名方式的原始出处的话,可以告诉我一声。

  • View - View
  • Action - Intent
  • Store - ViewModel
  • State - ViewState
  • Reducer - Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
kotlin复制代码// 1、ViewModel
class MainViewModel: ViewModel() {

private val mModel = MainModel()

val mIntent = Channel<MainIntent>(Channel.UNLIMITED)

private val _state = MutableStateFlow<MainViewState>(MainViewState.Idle)
val state: StateFlow<MainViewState>
get() = _state

init {
viewModelScope.launch {
mIntent.consumeAsFlow().collect {
when (it) {
is MainIntent.FetchNew -> fetchNews()
}
}
}
}

private fun fetchNews() {
viewModelScope.launch {
_state.value = MainViewState.Loading
_state.value = try {
MainViewState.News(mModel.fetchNews())
} catch (e: Exception) {
MainViewState.Error(e.localizedMessage)
}
}
}
}

// 2、ViewState
sealed class MainViewState {
object Idle : MainViewState()
object Loading : MainViewState()
data class News(val news: List<New>) : MainViewState()
data class Error(val error: String?) : MainViewState()

}
// 3、Intent
sealed class MainIntent {
object FetchNew : MainIntent()
}
// 4、View
class MainActivity : AppCompatActivity() {

private lateinit var mainViewModel: MainViewModel

private fun observeViewModel() {
lifecycleScope.launch {
mainViewModel.state.collect {
when (it) {
is MainViewState.Idle -> {

}
is MainViewState.Loading -> {
}

is MainViewState.News -> {
renderList(it.news)
}
is MainViewState.Error -> {
}
}
}
}
}

private fun renderList(news: List<New>) {
// do something
}
}

  1. MVP、MVVM 和 MVI 的对比

MVVM 和 MVP 的思想是相同的,最本质的概念就是 Activity 里做的事情太多了,所以要把 Activity 中与 UI 无关的部分抽离出来,交给别人做。这个 “别人” 在 MVP 里叫作 Presenter,在 MVVM 里叫作 ViewModel。而不论是 MVP 中的约定接口,还是 ViewModel 里的观察者模式,这些都是实现上的细节而已。

MVI 与前者的主要区别不在于强调严格的单向数据流,而在于从命令式的开发模式,转变为响应式的开发模式。我们并不是说越新潮,越复杂的架构就是最好的,只有合适的架构才是最好的。但是不可否认,从 React 到 Flutter,从 MVI 到 Compose,响应式编程似乎有一统天下的趋势。未来会怎么样,我们拭目以待。

参考资料

  • iPlayground 2019 | 漫談 iOS 架構:MVC / MVVM / VIPER 與 Redux —— Nelson 著
  • 关于MVC/MVP/MVVM的一些错误认识 —— wfwf 著
  • MVVM 进阶版:MVI 架构了解一下~ - 掘金 —— 程序员江同学 著
  • MVI 架构更佳实践:支持 LiveData 属性监听 - 掘金 —— 程序员江同学 著

推荐阅读

Android Jetpack 系列文章目录如下(2023/07/08 更新):

  • #1 Lifecycle:生命周期感知型组件的基础
  • #2 为什么 LiveData 会重放数据,怎么解决?
  • #3 为什么 Activity 都重建了 ViewModel 还存在?
  • #4 有小伙伴说看不懂 LiveData、Flow、Channel,跟我走
  • #5 Android UI 架构演进:从 MVC 到 MVP、MVVM、MVI
  • #6 ViewBinding 与 Kotlin 委托双剑合璧
  • #7 AndroidX Fragment 核心原理分析
  • #8 OnBackPressedDispatcher:Jetpack 处理回退事件的新姿势
  • #9 食之无味!App Startup 可能比你想象中要简单
  • #10 从 Dagger2 到 Hilt 玩转依赖注入(一)

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

拜托,使用Threejs让二维图片具有3D效果超酷的好吗

发表于 2022-02-22

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战」。

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

背景

逛 sketchfab 网站的时候我看到有很多二维平面转 3D 的模型例子,于是仿照他们的例子,使用 Three.js + React 技术栈,将二维漫画图片转化为三维视觉效果。本文包含的内容主要包括:THREE.Group 层级模型、MeshPhongMaterial 高光网格材质、正弦余弦函数 创建模型移动轨迹等。

效果

实现效果如 👇 下图所示:页面主要有背景图、漫画图片主体以及 💥 Boom 爆炸背景图片构成,按住鼠标左键移动模型可以获得不同视图,让图片在视觉上有 3D 景深效果。

已适配:

  • 💻 PC端
  • 📱 移动端

👀 在线预览:dragonir.github.io/3d/#/comic

实现

本文实现比较简单,和我前面几篇文章实现基本上是相同的,流程也比较简单,主要是素材准备流程比较复杂。下面看看具体实现方法。

素材制作

准备一张自己喜欢的图片作为素材原图,图片内容最好可以分成多个层级,以实现 3D 景深效果,本实例中使用的是一张漫画图片,刚好可以切分成多个层级。

在 Photoshop 中打开图片,根据自己需要的分层数量,创建若干图层,并将地图复制到每个图层上,然后根据对图层景深层级的划分,编辑每个图层,结合使用魔棒工具和套索工具删除多余的部分,然后将每个图层单独导出作为素材。我分为 👆 如上 7 个图层,外加一个边框,一共有 8 个图层。

资源引入

其中 OrbitControls 用于镜头轨道控制、TWEEN 用于镜头补间动画。

1
2
3
4
js复制代码import React from 'react';
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";

场景初始化

初始化渲染容器、场景、摄像机、光源。摄像机初始位置设置为位于偏左方的 (-12, 0, 0),以便于后面使用 TWEEN 实现翻转动画效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
js复制代码// 场景
container = document.getElementById('container');
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
scene = new THREE.Scene();
// 添加背景图片
scene.background = new THREE.TextureLoader().load(background);
// 相机
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(-12, 0, 0);
camera.lookAt(new THREE.Vector3(0, 0, 0));
// 直射光
light = new THREE.DirectionalLight(0xffffff, 1);
light.intensity = .2;
light.position.set(10, 10, 30);
light.castShadow = true;
light.shadow.mapSize.width = 512 * 12;
light.shadow.mapSize.height = 512 * 12;
light.shadow.camera.top = 100;
light.shadow.camera.bottom = - 50;
light.shadow.camera.left = - 50;
light.shadow.camera.right = 100;
scene.add(light);
// 环境光
ambientLight = new THREE.AmbientLight(0xdddddd);
scene.add(ambientLight);

创建漫画主体

首先创建一个 Group,用于添加图层网格,然后遍历图层背景图片数组,在循环体中创建每个面的网格,该网格使用平面立方体 PlaneGeometry,材质使用物理材质 MeshPhysicalMaterial,对每个网格位置设置相同的x轴和y轴值和不同的z轴值以创建景深效果。最后将 Group 添加到场景 Scene 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
js复制代码var layerGroup = new THREE.Group();
let aspect = 18;
for (let i=0; i<layers.length; i++) {
let mesh = new THREE.Mesh(new THREE.PlaneGeometry(10.41, 16), new THREE.MeshPhysicalMaterial({
map: new THREE.TextureLoader().load(layers[i]),
transparent: true,
side: THREE.DoubleSide
}));
mesh.position.set(0, 0, i);
mesh.scale.set(1 - (i / aspect), 1 - (i / aspect), 1 - (i / aspect));
layerGroup.add(mesh);
// 文字
if (i === 5) {
mesh.material.metalness = .6;
mesh.material.emissive = new THREE.Color(0x55cfff);
mesh.material.emissiveIntensity = 1.6;
mesh.material.opacity = .9;
}
// 会话框
if (i === 6) {
mesh.scale.set(1.5, 1.5, 1.5);
animateLayer = mesh;
}
}
layerGroup.scale.set(1.2, 1.2, 1.2);

到这一步,实现效果如下图所示:

💡 THREE.Group 层级模型

将具有相同主体的网格可以通过 Group 合并在一起,以便于提高运行效率。Three.js 层级模型 Group 的基类是 Object3D,它是 Three.js 中大部分对象的基类,提供了一系列的属性和方法来对三维空间中的物体进行操纵。如可以通过 .add(object) 方法来将对象进行组合,该方法将对象添加为子对象。

但最好使用 Group 来作为父对象,因为 Group 相比较Object3D 更语义化,可以使用 Group 作为点、线、网格等模型的父对象,用来构建一个层级模型。

创建Boom背景

为了加强视觉效果,我添加了一个💥 Boom 爆炸图形平面作为背景,用鼠标移动的时候随着光线的变化,可以看到该图案有金属渐变效果,这种效果主要是通过高光材质 MeshPhongMaterial 的 specular 和 shininess 属性实现的。

1
2
3
4
5
6
7
8
9
10
11
js复制代码const boom = new THREE.Mesh(new THREE.PlaneGeometry(36.76, 27.05), new THREE.MeshPhongMaterial({
map: new THREE.TextureLoader().load(boomImage),
transparent: true,
shininess: 160,
specular: new THREE.Color(0xff6d00),
opacity: .7
}));
boom.scale.set(.8, .8, .8);
boom.position.set(0, 0, -3);
layerGroup.add(boom)
scene.add(layerGroup);

添加后效果:

💡 MeshPhongMaterial 高光网格材质

MeshPhongMaterial 是一种用于具有镜面高光的光泽表面的材质。该材质使用非物理的 Blinn-Phong 模型来计算反射率。 与 MeshLambertMaterial 中使用的 Lambertian 模型不同,该材质可以模拟具有镜面高光的光泽表面,如涂漆木材。

构造函数:

1
js复制代码MeshPhongMaterial(parameters: Object)

parameters:可选,用于定义材质外观的对象,具有一个或多个属性。 材质的任何属性都可以从此处传入(包括从Material继承的任何属性)。

特殊属性:

  • .emissive[Color]:材质的放射光颜色,基本上是不受其他光照影响的固有颜色。默认为 黑色。
  • .emissiveMap[Texture]:设置发光贴图。默认值为 null。放射贴图颜色由放射颜色和强度所调节。
  • .emissiveIntensity[Float]:放射光强度。调节发光颜色。默认为 1。
  • .envMap[TextureCube]:环境贴图。默认值为 null。
  • .isMeshPhongMaterial[Boolean]:用于检查此类或派生类是否为 Phong 网格材质。默认值为 true。
  • .lightMap[Texture]:光照贴图。默认值为 null。
  • .lightMapIntensity[Float]:烘焙光的强度。默认值为 1。
  • .reflectivity[Float]:环境贴图对表面的影响程度。默认值为 1,有效范围介于 0(无反射) 和 1(完全反射) 之间。
  • .refractionRatio[Float]:空气的折射率除以材质的折射率。折射率不应超过 1。默认值为 0.98。
  • .shininess[Float]:.specular 高亮的程度,越高的值越闪亮。默认值为 30。
  • .skinning[Boolean]:材质是否使用蒙皮。默认值为 false。
  • .specular[Color]:材质的高光颜色。默认值为 0x111111 的颜色 Color。这定义了材质的光泽度和光泽的颜色。
  • .specularMap[Texture]:镜面反射贴图值会影响镜面高光以及环境贴图对表面的影响程度。默认值为 null。

📌 使用 Phong 着色模型计算着色时,会计算每个像素的阴影,与 MeshLambertMaterial 使用的 Gouraud 模型相比,该模型的结果更准确,但代价是牺牲一些性能。

镜头控制、缩放适配、动画

镜头补间动画,镜头切换到正确位置。

1
js复制代码Animations.animateCamera(camera, controls, { x: 0, y: 0, z: 20 }, { x: 0, y: 0, z: 0 }, 3600, () => { });

镜头控制,本示例中显示了模型平移以及水平垂直旋转的角度,以达到最好的预览效果。

1
2
3
4
5
6
7
8
9
10
js复制代码controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.enablePan = false;
// 垂直旋转角度限制
controls.minPolarAngle = 1.2;
controls.maxPolarAngle = 1.8;
// 水平旋转角度限制
controls.minAzimuthAngle = -.6;
controls.maxAzimuthAngle = .6;

屏幕缩放适配。

1
2
3
4
5
js复制代码window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}, false);

对于会话框图层网格,我给它添加了在一条光滑曲线上左右移动的动画效果,主要是通过修改它在 x 轴和 y 轴上的 position 来实现的 。

1
2
3
4
5
6
7
8
9
10
js复制代码function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
controls && controls.update();
TWEEN && TWEEN.update();
// 会话框摆动动画
step += 0.01;
animateLayer.position.x = 2.4 + Math.cos(step);
animateLayer.position.y = .4 + Math.abs(Math.sin(step));
}

💡 正弦余弦函数创建模型移动轨迹

使用 step 变量并在函数 Math.cos() 和 Math.sin() 的帮助下 ,创建出一条光滑的轨迹。step+= 0.01 定义的是球的弹跳速度。

到此,本示例的完整实现都描述完毕了,大家感兴趣的话,可以动手试着把自己喜欢的图片改造成 3D 视图。拜托,使用 Three.js 这样展示图片超酷的好吗! 😂

🔗 完整代码:github.com/dragonir/3d…

总结

本文知识点主要包含的的新知识:

  • THREE.Group 层级模型
  • MeshPhongMaterial 高光网格材质
  • 正弦余弦函数 创建模型移动轨迹

想了解场景初始化、光照、阴影、基础几何体、网格、材质及其他 Three.js 的相关知识,可阅读我往期文章。转载请注明原文地址和作者。如果觉得文章对你有帮助,不要忘了一键三连哦 👍。

附录

  • [1]. Three.js 实现2022冬奥主题3D趣味页面 🐼
  • [2]. 1000粉!使用Three.js制作一个专属3D奖牌 🥇
  • [3]. Three.js 实现虎年春节3D创意页面
  • [4]. Three.js 实现脸书元宇宙3D动态Logo
  • [5]. Three.js 实现3D全景侦探小游戏
  • [6]. Three.js实现炫酷的酸性风格3D页面
  • [7]. 3dx模型转换为blender支持格式

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Compose学习笔记2 - LaunchedEffect、

发表于 2022-02-22

在 Compose 中使用协程

Kotlin 中协程有多好用,想必不用我多说了。方便的构建、简洁的切换协程语法、await函数与join函数,尤其是在 lifecycle 扩展出现之后,在 Activity 与 Fragment 中可以通过类似 lifecycleScope.launch { } 这样的语法更方便的使用协程。

之前我们介绍过,Compose 是 FP 风格的,UI是通过一个个Composable函数组合在一起形成的,自然不能用lifecycleScope.launch { },那么在 Compose 中我们该如何使用协程呢?

LaunchedEffect

答案就是 LaunchedEffect !,单纯使用的话我们可以把他看过是 Compose 里的 launch{} 函数。用法非常的简单:

1
2
3
4
5
6
7
kotlin复制代码@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
LaunchedEffect(null) {
Log.d(TAG, "这个block执行在协程${Thread.currentThread().name}中")
}
}

如上面所说,如果你只是要一个 launch{} 函数,这样写就可以,但是它还有更高级的用法!
我们先来看一下这个函数的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
kotlin复制代码
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

internal class LaunchedEffectImpl(
parentCoroutineContext: CoroutineContext,
private val task: suspend CoroutineScope.() -> Unit
) : RememberObserver {
private val scope = CoroutineScope(parentCoroutineContext)
private var job: Job? = null

override fun onRemembered() {
job?.cancel("Old job was still running!")
job = scope.launch(block = task)
}

override fun onForgotten() {
job?.cancel()
job = null
}

override fun onAbandoned() {
job?.cancel()
job = null
}
}

如需从可组合项内安全调用挂起函数,请使用 LaunchedEffect 可组合项。当LaunchedEffect 进入组合时,它会启动一个协程,并将代码块作为参数传递。如果 LaunchedEffect 退出组合,协程将取消。如果使用不同的键重组 LaunchedEffect ,系统将取消现有协程,并在新的协程中启动新的挂起函数。

从源码可以看出我们传入的希望在协程中执行的 block,确实也是通过创建一个 CoroutineScope ,最终通过 launch{} 函数执行的。但是通过 remember{} 与 LaunchedEffectImpl 的配合,实现了当 key1 参数的值发生变化时,上一个 job 取消,然后重新执行 block。

需要注意的是,这里使用的 remember 函数,和我们之前在笔记1介绍的 remember 函数并不是同一个,注意区分。

这样做有什么好处?

在 Composable 函数中使用协程,好处与必要性都不言而喻,我们可以更多的书写复杂逻辑。而配合 key1 变化,上一个携程取消,再次执行新的协程,我们可以实现更多的响应式逻辑。
例如下拉刷,我们给 key1 传递一个 MutableState,我们可以在 block 里判断,当State == Refresh 时执行网络请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码@Composable
fun Greeting(name: String) {
var state by remember {
mutableStateOf(1)
}
var resp by remember {
mutableStateOf("hello $name!")
}
LaunchedEffect(state) {
delay(400)
resp = "state:${state}\n这个block执行在协程${Thread.currentThread().name}中"
}
Column {
Text(text = resp)
Button(
onClick = { ++state },
modifier = Modifier
.height(50.dp)
.width(100.dp)
) {
Text(text = "点一点")
}
}
}

状态与状态管理

参考官方文档状态和 Jetpack Compose

状态

remember

上一节我们介绍过 remember{} 函数就是我们用于保存 Composable 的状态的,我们通常要用 State 来保存状态,从而达成响应式。

在可组合项中声明 MutableState 对象的方法有三种:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

第三种写法看起来很奇特,如果没有接触过 Koltin,会有一点懵圈,看一下源码就能理解了。

1
2
3
4
5
6
kotlin复制代码@Stable
interface MutableState<T> : State<T> {
override var value: T
operator fun component1(): T
operator fun component2(): (T) -> Unit
}

注意 component1 与 component2,他就对应着声明时括号里的 value 与 setValue。这种高级特效被被称之为解构声明。

这种用法在 for 循环中其实也有应用,比如:

1
2
3
4
5
kotlin复制代码for ((index, value) in array.withIndex()) {
println("the element at $index is $value")
}

public data class IndexedValue<out T>(public val index: Int, public val value: T)

withIndex() 函数是 kotlin 为 Iterable 实现的一个扩展函数,他的最终返回其实是一个 data class,data class 默认实现了 component。

更多文档请参考:解构声明


虽然 remember 可帮助您在重组后保持状态,但不会帮助您在配置更改后保持状态。为此,您必须使用 rememberSaveable。rememberSaveable 会自动保存可保存在 Bundle 中的任何值。对于其他值,您可以将其传入自定义 Saver 对象。

其他受支持的状态类型

Jetpack Compose 并不要求您使用 MutableState 存储状态。Jetpack Compose 支持其他可观察类型。在 Jetpack Compose 中读取其他可观察类型之前,您必须将其转换为 State,以便 Jetpack Compose 可以在状态发生变化时自动重组界面。
Compose 附带一些可以根据 Android 应用中使用的常见可观察类型创建 State 的函数:

  • LiveData
  • Flow
  • RxJava2

如果您的应用使用自定义可观察类,您可以构建扩展函数,以使 Jetpack Compose 读取其他可观察类型。如需查看具体操作方法的示例,请参阅内置函数的实现。任何允许 Jetpack Compose 订阅每项更改的对象都可以转换为 State 并由可组合项读取。

在上一节我们也演示了 Flow 在 Compose 中的用法,对就是扩展函数 collectAsState()。

状态提升

通过前面的学习我们已经知道了该如何使用状态,以及使用状态更新 Compose UI界面。在每个 Composable 函数中都可以通过 remember{} 来实现 Stateful,有的时候这很好用,但有的场景我们也许不应该这样使用。

例如,当我们实现的 Composable 组件会在多个界面复用,如果它自身持有状态,对于调用者而言,显示是相对复杂切不易使用的,我们往往需要去查看代码才能理解,这种情况下,我们可以通过「状态提升」,来实现 Composable 函数的「无状态」。

Compose 中的状态提升是一种将状态移至 Composable 函数的调用方,以使 Composable 无状态的模式。Jetpack Compose 中的常规状态提升模式是将状态变量替换为两个参数:

  • value: T:要显示的当前值
  • onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
kotlin复制代码@Composable
fun HelloScreen() {
HelloContent()
var name by rememberSaveable { mutableStateOf("") }
//两种不同的状态提升
HelloContent(value = name, onValueChange = { name = it })
HelloContent(rememberSaveable { mutableStateOf("") })
}


@Composable
fun HelloContent() {
Column(modifier = Modifier.padding(16.dp)) {
var name by remember { mutableStateOf("") }
if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
}
}

@Composable
fun HelloContent(value: String, onValueChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
if (value.isNotEmpty()) {
Text(
text = "Hello, $value!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text("Name") }
)
}
}

@Composable
fun HelloContent(state: MutableState<String>) {
var name by state
Column(modifier = Modifier.padding(16.dp)) {
if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
}
}

观察上面的代码可以很容易理解这句话的意思,所以「状态提升」还是很形象的,Composable 的状态,从自己管理与处理,提升给了调用者。这其实是 Compose 的一个重要编程思想,可以看到 Compose 自生也很好的贯彻了这一思想。

例如上面示例中的 OutlinedTextField,作为一个输入框,如果调用者不处理其状态,他甚至连最基本的输入回显都做不到!

状态提升主要注意的事项

以这种方式提升的状态具有一些重要的属性:

  • 单一可信来源:我们会通过移动状态而不是复制状态,来确保只有一个可信来源。这有助于避免 bug。
  • 封装:只有有状态可组合项能够修改其状态。这完全是内部的。
  • 可共享:可与多个可组合项共享提升的状态。如果想在另一个可组合项中执行 name 操作,可以通过变量提升来做到这一点。
  • 可拦截:无状态可组合项的调用方可以在更改状态之前决定忽略或修改事件。
  • 解耦:无状态 ExpandingCard 的状态可以存储在任何位置。例如,现在可以将 name 移入 ViewModel。

通过从 HelloContent 中提升出状态,更容易推断该可组合项、在不同的情况下重复使用它,以及进行测试。HelloContent 与状态的存储方式解耦。解耦意味着,如果您修改或替换 HelloScreen,不必更改 HelloContent 的实现方式。

在这里插入图片描述

状态下降、事件上升的这种模式称为“单向数据流”。在这种情况下,状态会从 HelloScreen 下降为 HelloContent,事件会从 HelloContent 上升为 HelloScreen。通过遵循单向数据流,您可以将在界面中显示状态的可组合项与应用中存储和更改状态的部分解耦。

上面这段话眼熟不?这不就是 MVI 架构的编程思想么!可见 Compose 其实就是 MVI 的。

恢复状态

参考文档:在 Compose 中恢复状态

这个没啥太多好说的,注意一下几个点就行:

rememberSaveable

试试我们上面写的三个不同的 Composable,他们看起来效果完全相同,区别只是一个用的是 remember{} 函数,另一个用的是 rememberSaveable 函数,但当你试着选装屏幕或变更设置后,你会发现用remember{} 函数实现的状态消失了,所以当我们想要在屏幕旋转后还能保持状态,我们需要使用用 rememberSaveable 。他与 savedInstanceState 很相似,数据都是保存在 Bundle 之中的,因此可保存的数据类型也与 Bundle 的要求一致。

Parcelize

参考文档:Parcelize

如何使用 Parcelize :

  1. 添加插件
1
2
3
groovy复制代码plugins {   
id 'kotlin-parcelize'
}
  1. 为类添加注解 @Parcelize
1
2
3
kotlin复制代码import kotlinx.parcelize.Parcelize
@Parcelize
class User(val firstName: String, val lastName: String, val age: Int): Parcelable

如何在 rememberSaveable 中使用:

1
2
3
4
5
6
7
8
9
kotlin复制代码@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
var selectedCity = rememberSaveable {
mutableStateOf(City("Madrid", "Spain"))
}
}

这种方法是最简单、最易用的,绝大多数情况我们都应该优先使用该方法。

MapSaver

如果某种原因导致 @Parcelize 不合适,您可以使用 mapSaver 定义自己的规则,规定如何将对象转换为系统可保存到 Bundle 的一组值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码data class City(val name: String, val country: String)

val CitySaver = run {
val nameKey = "Name"
val countryKey = "Country"
mapSaver(
save = { mapOf(nameKey to it.name, countryKey to it.country) },
restore = { City(it[nameKey] as String, it[countryKey] as String) }
)
}

@Composable
fun CityScreen() {
var selectedCity = rememberSaveable(stateSaver = CitySaver) {
mutableStateOf(City("Madrid", "Spain"))
}
}

ListSaver

为了避免需要为映射定义键,您也可以使用 listSaver 并将其索引用作键:

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
save = { listOf(it.name, it.country) },
restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
var selectedCity = rememberSaveable(stateSaver = CitySaver) {
mutableStateOf(City("Madrid", "Spain"))
}
}

Tips:记不记得上一篇文章中介绍的 viewModel() 函数,如果状态被保存到 VM 中也能实现 rememberSaveable 相似的效果,原因很简单,无需多说。

管理状态

Compose 中大致有这管理状态的方式:

  1. Composable 函数自行通过 remember{} 函数管理自己的状态
  2. 通过「状态提升」将值与事件提升给更上一级的调用者管理
  3. 创建状态容器类,使用 remember{} 函数包装
  4. 使用 ViewModel 托管状态,在需要使用状态的地方通过 viewMode() 函数获取状态。

怎么管理状态是一种选择,没有正确错误之分,只有应用场景是否合适,只要团队统一即可,一般来说我们还是使用 ViewModel 比较符合习惯。

类比 Flutter

如果用 Flutter 做比较,我们可以这样来理解 Compose:

  • 在不使用状态时,每一个 Composable 函数相当于 一个 StatelessWidget。
  • 当使在 Composable 函数中用 remember{} 获取状态后,相当于 一个 StatefulWidget。
  • 有状态的 Composable 函数,相当于 Flutter 中的 State 的 Widget build(BuildContext context) 函数。
  • Compose 的状态发生变化时,Composable 函数会自动重组,相当于 Flutter State 的 rebuild。
  • Composable 函数,没有类似 initState() 这样的仅执行一次函数,因此需要注意一些逻辑代码的执行时机,防止出现死循环。想实现仅执行一次这样的效果,可以通过前面介绍的 LaunchedEffect 函数,为其参数 key1 赋值一个无状态值来达成,例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码@Composable
fun Greeting(name: String) {
var counter by remember { mutableStateOf(0) }
var visible by remember { mutableStateOf(true) }
LaunchedEffect(key1 = Unit, block = {
Log.d(TAG, "我只会在Composable第一次执行时才会执行,后续状态变化不会再次调用")
})
Column {
if (visible) {
Text(text = "Hello $name!${counter}")
}
Button(
onClick = {
counter += 1
visible = !visible
},
modifier = Modifier
.height(50.dp)
.width(100.dp)
) {
Text(text = "点一下")
}
}
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Threejs 实现2022冬奥主题3D趣味页面 🐼

发表于 2022-02-03

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战」。

声明:本文涉及奥运元素3D模型仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

背景

迎冬奥,一起向未来!2022冬奥会马上就要开始了,本文使用 Three.js + React 技术栈,实现冬日和奥运元素,制作了一个充满趣味和纪念意义的冬奥主题 3D 页面。本文涉及到的知识点主要包括:TorusGeometry 圆环面、MeshLambertMaterial 非光泽表面材质、MeshDepthMaterial 深度网格材质、custromMaterial 自定义材质、Points 粒子、PointsMaterial 点材质等。

效果

实现效果如以下 👇 动图所示,页面主要由 2022 冬奥会吉祥物 冰墩墩 、奥运五环、舞动的旗帜 🚩、树木 🌲 以及下雪效果 ❄️ 等组成。按住鼠标左键移动可以改为相机位置,获得不同视图。

👀 在线预览:dragonir.github.io/3d/#/olympi… (部署在 GitHub,加载速度可能会有点慢 😓)

实现

引入资源

首先引入开发页面所需要的库和外部资源,OrbitControls 用于镜头轨道控制、TWEEN 用于补间动画实现、GLTFLoader 用于加载 glb 或 gltf 格式的 3D 模型、以及一些其他模型、贴图等资源。

1
2
3
4
5
6
js复制代码import React from 'react';
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import bingdundunModel from './models/bingdundun.glb';
// ...

页面DOM结构

页面 DOM 结构非常简单,只有渲染 3D 元素的 #container 容器和显示加载进度的 .olympic_loading元素。

1
2
3
4
5
6
7
8
js复制代码<div>
<div id="container"></div>
{this.state.loadingProcess === 100 ? '' : (
<div className="olympic_loading">
<div className="box">{this.state.loadingProcess} %</div>
</div>
)}
</div>

场景初始化

初始化渲染容器、场景、相机。关于这部分内容的详细知识点,可以查阅我往期的文章,本文中不再赘述。

1
2
3
4
5
6
7
8
9
10
11
js复制代码container = document.getElementById('container');
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
scene = new THREE.Scene();
scene.background = new THREE.TextureLoader().load(skyTexture);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 30, 100);
camera.lookAt(new THREE.Vector3(0, 0, 0));

添加光源

本示例中主要添加了两种光源:DirectionalLight 用于产生阴影,调节页面亮度、AmbientLight 用于渲染环境氛围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码// 直射光
const light = new THREE.DirectionalLight(0xffffff, 1);
light.intensity = 1;
light.position.set(16, 16, 8);
light.castShadow = true;
light.shadow.mapSize.width = 512 * 12;
light.shadow.mapSize.height = 512 * 12;
light.shadow.camera.top = 40;
light.shadow.camera.bottom = -40;
light.shadow.camera.left = -40;
light.shadow.camera.right = 40;
scene.add(light);
// 环境光
const ambientLight = new THREE.AmbientLight(0xcfffff);
ambientLight.intensity = 1;
scene.add(ambientLight);

加载进度管理

使用 THREE.LoadingManager 管理页面模型加载进度,在它的回调函数中执行一些与加载进度相关的方法。本例中的页面加载进度就是在 onProgress 中完成的,当页面加载进度为 100% 时,执行 TWEEN 镜头补间动画。

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码const manager = new THREE.LoadingManager();
manager.onStart = (url, loaded, total) => {};
manager.onLoad = () => { console.log('Loading complete!')};
manager.onProgress = (url, loaded, total) => {
if (Math.floor(loaded / total * 100) === 100) {
this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
// 镜头补间动画
Animations.animateCamera(camera, controls, { x: 0, y: -1, z: 20 }, { x: 0, y: 0, z: 0 }, 3600, () => {});
} else {
this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
}
};

创建地面

本示例中凹凸起伏的地面是使用 Blender 构建模型,然后导出 glb 格式加载创建的。当然也可以只使用 Three.js 自带平面网格加凹凸贴图也可以实现类似的效果。使用 Blender 自建模型的优点在于可以自由可视化地调整地面的起伏效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码var loader = new THREE.GLTFLoader(manager);
loader.load(landModel, function (mesh) {
mesh.scene.traverse(function (child) {
if (child.isMesh) {
child.material.metalness = .1;
child.material.roughness = .8;
// 地面
if (child.name === 'Mesh_2') {
child.material.metalness = .5;
child.receiveShadow = true;
}
});
mesh.scene.rotation.y = Math.PI / 4;
mesh.scene.position.set(15, -20, 0);
mesh.scene.scale.set(.9, .9, .9);
land = mesh.scene;
scene.add(land);
});

创建冬奥吉祥物冰墩墩

现在添加可爱的冬奥会吉祥物熊猫冰墩墩 🐼,冰墩墩同样是使用 glb 格式模型加载的。它的原始模型来源于这里,从这个网站免费现在模型后,原模型是使用 3D max 建的我发现并不能直接用在网页中,需要在 Blender 中转换模型格式,还需要调整调整模型的贴图法线,才能还原渲染图效果。

原模型:

冰墩墩贴图:

转换成Blender支持的模型,并在Blender中调整模型贴图法线、并添加贴图:

导出glb格式:

📖 在 Blender 中给模型添加贴图教程传送门:在Blender中怎么给模型贴图

仔细观察冰墩墩 🐼可以发现,它的外面有一层透明塑料或玻璃质感外壳,这个效果可以通过修改模型的透明度、金属度、粗糙度等材质参数实现,最后就可以渲染出如 👆 banner图 所示的那种效果,具体如以下代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
js复制代码loader.load(bingdundunModel, mesh => {
mesh.scene.traverse(child => {
if (child.isMesh) {
// 内部
if (child.name === 'oldtiger001') {
child.material.metalness = .5
child.material.roughness = .8
}
// 半透明外壳
if (child.name === 'oldtiger002') {
child.material.transparent = true;
child.material.opacity = .5
child.material.metalness = .2
child.material.roughness = 0
child.material.refractionRatio = 1
child.castShadow = true;
}
}
});
mesh.scene.rotation.y = Math.PI / 24;
mesh.scene.position.set(-8, -12, 0);
mesh.scene.scale.set(24, 24, 24);
scene.add(mesh.scene);
});

创建奥运五环

奥运五环由基础几何模型圆环面 TorusGeometry 来实现,创建五个圆环面,并调整它们的材质颜色和位置来构成蓝黑红黄绿顺序的五环结构。五环材质使用的是 MeshLambertMaterial。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码const fiveCycles = [
{ key: 'cycle_0', color: 0x0885c2, position: { x: -250, y: 0, z: 0 }},
{ key: 'cycle_1', color: 0x000000, position: { x: -10, y: 0, z: 5 }},
{ key: 'cycle_2', color: 0xed334e, position: { x: 230, y: 0, z: 0 }},
{ key: 'cycle_3', color: 0xfbb132, position: { x: -125, y: -100, z: -5 }},
{ key: 'cycle_4', color: 0x1c8b3c, position: { x: 115, y: -100, z: 10 }}
];
fiveCycles.map(item => {
let cycleMesh = new THREE.Mesh(new THREE.TorusGeometry(100, 10, 10, 50), new THREE.MeshLambertMaterial({
color: new THREE.Color(item.color),
side: THREE.DoubleSide
}));
cycleMesh.castShadow = true;
cycleMesh.position.set(item.position.x, item.position.y, item.position.z);
meshes.push(cycleMesh);
fiveCyclesGroup.add(cycleMesh);
});
fiveCyclesGroup.scale.set(.036, .036, .036);
fiveCyclesGroup.position.set(0, 10, -8);
scene.add(fiveCyclesGroup);

💡 TorusGeometry 圆环面

TorusGeometry 一个用于生成圆环几何体的类。

构造函数:

1
js复制代码TorusGeometry(radius: Float, tube: Float, radialSegments: Integer, tubularSegments: Integer, arc: Float)
  • radius:圆环的半径,从圆环的中心到管道(横截面)的中心。默认值是 1。
  • tube:管道的半径,默认值为 0.4。
  • radialSegments:圆环的分段数,默认值为 8。
  • tubularSegments:管道的分段数,默认值为 6。
  • arc:圆环的圆心角(单位是弧度),默认值为 Math.PI * 2。

💡 MeshLambertMaterial 非光泽表面材质

一种非光泽表面的材质,没有镜面高光。该材质使用基于非物理的 Lambertian 模型来计算反射率。这可以很好地模拟一些表面(例如未经处理的木材或石材),但不能模拟具有镜面高光的光泽表面(例如涂漆木材)。

构造函数:

1
js复制代码MeshLambertMaterial(parameters : Object)
  • parameters:(可选)用于定义材质外观的对象,具有一个或多个属性。材质的任何属性都可以从此处传入。

创建旗帜

旗面模型是从sketchfab下载的,还需要一个旗杆,可以在 Blender中添加了一个柱状立方体,并调整好合适的长宽高和旗面结合起来。

旗面贴图:

旗面添加了动画,需要在代码中执行动画帧播放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
js复制代码loader.load(flagModel, mesh => {
mesh.scene.traverse(child => {
if (child.isMesh) {
child.castShadow = true;
// 旗帜
if (child.name === 'mesh_0001') {
child.material.metalness = .1;
child.material.roughness = .1;
child.material.map = new THREE.TextureLoader().load(flagTexture);
}
// 旗杆
if (child.name === '柱体') {
child.material.metalness = .6;
child.material.roughness = 0;
child.material.refractionRatio = 1;
child.material.color = new THREE.Color(0xeeeeee);
}
}
});
mesh.scene.rotation.y = Math.PI / 24;
mesh.scene.position.set(2, -7, -1);
mesh.scene.scale.set(4, 4, 4);
// 动画
let meshAnimation = mesh.animations[0];
mixer = new THREE.AnimationMixer(mesh.scene);
let animationClip = meshAnimation;
let clipAction = mixer.clipAction(animationClip).play();
animationClip = clipAction.getClip();
scene.add(mesh.scene);
});

创建树木

为了充实画面,营造冬日氛围,于是就添加了几棵松树 🌲 作为装饰。添加松树的时候用到一个技巧非常重要:我们知道因为树的模型非常复杂,有非常多的面数,面数太多会降低页面性能,造成卡顿。本文中使用两个如下图 👇 所示的两个交叉的面来作为树的基座,这样的话树只有两个面数,使用这个技巧可以和大程度上优化页面性能,而且树 🌲 的样子看起来也是有 3D 感的。

材质贴图:

为了使树只在贴图透明部分透明、其他地方不透明,并且可以产生树状阴影而不是长方体阴影,需要给树模型添加如下 MeshPhysicalMaterial、MeshDepthMaterial 两种材质,两种材质使用同样的纹理贴图,其中 MeshDepthMaterial 添加到模型的 custromMaterial 属性上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
js复制代码 let treeMaterial = new THREE.MeshPhysicalMaterial({
map: new THREE.TextureLoader().load(treeTexture),
transparent: true,
side: THREE.DoubleSide,
metalness: .2,
roughness: .8,
depthTest: true,
depthWrite: false,
skinning: false,
fog: false,
reflectivity: 0.1,
refractionRatio: 0,
});
let treeCustomDepthMaterial = new THREE.MeshDepthMaterial({
depthPacking: THREE.RGBADepthPacking,
map: new THREE.TextureLoader().load(treeTexture),
alphaTest: 0.5
});
loader.load(treeModel, mesh => {
mesh.scene.traverse(child =>{
if (child.isMesh) {
child.material = treeMaterial;
child.custromMaterial = treeCustomDepthMaterial;
}
});
mesh.scene.position.set(14, -9, 0);
mesh.scene.scale.set(16, 16, 16);
scene.add(mesh.scene);
// 克隆另两棵树
let tree2 = mesh.scene.clone();
tree2.position.set(10, -8, -15);
tree2.scale.set(18, 18, 18);
scene.add(tree2)
// ...
});

实现效果也可以从 👆 上面 Banner 图中可以看到,为了画面更好看,我取消了树的阴影显示。

📌 在 3D 功能开发中,一些不重要的装饰模型都可以采取这种策略来优化。

💡 MeshDepthMaterial 深度网格材质

一种按深度绘制几何体的材质。深度基于相机远近平面,白色最近,黑色最远。

构造函数:

1
js复制代码MeshDepthMaterial(parameters: Object)
  • parameters:(可选)用于定义材质外观的对象,具有一个或多个属性。材质的任何属性都可以从此处传入。

特殊属性:

  • .depthPacking[Constant]:depth packing 的编码。默认为 BasicDepthPacking。
  • .displacementMap[Texture]:位移贴图会影响网格顶点的位置,与仅影响材质的光照和阴影的其他贴图不同,移位的顶点可以投射阴影,阻挡其他对象,以及充当真实的几何体。
  • .displacementScale[Float]:位移贴图对网格的影响程度(黑色是无位移,白色是最大位移)。如果没有设置位移贴图,则不会应用此值。默认值为 1。
  • .displacementBias[Float]:位移贴图在网格顶点上的偏移量。如果没有设置位移贴图,则不会应用此值。默认值为 0。

💡 custromMaterial 自定义材质

给网格添加 custromMaterial 自定义材质属性,可以实现透明外围 png 图片贴图的内容区域阴影。

创建雪花

创建雪花 ❄️,就要用到粒子知识。THREE.Points 是用来创建点的类,也用来批量管理粒子。本例中创建了 1500 个雪花粒子,并为它们设置了限定三维空间的随机坐标及横向和竖向的随机移动速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
js复制代码// 雪花贴图
let texture = new THREE.TextureLoader().load(snowTexture);
let geometry = new THREE.Geometry();
let range = 100;
let pointsMaterial = new THREE.PointsMaterial({
size: 1,
transparent: true,
opacity: 0.8,
map: texture,
// 背景融合
blending: THREE.AdditiveBlending,
// 景深衰弱
sizeAttenuation: true,
depthTest: false
});
for (let i = 0; i < 1500; i++) {
let vertice = new THREE.Vector3(Math.random() * range - range / 2, Math.random() * range * 1.5, Math.random() * range - range / 2);
// 纵向移速
vertice.velocityY = 0.1 + Math.random() / 3;
// 横向移速
vertice.velocityX = (Math.random() - 0.5) / 3;
// 加入到几何
geometry.vertices.push(vertice);
}
geometry.center();
points = new THREE.Points(geometry, pointsMaterial);
points.position.y = -30;
scene.add(points);

💡 Points 粒子

Three.js 中,雨 🌧️、雪 ❄️、云 ☁️、星辰 ✨ 等生活中常见的粒子都可以使用 Points 来模拟实现。

构造函数:

1
js复制代码new THREE.Points(geometry, material);
  • 构造函数可以接受两个参数,一个几何体和一个材质,几何体参数用来制定粒子的位置坐标,材质参数用来格式化粒子;
  • 可以基于简单几何体对象如 BoxGeometry、SphereGeometry等作为粒子系统的参数;
  • 一般来讲,需要自己指定顶点来确定粒子的位置。

💡 PointsMaterial 点材质

通过 THREE.PointsMaterial 可以设置粒子的属性参数,是 Points 使用的默认材质。

构造函数:

1
js复制代码PointsMaterial(parameters : Object)
  • parameters:(可选)用于定义材质外观的对象,具有一个或多个属性。材质的任何属性都可以从此处传入。

💡 材质属性 .blending

材质的.blending 属性主要控制纹理融合的叠加方式,.blending 属性的值包括:

  • THREE.NormalBlending:默认值
  • THREE.AdditiveBlending:加法融合模式
  • THREE.SubtractiveBlending:减法融合模式
  • THREE.MultiplyBlending:乘法融合模式
  • THREE.CustomBlending:自定义融合模式,与 .blendSrc, .blendDst 或 .blendEquation 属性组合使用

💡 材质属性 .sizeAttenuation

粒子的大小是否会被相机深度衰减,默认为 true(仅限透视相机)。

💡 Three.js 向量

几维向量就有几个分量,二维向量 Vector2 有 x 和 y 两个分量,三维向量 Vector3 有x、y、z 三个分量,四维向量 Vector4 有 x、y、z、w 四个分量。

相关API:

  • Vector2:二维向量
  • Vector3:三维向量
  • Vector4:四维向量

镜头控制、缩放适配、动画

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
// 禁用平移
controls.enablePan = false;
// 禁用缩放
controls.enableZoom = false;
// 垂直旋转角度限制
controls.minPolarAngle = 1.4;
controls.maxPolarAngle = 1.8;
// 水平旋转角度限制
controls.minAzimuthAngle = -.6;
controls.maxAzimuthAngle = .6;
1
2
3
4
5
js复制代码window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}, false);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
js复制代码function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
controls && controls.update();
// 旗帜动画更新
mixer && mixer.update(new THREE.Clock().getDelta());
// 镜头动画
TWEEN && TWEEN.update();
// 五环自转
fiveCyclesGroup && (fiveCyclesGroup.rotation.y += .01);
// 顶点变动之后需要更新,否则无法实现雨滴特效
points.geometry.verticesNeedUpdate = true;
// 雪花动画更新
let vertices = points.geometry.vertices;
vertices.forEach(function (v) {
v.y = v.y - (v.velocityY);
v.x = v.x - (v.velocityX);
if (v.y <= 0) v.y = 60;
if (v.x <= -20 || v.x >= 20) v.velocityX = v.velocityX * -1;
});
}

🔗 完整代码:github.com/dragonir/3d…

总结

💡 本文中主要包含的新知识点包括:

  • TorusGeometry 圆环面
  • MeshLambertMaterial 非光泽表面材质
  • MeshDepthMaterial 深度网格材质
  • custromMaterial 自定义材质
  • Points 粒子
  • PointsMaterial 点材质
  • 材质属性 .blending、.sizeAttenuation
  • Three.js 向量

进一步优化的空间:

  • 添加更多的交互功能、界面样式进一步优化;
  • 吉祥物冰墩墩添加骨骼动画,并可以通过鼠标和键盘控制其移动和交互。

下期预告:

  • 《Metahuman元人类!Three.js人像优化》

想了解场景初始化、光照、阴影、基础几何体、网格、材质及其他 Three.js 的相关知识,可阅读我往期文章。如果觉得文章对你有帮助,不要忘了一键三连哦 👍。

附录

  • [1]. 1000粉!使用Three.js制作一个专属3D奖牌 🥇
  • [2]. Three.js 实现虎年春节3D创意页面
  • [3]. Three.js 实现脸书元宇宙3D动态Logo
  • [4]. Three.js 实现3D全景侦探小游戏
  • [5]. Three.js实现炫酷的酸性风格3D页面
  • [6]. 3dx模型转换为blender支持格式

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1000粉!使用Threejs制作一个专属3D奖牌🥇

发表于 2022-01-20

「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。

背景

破防了 😭!突然发现 SegmentFault 平台的粉丝数量已经突破 1000 了,它是我的三个博客平台掘金、博客园、SegmentFault中首个粉丝突破 1000 的,于是设计开发这个页面,特此纪念一下。非常感谢大家的关注 🙏,后续我会更加专注前端知识的整理分享,写出更多高质量的文章。(希望其他平台也早日破千 😂)

本文使用 React + Three.js 技术栈,实现粉丝突破 1000 的 3D 纪念页面,包含的主要知识点包括:Three.js 提供的光源、DirectionLight 平行光、HemisphereLight 半球光源、AmbientLight 环境光、奖牌素材生成、贴图知识、MeshPhysicalMaterial 物理材质、TWEEN 镜头补间动画、CSS 礼花动画等。

效果

实现效果图如文章 👆 Banner图 所示,页面由包含我的个人信息的奖牌 🥇、1000+ Followers 模型构成,通过以下链接可以实时预览哦 🤣。

👀 在线预览:dragonir.github.io/3d/#/segmen…

实现

引入资源

首先引入开发功能所需的库,其中 FBXLoader 用于加在 1000+ 字体模型、OrbitControls 镜头轨道控制、TWEEN 用于生成补间动画、Stats 用于开发时性能查看。

1
2
3
4
5
js复制代码import * as THREE from "three";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";
import Stats from "three/examples/jsm/libs/stats.module";

场景初始化

这部分内容主要用于初始化场景和参数,详细讲解可点击文章末尾链接阅读我之前的文章,本文不再赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码container = document.getElementById('container');
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.needsUpdate = true;
container.appendChild(renderer.domElement);
// 场景
scene = new THREE.Scene();
// 给场景设置好看的背景
scene.background = new THREE.TextureLoader().load(backgroundTexture);
// 摄像机
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 0);
camera.lookAt(new THREE.Vector3(0, 0, 0));
// 控制器
controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.enableZoom = false;
controls.enablePan = false;
controls.rotateSpeed = .2;

📌 为了达到更好的视觉效果,为 OrbitControls 设置了缩放禁用、平移禁用和减小默认旋转速度

光照效果

为了模拟真实的物理场景,本示例中使用了 3种 光源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
js复制代码// 直射光
const cubeGeometry = new THREE.BoxGeometry(0.001, 0.001, 0.001);
const cubeMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff });
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
cube.position.set(0, 0, 0);
light = new THREE.DirectionalLight(0xffffff, 1);
light.intensity = 1;
light.position.set(18, 20, 60);
light.castShadow = true;
light.target = cube;
light.shadow.mapSize.width = 512 * 12;
light.shadow.mapSize.height = 512 * 12;
light.shadow.camera.top = 80;
light.shadow.camera.bottom = -80;
light.shadow.camera.left = -80;
light.shadow.camera.right = 80;
scene.add(light);
// 半球光
const ambientLight = new THREE.AmbientLight(0xffffff);
ambientLight.intensity = .8;
scene.add(ambientLight);
// 环境光
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0xfffc00);
hemisphereLight.intensity = .3;
scene.add(hemisphereLight);

💡 Three.js 提供的光源

Three.js 库提供了一些列光源,而且没种光源都有特定的行为和用途。这些光源包括:

光源名称 描述
AmbientLight 环境光 这是一种基础光源,它的颜色会添加到整个场景和所有对象的当前颜色上
PointLight 点光源 空间中的一点,朝所有的方向发射光线
SpotLight 聚光灯光源 这种光源有聚光的效果,类似台灯、天花板上的吊灯,或者手电筒
DirectionLight 平行光 也称为无限光。从这种光源发出的光线可以看着平行的。例如,太阳光
HemishpereLight 半球光 这是一种特殊光源,可以用来创建更加自然的室外光线,模拟放光面和光线微弱的天空
AreaLight 面光源 使用这种光源可以指定散发光线的平面,而不是空间中的一个点
LensFlare 镜头眩光 这不是一种光源,但是通过 LensFlare 可以为场景中的光源添加眩光效果

💡 THREE.DirectionLight 平行光

THREE.DirectionLight 可以看作是距离很远的光,它发出的所有光线都是相互平行的。平行光的一个范例就是太阳光。被平行光照亮的整个区域接受到的光强是一样的。

构造函数:

1
js复制代码new THREE.DirectionLight(color);

属性说明:

  • position:光源在场景中的位置。
  • target:目标。它的指向很重要。使用 target 属性,你可以将光源指向场景中的特定对象或位置。此属性需要一个 THREE.Object3D 对象。
  • intensity:光源照射的强度,默认值:1。
  • castShadow:投影,如果设置为 true,这个光源就会生成阴影。
  • onlyShadow:仅阴影,如果此属性设置为 true,则该光源只生成阴影,而不会在场景中添加任何光照。
  • shadow.camera.near:投影近点,表示距离光源的哪一个位置开始生成阴影。
  • shadow.camera.far:投影远点,表示到距离光源的哪一个位置可以生成阴影。
  • shadow.camera.left:投影左边界。
  • shadow.camera.right:投影右边界。
  • shadow.camera.top:投影上边界。
  • shadow.camera.bottom:投影下边界。
  • shadow.map.width 和 shadow.map.height:阴影映射宽度和阴影映射高度。决定了有多少像素用来生成阴影。当阴影具有锯齿状边缘或看起来不光滑时,可以增加这个值。在场景渲染之后无法更改。两者的默认值均为:512。

💡 THREE.HemisphereLight 半球光光源

使用半球光光源,可以创建出更加贴近自然的光照效果。

构造函数:

1
js复制代码new THREE.HeimsphereLight(groundColor, color, intensity);

属性说明:

  • groundColor:从地面发出的光线颜色。
  • Color:从天空发出的光线颜色。
  • intensity:光线照射的强度。

💡 THREE.AmbientLight 环境光

在创建 THREE.AmbientLight 时,颜色会应用到全局。该光源并没有特别的来源方向,并且不会产生阴影。

构造函数:

1
js复制代码new THREE.AmbientLight(color);

使用建议:

  • 通常不能将 THREE.AmbientLight 作为场景中唯一的光源,因为它会将场景中的所有物体渲染为相同的颜色。
  • 使用其他光源,如 THREE.SpotLight 或 THREE.DirectionLight的同时使用它,目的是弱化阴影或给场景添加一些额外颜色。
  • 由于 THREE.AmbientLight 光源不需要指定位置并且会应用到全局,所以只需要指定个颜色,然后将它添加到场景中即可。

添加网格和地面

添加网格是为了方便开发,可以调整模型的合适的相对位置,本例中保留网格的目的是为了页面更有 3D景深效果。透明材质的地面是为了显示模型的阴影。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码// 网格
const grid = new THREE.GridHelper(200, 200, 0xffffff, 0xffffff);
grid.position.set(0, -30, -50);
grid.material.transparent = true;
grid.material.opacity = 0.1;
scene.add(grid);
// 创建地面,透明材质显示阴影
var planeGeometry = new THREE.PlaneGeometry(200, 200);
var planeMaterial = new THREE.ShadowMaterial({ opacity: .5 });
var plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.rotation.x = -0.5 * Math.PI;
plane.position.set(0, -30, -50);
plane.receiveShadow = true;
scene.add(plane);

创建奖牌

由于时间关系,本示例奖牌模型直接使用 Three.js 自带的基础立方体模型 THREE.BoxGeometry 来实现,你也可以使用其他立方体如球体、圆珠等,甚至可以使用 Blender 等专业建模软件创建自己喜欢的奖牌形状。(ps:个人觉得立方体也挺好看的 😂)

💡 奖牌UI素材生成

🥇 奖牌上下面和侧面贴图制作:

为了生成的奖牌有黄金质感,本例中使用 👇 该材质贴图,来生成亮瞎眼的24K纯金效果 🤑。

🥇 奖牌正面和背面贴图制作:

奖牌的正面和背面使用的贴图是 SegmentFault 个人中心页的截图,为了更具有金属效果,我用 👆 上面金属材质贴图给它添加了一个带有圆角的边框。

Photoshop 生成圆角金属边框具体方法:截图上面添加金属图层 -> 使用框选工具框选需要删除的内容 -> 点击选择 -> 点击修改 -> 点击平滑 -> 输入合适的圆角大小 -> 删除选区 -> 合并图层 -> 完成并导出图片。

最终的正反面的材质贴图如 👇 下图所示,为了显示更清晰,我在 Photoshop 中同时修改了图片的对比度 和 饱和度,并加了 SegmentFault 的 Logo 在上面。

🥇 奖牌正面和背面的法相贴图制作:

为了生成凹凸质感,就需要为模型添加法相贴图。使用 👆 上面已经生成的正面和背面的材质贴图,就可以使用在线工具自动生成法相贴图。生成时可以根据需要,通过调整 Strength、Level、Blur 等参数进行样式微调,并且能够实时预览。调整好后点击 Download 下载即可。

🚪 法相贴图在线制作工具传送门:NormalMap-Online

通过多次调节优化,最终使用的法相贴图如 👇 下图所示。

使用上面生成的素材,现在进行奖牌模型的构建。正面和背面使用个人信息材质,其他面使用金属材质。然后遍历对所有面调整金属度和粗糙度样式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码let segmentMap = new THREE.MeshPhysicalMaterial({map: new THREE.TextureLoader().load(segmentTexture), normalMap: new THREE.TextureLoader().load(normalMapTexture) });
let metalMap = new THREE.MeshPhysicalMaterial({map: new THREE.TextureLoader().load(metalTexture)});
// 创建纹理数组
const boxMaps = [metalMap, metalMap, metalMap, metalMap, segmentMap, segmentMap];
// 💡 立方体长宽高比例需要和贴图的大小比例一致,厚度可以随便定
box = new THREE.Mesh(new THREE.BoxGeometry(297, 456, 12), boxMaps);
box.material.map(item => {
// 材质样式调整
item.metalness = .5;
item.roughness = .4;
item.refractionRatio = 1;
return item;
});
box.scale.set(0.085, 0.085, 0.085);
box.position.set(-22, 2, 0);
box.castShadow = true;
meshes.push(box);
scene.add(box);

👆 上面 4 张效果图依次对应的是:

  • 图1:创建没有贴图的 BoxGeometry,只是一个白色的立方体。
  • 图2:立方体添加 材质贴图,此时没有凹凸效果。
  • 图3:立方体添加 法相贴图,此时产生凹凸效果。
  • 图4:调节立方体材质的 金属度、粗糙程度 和 反射率,更具有真实感。

💡 Three.js 中的贴图

贴图类型
  • map:材质贴图
  • normalMap:法线贴图
  • bumpMap:凹凸贴图
  • envMap:环境贴图
  • specularMap:高光贴图
  • lightMap:光照贴图
贴图原理

通过纹理贴图加载器 TextureLoader() 去新创建一个贴图对象出来,然后再去调用里面的 load() 方法去加载一张图片,这样就会返回一个纹理对象,纹理对象可以作为模型材质颜色贴图 map 属性的值,材质的颜色贴图属性 map 设置后,模型会从纹理贴图上采集像素值。

💡 MeshPhysicalMaterial 物理材质

MeshPhysicalMaterial 类是 PBR 物理材质,可以更好的模拟光照计算,相比较高光网格材质MeshPhongMaterial 渲染效果更逼真。

如果你想展示一个产品,为了更逼真的渲染效果最好选择该材质,如果游戏为了更好的显示效果可以选择 PBR 材质 MeshPhysicalMaterial,而不是高光材质 MeshPhongMaterial。

特殊属性
  • .metalness 金属度属性:表示材质像金属的程度。非金属材料,如木材或石材,使用 0.0,金属使用 1.0,中间没有(通常). 默认 0.5. 0.0 到 1.0 之间的值可用于生锈的金属外观。如果还提供了粗糙度贴图 .metalnessMap,则两个值都相乘。
  • .roughness 粗糙度属性:表示材质的粗糙程度. 0.0 表示平滑的镜面反射,1.0 表示完全漫反射. 默认 0.5. 如果还提供粗糙度贴图 .roughnessMap,则两个值相乘.
  • .metalnessMap 金属度贴图:纹理的蓝色通道用于改变材料的金属度.
  • .roughnessMap 粗糙度贴图:纹理的绿色通道用于改变材料的粗糙度。

📌 注意使用物理材质的时候,一般需要设置环境贴图 .envMap。

加载1000+文字模型

1000+ 字样的模型使用 THREE.LoadingManager 和 FBXLoader 加载。详细使用方法也不再本文中赘述,可参考文章末尾链接查看我的其他文章,里面有详细描述。😁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
js复制代码const manager = new THREE.LoadingManager();
manager.onProgress = async(url, loaded, total) => {
if (Math.floor(loaded / total * 100) === 100) {
// 设置加载进度
_this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
// 加载镜头移动补间动画
Animations.animateCamera(camera, controls, { x: 0, y: 4, z: 60 }, { x: 0, y: 0, z: 0 }, 3600, () => {});
} else {
_this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
}
};
const fbxLoader = new FBXLoader(manager);
fbxLoader.load(textModel, mesh => {
mesh.traverse(child => {
if (child.isMesh) {
// 生成阴影
child.castShadow = true;
// 样式调整
child.material.metalness = 1;
child.material.roughness = .2;
meshes.push(mesh);
}
});
mesh.position.set(16, -4, 0);
mesh.rotation.x = Math.PI / 2
mesh.scale.set(.08, .08, .08);
scene.add(mesh);
});

补间动画

相机移动实现漫游等动画,页面打开时,模型加载完毕从大变小的动画就是通过 TWEEN 实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
js复制代码animateCamera: (camera, controls, newP, newT, time = 2000, callBack) => {
var tween = new TWEEN.Tween({
x1: camera.position.x, // 相机x
y1: camera.position.y, // 相机y
z1: camera.position.z, // 相机z
x2: controls.target.x, // 控制点的中心点x
y2: controls.target.y, // 控制点的中心点y
z2: controls.target.z, // 控制点的中心点z
});
tween.to({
x1: newP.x,
y1: newP.y,
z1: newP.z,
x2: newT.x,
y2: newT.y,
z2: newT.z,
}, time);
tween.onUpdate(function (object) {
camera.position.x = object.x1;
camera.position.y = object.y1;
camera.position.z = object.z1;
controls.target.x = object.x2;
controls.target.y = object.y2;
controls.target.z = object.z2;
controls.update();
});
tween.onComplete(function () {
controls.enabled = true;
callBack();
});
tween.easing(TWEEN.Easing.Cubic.InOut);
tween.start();
}

动画更新

最后不要忘了要在 requestAnimationFrame 中更新场景、轨道控制器、TWEEN、以及模型的自转 🌍 等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码// 监听页面缩放,更新相机和渲染
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
stats && stats.update();
controls && controls.update();
TWEEN && TWEEN.update();
// 奖牌模型自转
box && (box.rotation.y += .04);
}

礼花动画

最后,通过 box-shadow 和简单的 CSS 动画,给页面添加 🎉 绽放效果,营造 🎅 欢庆氛围!

1
2
3
4
html复制代码<div className="firework_1"></div>
<div className="firework_2"></div>
<!-- ... -->
<div className="firework_10"></div>

样式动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
css复制代码[class^=firework_] {
position: absolute;
width: 0.1rem;
height: 0.1rem;
border-radius: 50%;
transform: scale(8)
}
.firework_1 {
animation: firework_lg 2s both infinite;
animation-delay: 0.3s;
top: 5%;
left: 5%;
}
@keyframes firework_lg {
0%, 100% {
opacity: 0;
}
10%, 70% {
opacity: 1;
}
100% {
box-shadow: -0.9rem 0rem 0 #fff, 0.9rem 0rem 0 #fff, 0rem -0.9rem 0 #fff, 0rem 0.9rem 0 #fff, 0.63rem -0.63rem 0 #fff, 0.63rem 0.63rem 0 #fff, -0.63rem -0.63rem 0 #fff, -0.63rem 0.63rem 0 #fff;
}
}

实现效果:

🔗 完整代码 github.com/dragonir/3d…

总结

本文中主要涉及到的知识点包括:

  • Three.js 提供的光源
  • THREE.DirectionLight 平行光
  • THREE.HemisphereLight 半球光光源
  • THREE.AmbientLight 环境光
  • 奖牌 UI 素材生成
  • Three.js 中的贴图
  • MeshPhysicalMaterial 物理材质
  • TWEEN 镜头补间动画
  • CSS 礼花动画

想了解场景初始化、光照、阴影及其他 Three.js 的相关知识,可阅读我的其他文章。如果觉得文章对你有帮助,不要忘了 一键三连 👍。

附录

  • [1]. Three.js 实现虎年春节3D创意页面
  • [2]. Three.js 实现脸书元宇宙3D动态Logo
  • [3]. Three.js 实现3D全景侦探小游戏
  • [4]. 使用Three.js实现炫酷的酸性风格3D页面
  • [5]. 环境贴图来源:dribbble
  • [6]. 字体模型来源:sketchfab

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…99100101…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%