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

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


  • 首页

  • 归档

  • 搜索

如何构建Android应用动态图标?

发表于 2024-04-27

揭开 Android 中动态应用图标的秘密, 通过本分步指南彻底改变应用的用户体验!

简介

你是否曾瞥一眼手机屏幕, 发现某个应用的图标看起来焕然一新, 与众不同? 这不仅仅是视觉上的炫耀, 而是动态应用图标的魔力在发挥作用. 这项迷人的功能允许 Android 应用动态更改图标, 而无需从 Play Store 进行更新! 这是一种微妙而强大的策略, 可以吸引用户并为你的应用增添活力. 虽然这看似高深莫测, 但掌握动态应用图标绝对不是难事. 让我们一起踏上学习之旅吧!

如果你是 Android 开发的新手, 或者即使你已经在游戏中摸爬滚打多年, 你可能还没有探索过动态应用图标这个有趣的世界. 不过不用担心! 本指南旨在揭开这一过程的神秘面纱, 提供在 Android 中创建动态应用图标的逐步过程. 无论你是刚刚开始编码冒险, 还是希望为自己的工具包添加另一项技能, 本篇文章都将指导你以独特的方式增强应用的用户体验.

为什么要使用动态应用图标?

试想一下, 你的应用的图标会随着节日的到来而改变, 会根据用户偏好的主题进行调整, 或者会显示出新的功能. 这不仅仅是为了美观, 而是为了在应用和用户之间创建动态互动. 让我们深入了解如何实现这一点.

实现图标更改逻辑

在跳转到代码之前, 让我们先澄清一些事情, 以确保每个人都站在同一起跑线上. 在 Android 的世界里, 图标代表着应用的大门. 它是用户与之交互的第一件事, 因此让它充满活力可以显著提升用户体验.

在本节中, 我们将通过实现动态更改应用图标的逻辑, 深入了解 Android 中动态应用图标的核心. 我们将逐步介绍代码, 解释每个部分的作用.

在 AndroidManifest.xml 中设置

我们需要在 AndroidManifest.xml 文件中处理一个重要步骤. 如果你想让你的应用通过动态图标变化大显身手, 你必须为你的主Activity设置一个activity-alias.

了解activity-alias

在 Android 中, 你可以为清单文件中的主Activity创建activity-alias, 以启用动态应用图标更改. 这些别名允许你将不同的图标与同一个主Activity关联起来, 你可以通过编程启用或禁用这些别名来动态更改应用的图标.

可以将其视为主应用图标的诱饵或替身. 它允许你的应用在同一名称下的不同图标之间切换.

以下是如何在 AndroidManifest.xml 文件中定义activity-alias的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码<application
android:icon="icon"
android:roundIcon="icon">
<activity
// ...
</activity>

<activity-alias
android:name=".MainActivityAlias"
android:icon="icon_2"
android:roundIcon="icon_2"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
</application>

在此示例中, .MainActivityAlias 是别名的名称, .MainActivity 是别名指向的目标Activity. <intent-filter> 就像是在告诉你的应用: “嘿, 这个别名也是启动应用的一种方式!”

请记住, 将代码中的 MainActivityAlias 替换为你在 AndroidManifest.xml 文件中为activity-alias命名的实际名称. 这样可以确保在动态图标更改时, 代码和配置保持一致.

现在, 要在 Android 中创建动态应用图标, 请按照以下步骤操作:

1. 创建广播接收器

我们先创建一个 BroadcastReceiver, 它将监听特定的广播事件, 并负责图标切换. 让我们创建一个新的 IconChangeReceiver 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码class IconChangeReceiver : BroadcastReceiver() {

override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "com.example.app.ACTION_CHANGE_ICON") {
changeAppIcon(context)
}
}

private fun changeAppIcon(context: Context?) {
context?.let {
// This is where the magic happens. We'll get to this in a bit!
}
}
}

在这段代码中, IconChangeReceiver 将充当勤勉的监听器, 同时热切地等待信号com.example.app.ACTION_CHANGE_ICON. 当它听到这个信号时, 就会调用 changeAppIcon - 这是你切换图标的提示.

2. 注册广播接收器

在 AndroidManifest.xml 文件中, 确保使用意图过滤器注册 IconChangeReceiver, 以监听自定义操作:

1
2
3
4
5
6
7
8
ini复制代码<receiver
android:name=".IconChangeReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="com.example.app.ACTION_CHANGE_ICON" />
</intent-filter>
</receiver>

3. 实现图标更改逻辑

在IconChangeReceiver的onReceive方法中, 实现动态更改应用图标的逻辑. 你可以使用条件语句, 根据特定条件选择不同的图标. 下面是一个简化示例:

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
kotlin复制代码class IconChangeReceiver : BroadcastReceiver() {

override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "com.example.app.ACTION_CHANGE_ICON") {
changeAppIcon(context)
}
}

private fun changeAppIcon(context: Context?) {
context?.let { ctx ->
// Example: Conditionally select a different alias based on some criteria
val aliasToEnable = when (someCondition) {
true -> ctx.getString(R.string.alias_1)
false -> ctx.getString(R.string.alias_2)
}

val aliasToDisable = when (aliasToEnable) {
ctx.getString(R.string.alias_1) -> ctx.getString(R.string.alias_2)
else -> ctx.getString(R.string.alias_1)
}

// Change the app icon by enabling one alias and disabling the other
val packageManager = ctx.packageManager
enableComponent(ctx, packageManager, aliasToEnable)
disableComponent(ctx, packageManager, aliasToDisable)
}
}

private fun enableComponent(
context: Context,
packageManager: PackageManager,
componentNameString: String
) {
val componentName = ComponentName(context, componentNameString)

packageManager.setComponentEnabledSetting(
componentName,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP
)
}

private fun disableComponent(
context: Context,
packageManager: PackageManager,
componentNameString: String
) {
val componentName = ComponentName(context, componentNameString)

packageManager.setComponentEnabledSetting(
componentName,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
}
}

在这里, 我们首先要检查上下文(应用的当前状态)是否为空. 我们需要上下文, 因为它能让我们访问特定于应用的资源和类.

然后, 我们根据特定条件决定显示哪个图标. 这就是你可以发挥创意的地方. 例如, 你可以根据一天中的时间, 节假日或应用中的特殊事件来更改图标.

最后, 有了选定的图标资源, 我们就可以开始关键的一步: 更改应用图标. 我们使用 PackageManager 启用代表新图标的activity-alias, 并禁用当前的activity-alias. 这就好比告诉 Android: “嘿, 别用这个图标了, 改用这个吧”.

我们通过在主Activity及其别名上调用 setComponentEnabledSetting 来实现这一点. 使用新图标启用别名并禁用当前图标, 可以有效地切换图标.

4. 触发图标更改

要真正触发图标更改, 我们要发送一个广播, 其中包含我们的 IconChangeReceiver 正在监听的特定操作. 具体方法是创建一个包含动作 com.example.app.ACTION_CHANGE_ICON 的 Intent 并在上下文中调用 sendBroadcast. 通常是这样做的:

如果你是在 Activity 或其他持有 Context 的组件中调用它, 你可以使用:

1
2
scss复制代码val iconChangeIntent = Intent("com.example.app.ACTION_CHANGE_ICON")
sendBroadcast(iconChangeIntent)

而如果你在应用的另一部分, 并拥有对 Context 的引用, 你可以使用:

1
2
ini复制代码val iconChangeIntent = Intent("com.example.app.ACTION_CHANGE_ICON")
context.sendBroadcast(iconChangeIntent)

请记住, 我们的 IconChangeReceiver 将接收广播, 然后根据我们设置的逻辑更改应用的图标.

总结一下

在不断发展的 Android 应用开发世界中, 如何让你的应用脱颖而出, 关键在于如何吸引用户并加入一些个人特色. 动态应用图标可以让你获得身临其境的全新用户体验. 它们能让你的应用图标随时演变, 无需更新或重新安装. 你可以尽情发挥: 庆祝特殊时刻, 切换主题或展示新功能, 一切尽在动态变化之中!

本文转载自: 掘金

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

Flutter - 使用Pigeon实现视频缓存插件 🐌

发表于 2024-04-27

欢迎关注微信公众号:FSA全栈行动 👋

BiliBili: www.bilibili.com/video/BV1mt…

一、概述

Pigeon 是一个可以帮助我们生成 Flutter 与 原生 的通信代码的工具,我们只需要关注其两侧主要的数据处理逻辑即可,从而提升效率。

Flutter 端对于视频缓存功能主要还是依赖原生端比较成熟的实现方案,如下两个开源库

  • iOS: github.com/ChangbaDevs…
  • 安卓: github.com/danikula/An…

其功能是:丢给它一个视频链接,它将生成一个具备缓存功能的播放代理链接。

接下来我们一起看看,如何使用 Pigeon 并结合上述两个库来实现视频缓存插件。

二、创建 Plugin

使用如下命令生成插件项目,这里我指定iOS使用的是 Swift,安卓使用的是 Kotlin

1
2
3
4
shell复制代码flutter create --template=plugin --platforms=android,ios -i swift -a kotlin 项目名

# 如:
# flutter create --template=plugin --platforms=android,ios -i swift -a kotlin video_cache

三、原生依赖

iOS

打开在 ios 目录下的 podspec 文件(这里是 video_cache.podspec),添加相关的第三方库依赖,比如我这里依赖的是 KTVHTTPCache。

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
diff复制代码#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint video_cache.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'video_cache'
s.version = '0.0.1'
s.summary = 'A new Flutter plugin project.'
s.description = <<-DESC
A new Flutter plugin project.
DESC
s.homepage = 'http://example.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'Flutter'
s.platform = :ios, '11.0'

# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
s.swift_version = '5.0'

+ # KTVHTTPCache
+ s.dependency 'KTVHTTPCache', '~> 3.0.0'
end

安卓

打开在 android 目录下的 build.gradle 文件,添加

1
2
3
4
5
6
7
8
9
10
11
12
diff复制代码...
android {
...

dependencies {
testImplementation 'org.jetbrains.kotlin:kotlin-test'
testImplementation 'org.mockito:mockito-core:5.0.0'
+ implementation 'com.danikula:videocache:2.7.1'
}

...
}

然后在 example/android 目录下的 build.gradle 和 settings.gradle 文件添加如下 maven,否则会找不到依赖库

1
2
3
4
5
6
7
8
9
10
11
diff复制代码// build.gradle

allprojects {
repositories {
+ maven { url "https://jitpack.io" }
+ maven { url 'https://maven.aliyun.com/repository/public' }
google()
mavenCentral()
}
}
...
1
2
3
4
5
6
7
8
9
10
11
12
13
diff复制代码// settings.gradle

pluginManagement {
...
repositories {
+ maven { url "https://jitpack.io" }
+ maven { url 'https://maven.aliyun.com/repository/public' }
google()
mavenCentral()
gradlePluginPortal()
}
}
...

四、Pigeon

添加依赖

在 pubspec.yaml 的 dev_dependencies 下添加 pigeon 依赖

1
2
yaml复制代码dev_dependencies:
pigeon: ^17.3.0

定义通信接口

在 lib 目录外创建一个用来定义通信接口的 dart 文件。

这里我们新建了一个与 lib 目录同级的 pigeons 文件夹,来存放与 Pigeon 相关的文件

1
2
3
4
5
6
7
8
shell复制代码.
├── lib
│   ├── ...
│   └── ...
├── pigeons
│   ├── cache.dart
│   └── ...
├── ...

cache.dart 就是我用来定义视频缓存功能相关的通信接口的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dart复制代码import 'package:pigeon/pigeon.dart';

// https://github.com/flutter/packages/blob/main/packages/pigeon/example/README.md
@ConfigurePigeon(PigeonOptions(
dartOut: 'lib/plugin/pigeon.g.dart',
kotlinOut:
'android/src/main/kotlin/com/lxf/video_cache/VideoCacheGeneratedApis.g.kt',
kotlinOptions: KotlinOptions(
// https://github.com/fluttercommunity/wakelock_plus/issues/18
errorClassName: "LXFVideoCacheFlutterError",
),
swiftOut: 'ios/Classes/LXFVideoCacheGeneratedApis.g.swift',
))
@HostApi()
abstract class LXFVideoCacheHostApi {
/// 转换为缓存代理URL
String convertToCacheProxyUrl(String url);
}

生成交互代码

再执行如下命令,指定根据 cache.dart 来生成相应的繁杂且重要的交互代码。

1
shell复制代码flutter pub run pigeon --input pigeons/cache.dart

坑点

一定一定,一定要自定义 kotlinOptions 里的 errorClassName,不然它会给你生成默认的 FlutterError,单单自己的插件编译可能不会怎样,但是一旦集成的项目里也有用到其它用 Pigeon 生成了 FlutterError 的插件时,就会报如下错误了

1
shell复制代码Type FlutterError is defined multiple times

自定义 kotlinOptions 里的 errorClassName:

1
2
3
4
5
6
7
8
dart复制代码@ConfigurePigeon(PigeonOptions(
...
kotlinOptions: KotlinOptions(
// https://github.com/fluttercommunity/wakelock_plus/issues/18
errorClassName: "LXFVideoCacheFlutterError"
),
...
))

五、编写原生代码

iOS

进入到 example/ios 目录下,安装依赖

1
2
shell复制代码cd example/ios 
pod install --repo-update

使用 Xcode 打开 Runner.xcworkspace 开始编写原生代码

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
swift复制代码// VideoCachePlugin.swift

import Flutter
import UIKit
import KTVHTTPCache

// 创建插件时自动生成的类
public class VideoCachePlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
// 注册实现
LXFVideoCacheHostApiSetup.setUp(
binaryMessenger: registrar.messenger(),
api: LXFVideoCacheHostApiImplementation()
)
}
}

class LXFVideoCacheHostApiImplementation: LXFVideoCacheHostApi {
/// 是否可以代理
private var canProxy: Bool?

func convertToCacheProxyUrl(url: String) throws -> String {
// 还未试过开启代理服务
if (self.canProxy == nil) {
self.canProxy = ((try? KTVHTTPCache.proxyStart()) != nil)
}
// 无法代理
if !self.canProxy! { return url }
// 无法转 URL 对象
guard let urlObj = URL(string: url) else { return url }

guard let proxyUrlObj = KTVHTTPCache.proxyURL(withOriginalURL: urlObj) else {
// 代理失败
return url
}
// 代理成功
return proxyUrlObj.absoluteString
}
}

安卓

使用 AndroidStudio 打开 example/android,找到外层的 android 项目开始编写原生代码

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
kotlin复制代码package com.lxf.video_cache

import LXFVideoCacheHostApi
import com.danikula.videocache.HttpProxyCacheServer

import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result

/** VideoCachePlugin */
class VideoCachePlugin : FlutterPlugin, MethodCallHandler {

private lateinit var videoCacheHostApiImplementation: LXFVideoCacheHostApiImplementation

override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
videoCacheHostApiImplementation = LXFVideoCacheHostApiImplementation(flutterPluginBinding)
// 初始化插件
LXFVideoCacheHostApi.setUp(
flutterPluginBinding.binaryMessenger,
videoCacheHostApiImplementation,
)
}

override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// 关闭服务
videoCacheHostApiImplementation.shutdown()
}

override fun onMethodCall(call: MethodCall, result: Result) {}
}

class LXFVideoCacheHostApiImplementation(
private val flutterPluginBinding: FlutterPlugin.FlutterPluginBinding
) : LXFVideoCacheHostApi {
/// 懒加载缓存服务
private val cacheServer by lazy { HttpProxyCacheServer.Builder(flutterPluginBinding.applicationContext).build() }

/// 重写并通过 cacheServer 将原 url 转换为具备缓存功能的 url
override fun convertToCacheProxyUrl(url: String): String {
return cacheServer.getProxyUrl(url)
}

/// 关闭服务
fun shutdown() {
cacheServer.shutdown()
}
}

六、开源库

上述视频缓存插件已开源,并发布至 GitHub:github.com/LinXunFeng/…

你可以通过如下步骤集成使用:

在 pubspec.yaml 中添加 video_cache 依赖

1
2
yaml复制代码dependencies:
video_cache: latest_version

使用

1
2
3
4
5
6
7
8
9
dart复制代码// 导入
import 'package:video_cache/video_cache.dart';

// 将原视频链接转为缓存代理链接
String url = 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4';
url = await VideoCache().convertToCacheProxyUrl(url);

// url转换结果
// http://localhost:50050/https%3A%2F%2Fflutter%2Egithub%2Eio%2Fassets%2Dfor%2Dapi%2Ddocs%2Fassets%2Fvideos%2Fbee%2Emp4/KTVHTTPCachePlaceHolder/KTVHTTPCacheLastPathComponent.mp4

然后把转换后的 url 丢给播放器就可以了~

七、结尾

以上就是 Flutter 与原生交互拿到代理 url 的例子,使用的是 @HostApi,而如果你如果在原生端去调用 Flutter 的 api,则使用 @FlutterApi 去标注相关抽象类即可,使用方法是差不多的。

需要注意的是,当你使用 Swift 去写插件,且使用了 @FlutterApi 去生成相应的原生代码后编译,可能会遇到这个错误

1
bash复制代码type 'FlutterError' does not conform to protocol 'Error'

添加如下拓展即可

1
2
swift复制代码// https://github.com/flutter/flutter/issues/136081
extension FlutterError: Error {}

八、资料

  • github.com/flutter/pac…

如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有 iOS 技术,还有 Android,Flutter,Python 等文章, 可能有你想要了解的技能知识点哦~

本文转载自: 掘金

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

java热更新神器——十秒钟热更新线上代码 分享一个很好用的

发表于 2024-04-27

分享一个很好用的热更新插件ArthasHotSwap

  • github地址:github.com/xxxtai/Arth…

我们在测试环境进行开发调试的时候,会有想要热更新几个文件的需求

例如想要把下面的张三改成李四

image-20240427165838595

如果只是做了一小部分的修改,就去重新发布的,有点得不偿失(因为发布常常得几分钟甚至十几分钟),这时候我们就可以使用ArthasHotSwap这个插件帮我们进行热更新,并且操作特别简单

只需要下载该插件,然后修改代码,进行编译(因为需要class文件)

image-20240427171359813

然后使用插件

image-20240427171524170

再到服务器上粘贴就好了!😁

这里需要注意的是,该插件是将修改后的字节码文件上传到了oss中,然后目标服务器再下载下来进行热更新的,如果需要自定义上传的对象存储的话可以去作者github issue中找到方法

image-20240427171651771

我们可以看到修改已经成功了!

image-20240427183948357

本文转载自: 掘金

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

远程组件 -- 引领未来组件开发新浪潮

发表于 2024-04-27

一、背景

由于公司有一个业务需求:是要小程序中动态加载卡片内容。由于卡片内容经常变化,并且样式布局也可能改动较大。还有一种情况是卡片内容也可能由服务商提供,代码内容无法把控。出于此背景,我们调研了可能的解决方案,感觉都不能满足现状要求:

  • 方案一: iframe 方案:由于代码运行在小程序端,iframe 方案无法满足性能要求,并且如果卡片比较多,附加众多 iframe 也不是那么一回事,
  • 方案二: 微前端方案:由于卡片内容比较小,一般第三方也不会为此特意部署一台服务器,而且要考虑加载资源跨域问题。
  • 方案三:提供源码方案:由于卡片内容经常变化,每次随着他们变动而变更也不是那回事。
  • 终极方案,加载远程组件。服务商只要将自己的代码打包成 umd 格式,然后存放在远程可访问的地址上就行,比如 S3、OSS 等。当他们需求变动时,只需求重新打包上传就 OK 了,基座也不用随着他们一起发版变更。

二、优势

  • 资源高效
  • 灵活便捷
  • 一次部署,N 次受益

三、使用场景

  • 快速演示组件特性
  • 卡片组件,自由定义
  • 协同开发,自由组合
  • 嵌入开发,业务附属
  • 广告投放,引流

四、如何使用

0. 前提

  • 远程组件必须使用 umd 格式
  • 远程地址必须可以跨域访问

1. 查看 DEMO

1.1 react-demo,加载 Antd5 按钮组件示例,在线预览。

  • 效果图:
  • image
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
tsx复制代码import React from "react";
import ReactDOM from "react-dom";
import dayjs from "dayjs";
import LoadRemoteComponent from "./components/LoadRemoteComponent";

<LoadRemoteComponent
urls={["https://cdnjs.cloudflare.com/ajax/libs/antd/5.16.2/antd.min.js"]}
name="antd.Button"
options={{
props: {
type: "primary",
loading: true,
},
externals: {
react: {
import: React,
export: "React",
},
"react-dom": {
import: ReactDOM,
export: "ReactDOM",
},
dayjs: {
import: dayjs,
export: "dayjs",
},
},
}}
>
按钮文字
</LoadRemoteComponent>;

1.2 vue-demo,加载 element-plus 按钮组件示例,在线预览。

  • 效果图:
  • image
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
vue复制代码<template>
<LoadRemoteComponent
:urls="urls"
name="ElementPlus.ElButton"
:options="options"
>
按钮文字
</LoadRemoteComponent>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import LoadRemoteComponent from "./components/LoadRemoteComponent/Index.vue";

const urls = ref([
"https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.7.0/index.full.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.7.0/index.min.css",
]);

const options = ref({
props: {
type: "primary",
loading: true,
},
externals: {
vue: {
import: "",
export: "Vue",
},
},
});

onMounted(() => {
import("vue").then((vue) => {
options.value.externals["vue"].import = vue;
});
});
</script>

2. 属性解释

  • urls: 加载的远程资源地址数组;
  • name: 导出的组件名称;
  • options
+ props: 导出的远程组件的属性;
+ externals: 远程组件的依赖库;

五、源码

点击访问 GitHub:Import-Remote-Component,👏🏻👏🏻👏🏻 欢迎 Star,欢迎批评指正。

本文转载自: 掘金

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

如何用Android Studio 开发原生插件

发表于 2024-04-27

说明

公司使用uniapp开发的app,后期可能要对接银行的一些服务,可能会用到U盾之类的,这种没做过也不太懂,其次就是对接大华摄像头视频播放也要用到SDK之类的,设备初始化之类的。就先研究下怎么生成可供uniapp调用的原生插件了。

环境准备

  1. 下载uniapp官方Android 离线SDK下载完成解压,后面会用到。

image.png
2. 安装java环境,这个很简单,下载JDK,我装的是jdk11,安装完成配置下环境变量即可。
3. Android Studio 下载安装

开发demo插件

  1. 打开AndroidStudio,在菜单栏选择File>New>New Project,新建自定义项目,

image.png
2. 创建名称为MyDemoPlugin的测试项目,按照下面的配置就行。

image.png
新建完成后,项目如下图:

image.png
3. 接下来我们就要创建名为testPlugin插件模块,依次点击File>New>New Module。

image.png
4.创建完成,我们的项目里就多了个testPlugin的文件夹:

image.png
5.下面就要把上面第一步下载的SDK中的UniPlugin-Hello-AS下的app>libs下的文件复制到自己项目的app>libs

image.png
6. 然后就是修改我们的testPlugin中的build.gradle文件里的配置信息,选中文件,双击打开,将dependencies下默认生成的依赖注释掉,添加uni-app所需库依赖。最后一项是引入我们上面复制过来的libs文件目录。

1
2
3
4
5
php复制代码compileOnly 'androidx.recyclerview:recyclerview:1.0.0'
compileOnly 'androidx.legacy:legacy-support-v4:1.0.0'
compileOnly 'androidx.appcompat:appcompat:1.0.0'
compileOnly 'com.alibaba:fastjson:1.2.83'
compileOnly fileTree(include: ['uniapp-v8-release.aar'], dir: '../app/libs')

image.png
7.配置完保存,点击文件上方的立刻同步:

image.png
8. 上面基本配置完了,剩下的就是写个测试代码,在我们所创建的testPlugin下面,下图所示位置右键创建个TestModule类:

image.png

  • 注意:Module 扩展必须继承 UniModule 类
  • 扩展方法必须加上@UniJSMethod (uiThread = false or true) 注解。UniApp会根据注解来判断当前方法是否要运行在UI 线程,和当前方法是否是扩展方法。
    示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
scala复制代码package com.test.testplugin;
import com.alibaba.fastjson.JSONObject;
import io.dcloud.feature.uniapp.annotation.UniJSMethod;
import io.dcloud.feature.uniapp.bridge.UniJSCallback;
import io.dcloud.feature.uniapp.common.UniModule;
public class TestModule extends UniModule {
@UniJSMethod(uiThread = false)
public void open(UniJSCallback callback) {
JSONObject data = new JSONObject();
data.put("hello world:","插件调用成功");
callback.invoke(data);
}
}
  1. 注册插件,在app>src>main目录下创建assets文件夹,然后将下载的SDK中assets中的dcloud_uniplugins.json文件复制过来。修改下里面的内容如下:
    修改前:

image.png

  • dcloud_uniplugins.json说明
  • nativePlugins: 插件跟节点 可存放多个插件
  • hooksClass: 生命周期代理(实现AppHookProxy接口类)格式(完整包名加类名)
  • plugins: 插件数组
  • name : 注册名称
  • class : module 或 component 实体类完整名称
  • type : module 或 component类型。

根据自身项目修改后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
json复制代码{
"nativePlugins":[
{
"hooksClass":"",
"plugins":[
{
"type":"module",
"name":"Test-Plugin",
"class":"com.test.testplugin.TestModule"
}
]
}
]
}

image.png
10. 至此,就可以打包插件了。菜单build->make moudule,下图所示:

image.png
打包需要等一会,完成后会生成如下文件,arr目录中的就是打包后的插件:

image.png

这样java开发的demo插件就算完成了,剩下的就是uniapp调用的问题了。算是完成了第一步吧,过程有点繁琐。

本文转载自: 掘金

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

SwiftUI-日期显示总结

发表于 2024-04-27

当 SwiftUI 需要显示日期时,可以有多种选择,下面总结一些常见的使用方式。

选择显示

比较常见的方式是通过日期选择器选择某个日期后显示。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
swift复制代码import SwiftUI

struct ContentView: View {
@State private var birthDay = Date()

var body: some View {
DatePicker(selection: $birthDay, displayedComponents: [.hourAndMinute,.date]) {
Text("出生日期")
}
.environment(\.locale, Locale(identifier: "zh_Hans_CN"))
.padding()
}
}

效果如下:

日期选择器.png

选择显示到Text

借助于DateFormatter,首先格式化成需要的日期格式,然后显示。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
swift复制代码import SwiftUI

struct ContentView: View {
@State private var selectedDate = Date()
var formatter: DateFormatter {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd hh:mm:ss"
return dateFormatter
}

var body: some View {
VStack {
DatePicker(
selection: $selectedDate,
displayedComponents: [.hourAndMinute, .date],
label: { Text("选择日期") }
)
.padding()

Text(formatter.string(from: selectedDate))
}
}
}

效果如下:

DateFormatter方式.png

不选择显示到Text

SwiftUI 1.0

SwiftUI 1.0 时 Text 就可以显示日期字符串,而且可以同时使用DateFormatter。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
vbnet复制代码import SwiftUI

struct ContentView: View {
let date = Date()

var formatter: DateFormatter {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd hh:mm:ss"
return dateFormatter
}

var body: some View {
VStack {
Text("\(date)")

Text(formatter.string(from: date))
}
}
}

效果如下:

SwiftUI 1.0

SwiftUI 2.0

SwiftUI 2.0 之后,Text 可以直接显示日期,而且支持多种不同的形式。代码如下:

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
swift复制代码import SwiftUI

struct ContentView: View {
var date = Date()

var body: some View {
VStack {
Text(date, style: .date)

Text(date, style: .time)

Text(date, style: .relative)

Text(date, style: .offset)

Text(date.addingTimeInterval(600), style: .timer)

Text(date.getCurrentTime(), style: .timer)
}
}
}

extension Date {
func getCurrentTime() -> Date {
let calendar: Calendar = Calendar.current
let year = calendar.component(.year, from: self)
let month = calendar.component(.month, from: self)
let day = calendar.component(.day, from: self)
let components = DateComponents(year: year, month: month, day: day, hour: 0, minute: 0, second: 0)
return Calendar.current.date(from: components)!
}
}

效果如下:

SwiftUI 2.0.gif

SwiftUI 3.0

WWDC21 推出了获取当前日期与格式化日期的新方法,因此 SwiftUI 3.0 之后显示日期更加方便。代码如下:

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
swift复制代码import SwiftUI

struct ContentView: View {
var date = Date.now

var body: some View {
VStack {
Text(date.formatted(.dateTime.locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.day().locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.week().locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.weekday().locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.weekday(.wide).locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.month().locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.month(.wide).locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.year().locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.day().weekday().locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.day().weekday().month().locale(Locale(identifier: "zh_Hans_CN"))))

Text(date.formatted(.dateTime.day().month().year().locale(Locale(identifier: "zh_Hans_CN"))))
}
}
}

效果如下:

SwiftUI 3.0.png

本文转载自: 掘金

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

几分钟带你了解预编译,拿下大厂面试题 前言 正文 小结

发表于 2024-04-27

前言

我们执行代码的过程,在 JavaScript 引擎的眼里可以分为两个重要的步骤,分别是预编译和执行。

预编译阶段会处理一些语法解析、变量声明提升等工作,为后续的代码执行做好准备;在预编辑完成后代码才开始执行。

我会用底层逻辑详细讲解代码执行的过程需要经历的过程。并且解答:

  • 为什么外层作用域无法访问内层作用域,内层作用域为什么可以访问外层作用域。
  • 变量声明的声明提升和函数声明的整体提升是如何实现的。

正文

我们在开始了解什么是预编译和预编译要经历的过程之前,我们需要先了解函数的自带属性、作用域和作用域连。

函数的自带属性

在JavaScript中,函数有一些自带的属性,一下是一些常见的属性:

  1. length:表示函数的参数个数。
  2. prototype:指向函数的原型对象,原型对象用于定义构造函数的公共属性和方法。
  3. name:函数的名称。
  4. arguments:函数调用时传递的参数数组。

除了这些常见的属性外,函数还存在隐式属性,其中就包括[[scope]]属性。[[scope]]是 JavaScript 中函数的一个隐式属性,其中scope翻译为域或范围。[[scope]]属性仅供 JavaScript 引擎使用,我们无法直接访问。

在函数定义时,系统会通过scope的内部原理定期去调用它,但不会让用户去用。当函数执行时,系统会创建一个执行期上下文的内部对象,此时[[scope]]的值会发生变化。在函数内部访问变量时,实际访问的就是变量的scope(作用域),scope里有作用域链,系统会从作用域链底端依次向下去找变量。

预编译流程

我们用一个例子深入了解一下。

1
2
3
4
5
6
7
8
9
10
javascript复制代码function a() {
function b() {
var b = 55
console.log(a);
}
var a = 200
b()
}
var glob = 50
a()

这段代码会输出什么呢?

让我们通过这段代码一起跟着JavaScript 引擎进入底层世界。

该代码大致流程:首先在全局预编译代码,然后全局执行,然后调用函数a;停止全局执行,开始预编译函数体a,预编译结束后执行函数a,最后调用函数b;停止执行函数a,预编译函数b,预编译完成后执行b;执行完b函数后返回执行a函数,a函数执行完返回全局,然后结束。

  1. 首先JavaScript 引擎对代码进行预编译(发生在全局中):
1. 创建全局上下文对象(Global Object)用于存储全局的有效标识符。
2. 在全局找变量声明,将变量名作为Global Object的属性名,属性值为undefined。
3. 在全局找函数声明,将函数名作为Global Object的属性名,属性值为该函数体。在这几个预编译的步骤中,只会在寻找变量声明和函数声明,其他语句一律跳过。按顺序依次执行完这些步骤后,我们可以得到一个Global Object。

屏幕截图 2024-04-27 133421.png

  1. 对代码的预编译结束后进行全局执行。
1
2
3
javascript复制代码function a() {}
var glob = 50
a()

在执行到a()时开始调用函数,这时JavaScript 引擎会停止执行代码而去调用a函数并且对a函数进行预编译再执行。
3. 在函数体a中进行预编译(发生在函数体中):

1. 创建一个函数上下文对象(Activation Object)用于存储函数中的有效标识符。
2. 在函数体里找形参和变量声明,将形参和变量名作为Activation Object的属性名,属性值值为undefined。
3. 形参和实参相互统一。
4. 在函数体内找函数声明,将函数名作为Activation Object的属性名,属性值为该函数体。在这几个预编译的步骤中,需要按顺序依次执行。

进行a步骤:创建一个函数上下文对象(Activation Object)

屏幕截图 2024-04-27 135402.png

进行b步骤:在函数体里找形参和变量声明

1
2
3
4
5
6
7
8
9
10
javascript复制代码Activation Object={
a:undefined, (形参)
a:undefined (实参)
}//是错误的

因为对象里不能存在相同的键,所以如果会进行重叠覆盖

Activation Object={
a:undefined
}

进行c步骤:形参和实参相互统一。

1
2
3
javascript复制代码Activation Object={
a:undefined
}

进行d步骤:在函数体内找函数声明

1
2
3
4
javascript复制代码Activation Object={
a:undefined,
b:function
}
  1. 执行函数a。
1
2
3
4
5
6
7
8
javascript复制代码function a() {
function b() {
var b = 55
console.log(a);
}
var a = 200
b()
}

当执行到var a = 200时

1
2
3
4
css复制代码Activation Object={
a:200,
b:function
}

屏幕截图 2024-04-27 163958.png

当执行到b()时调用b函数,停止执行函数a,对函数体b进行预编译。

  1. 在函数体b中进行预编译(发生在函数体中):
1. 创建一个函数上下文对象(Activation Object)用于存储函数中的有效标识符。
2. 在函数体里找形参和变量声明,将形参和变量名作为Activation Object的属性名,属性值值为undefined。
3. 形参和实参相互统一。
4. 在函数体内找函数声明,将函数名作为Activation Object的属性名,属性值为该函数体。你会发现函数预编译的方法是一样的。

我们会得到函数b的函数上下文对象为

1
2
3
javascript复制代码Activation Object={
b:undefined
}
  1. 执行函数b
1
2
3
javascript复制代码Activation Object={
b:55
}

屏幕截图 2024-04-27 170005.png

代码执行完成。

小结

预编译的具体步骤。

  1. 在全局进行预编译(发生在全局中):
    1. 创建全局上下文对象(Global Object)用于存储全局的有效标识符。
    2. 在全局找变量声明,将变量名作为Global Object的属性名,属性值为undefined。
    3. 在全局找函数声明,将函数名作为Global Object的属性名,属性值为该函数体。
  2. 在函数体中进行预编译(发生在函数体中):
    1. 创建一个函数上下文对象(Activation Object)用于存储函数中的有效标识符。
    2. 在函数体里找形参和变量声明,将形参和变量名作为Activation Object的属性名,属性值值为undefined。
    3. 形参和实参相互统一。
    4. 在函数体内找函数声明,将函数名作为Activation Object的属性名,属性值为该函数体。

解答

变量声明的声明提升和函数声明的整体提升是如何实现的?

根据预编译的流程,JavaScript 引擎找到变量声明和函数声明后会在运行前赋值,分别赋值为undefined和function(函数体),然后再运行。这样就实现了变量声明的声明提升和函数声明的整体提升。

作用域和作用域链

作用域是执行期上下文对象的集合,这种集合呈链式连接,我们把这种链状关系称之为作用域链。

我们通过这个代码进行解释。

1
2
3
4
5
6
7
8
9
10
javascript复制代码function a() {
function b() {
var b = 55
console.log(a);
}
var a = 200
b()
}
var glob = 50
a()

在这个代码中有3个作用域,分别是全局作用域和a.[[scope]]和b.[[scope]]。它们之间的关系是这样的。

屏幕截图 2024-04-27 170806.png

这个关系是怎么形成的呢?

  1. 在全局预编译完成后,函数a被整体提升生成作用域,并且作用域的0号位指向Global Object。

屏幕截图 2024-04-27 172549.png

  1. 在函数a预编译时:函数a的作用域的0号位指向自己的上下文对象,1号位指向Global Object;函数b在函数a的预编译过程中被整体提升,生成作用域,并且作用域的0号位指向a的作用域。

屏幕截图 2024-04-27 173104.png

  1. 在函数b预编译时:函数b的作用域的0号位指向自己的上下文对象;1号位指向函数a的作用域。

屏幕截图 2024-04-27 173444.png

通过这些步骤就可以理解作用域链是什么,怎么形成的。

解答

为什么外层作用域无法访问内层作用域,内层作用域为什么可以访问外层作用域?

因为在作用域中只能从低位向高位查找,不能从高位找回低位。

屏幕截图 2024-04-27 173104.png

我们通过在这张图进行理解。a函数执行阶段通过作用域的0号位查找需要的有效标识符,如果没有找到便通过作用域的1号位继续查找需要的有效标识符。

小结

我们通过预编译的底层逻辑解答了

  • 为什么外层作用域无法访问内层作用域,内层作用域为什么可以访问外层作用域。
  • 变量声明的声明提升和函数声明的整体提升是如何实现的。

并且了解了预编译的具体步骤:

  • 在全局进行预编译(发生在全局中):
    1. 创建全局上下文对象(Global Object)用于存储全局的有效标识符。
    2. 在全局找变量声明,将变量名作为Global Object的属性名,属性值为undefined。
    3. 在全局找函数声明,将函数名作为Global Object的属性名,属性值为该函数体。
  • 在函数体中进行预编译(发生在函数体中):
    1. 创建一个函数上下文对象(Activation Object)用于存储函数中的有效标识符。
    2. 在函数体里找形参和变量声明,将形参和变量名作为Activation Object的属性名,属性值值为undefined。
    3. 形参和实参相互统一。
    4. 在函数体内找函数声明,将函数名作为Activation Object的属性名,属性值为该函数体。

最后我们用运行代码结尾吧

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码function test(a, b) {
console.log(a);
c = 0
var c;
a = 3
b = 2
console.log(b);
function b() { }
console.log(b);
}
test(1)

自己动手试试会输出什么。

本文转载自: 掘金

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

射线与三角形的交点

发表于 2024-04-27

前言

射线与三角形的求交是一道经典面试题,它是模型选择的必备基础。

这道题要是说的话,并不难,但要往深了说,也不太容易。

而你若想脱颖而出,那就得往深了说。

我接下来会整体的说一下这个解题的思路和方法,有些公式我不会说太细,大家哪里不懂可以点击后面的链接学习。

前期准备

  • 点积:www.bilibili.com/video/BV13s…
  • 叉乘:www.bilibili.com/video/BV1vs…
  • 直线和平面的交点公式:wuli.wiki/online/LPin…
  • 矩阵行列式:www.bilibili.com/video/BV1Qs…
  • 克莱姆法则:www.bilibili.com/video/BV1Pb…

两种解法

image-20240425162453279

射线与三角形求交的常见解法有两种:

  • 先求交,后过滤:先求射线与三角形所在平面的交点,然后判断此交点是否在三角形中。
  • 先过滤,后求交:逐步过滤射线与三角形相交的条件,最后推导出交点。

第一种方法比较简单,很容易推导和理解,但计算效率会比较低,需要具备的知识是叉乘、点积、射线与平面的求交公式。

第二种方法相对难一些,需要具备扎实的线性变换基础,计算效率会高于前者,需要具备的知识是叉乘、点积、矩阵行列式、克莱姆法则(Cramer’s rule)。

咱们依次说一下这两种方法。

解法一

我们用”先求交,后过滤”的方法求射线与三角形交点。

image-20240425172553070

已知:

  • 三角形ABC
  • 射线,原点为O,方向为d

求:射线与三角形的交点P

解:

1.通过AB和AC的叉乘,求出三角形ABC所在平面的垂线n。

image-20240427145451292

若n等于0,则三角形的三点共线,甚至共点,需要过滤掉。

注:这里的n 不需要归一化。

2.通过射线与平面的求交公式求出交点。

根据射线与平面的求交公式可得:

image-20240425172046691

  • t:射线上的时间或距离标量
  • O:射线原点
  • A:平面上一点

射线的方程是:

image-20240425171022590

  • r(t) :在射线上到射线原点的距离为t的点位。

将之前求出的t值代入r(t)便可求出交点,设此交点为P。

3.判断点P是否在三角形ABC中。

在右手坐标系中,若AP^AC,BP^BA,CP^CB分别点积AB^AC都大于0,则点P在三角形ABC中,此时的点P就是射线与三角形的交点,如下图所示:

image-20240426071905554

否则,点P在三角形ABC之外,如下图所示:

image-20240426073101605

解法二

我们用”先过滤,后求交”的方法求射线与三角形交点。

以具象化的方式把问题和问题的解法画出来,要比数字计算更容易理解。所以我接下来会把这个问题的解法画出来。

这个解法的核心是:基于射线的方向和三角形的两条边把射线的源点投影到一个新的空间中。

image-20240426095727618

已知:

  • 三角形ABC
  • 射线,原点为O,方向为d

求:射线与三角形的交点P

解:

根据已知条件,我们可以得到一个线性变换矩阵[-d,AB,AC],矩阵也可以理解为坐标系。

在这个矩阵中,A是原点,-d是射线的反方向,AB,AC是三角形的两条边。

向量AO在坐标系[-d,AB,AC] 中的本地坐标位是(t,u,v)。

由上面的条件可知,(t,u,v) 就是向量AO在[-d,AB,AC] 中的本地坐标位。

image-20240426102942350

所以(t,u,v) 就是[-d,AB,AC]的逆矩阵乘以AO。

image-20240426103625778

因为-d是一个单位向量,所以t值就是世界坐标系中射线与三角形ABC相交的距离,由此距离可以得到交点P:

image-20240426102004413

现在我们已经用一个很快的方式把射线与三角形的交点P给算出来了,那说好的过滤呢?

过滤就是要在刚才的计算过程中进行逐步拦截,判断射线有没有可能和三角形存在交点,若不存在,就不用再往后走了。

一边过滤一边求交

1.先判断矩阵[-d,AB,AC]的有效性。

[-d,AB,AC]的有效性可以通过其行列式来判断。

三维矩阵的行列式可以理解为由以矩阵基向量为临边的平行六面体的有向体积。

[-d,AB,AC] 的行列式的计算方式如下:

image-20240426143734207

  • det 是determinant 的缩写,即行列式
  • AB叉乘AC的结果是垂直于AB、AC的向量,此向量的长度是以AB、AC为临边的平行四边形的面积
  • -d点积上面的向量,就是把-d投影到AB和AC的垂线上,从而得到平行六面体的高,然后乘以上面的向量的向量的长度,也就是平行四边形的面积,从而就得到了以-d,AB,AC为临边的平行六面体的有向体积,效果如下:

image-20240426160804630

det([-d,AB,AC]) 的值有三种情况:

  • 为正时,射线从三角形所在平面的正面穿过;
  • 为负时,射线从三角形所在平面的背面穿过,若三角形背面不可选,则无交点;
  • 为零时,矩阵[-d,AB,AC]发生空间降维,射线可能与三角形平行或者出现零向量,射线与三角形所在的平面没有交点或者有无数个交点。此情况一般不考虑,默认无交点。

2.把[-d,AB,AC]的逆矩阵乘以AO的过程分解、分别计算u,v,t,并过滤。

这时候就需要用到克莱姆法则,其规则如下:

image-20240426163259352

上面的结构和咱们之前求(t,u,v) 时的结构是一样的:

image-20240426102942350

image-20240426173104253

顺便回顾一下我们在解法一里说过的,射线与平面的求交公式:

image-20240425172046691

大家有没有觉得这两种解法里的t 有点相似?对,它们不仅相似,而且一样。

对于为什么用克莱姆法则就能算出t,u,v 来,我会在最后简单解释一下。

当然大家也可以看我在前期准备里提供的链接。

接下来咱们先往后走,说一下过滤条件。

当t 满足以下条件时不会有交点:

  • t<0

t 是从射线的原点O 向着射线的方向d 推进的距离,当此值小于0时,是向射线反方向推进的,这肯定是不对的。

当u和v 满足以下任一条件都不会有交点:

  • u<0
  • v<0
  • u+v>1

(t,u,v)可以理解为在[-d,AB,AC] 中的本地坐标。

当u或v小于0 时,交点会跑到三角形外面,这个比较好理解,因为点A就是源点,[-d,AB,AC] 中的基向量AB、AC 是三角形的边。

那u+v≤1 是什么概念呢?

这就是一个简单的向量加法。

想象你在直角边为1的等腰直角三角形内跑步,三角形中的任一点都可以理解为你先沿着AB轴跑了一段,然后又沿着AC轴跑了一段,你能跑出的最远距离只能是直角边的长度

3.把t 代入射线方程求出交点P。

image-20240426102004413

代码实现

这是我用three.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
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
php复制代码/* 三角形ABC */
const A=new Vector3(0, 0, 0,)
const B=new Vector3(0, 0, -1)
const C=new Vector3(1, 0, -1)
// 背面是否可选
let backfaceCulling=false

/* 射线 */
// 射线原点
const O=new Vector3(0,1,0)
//射线方向
const d=new Vector3(0.2,-1,-0.8)

/* 射线与三角形的求交 */
// 射线的反方向
const _d=new Vector3(-d.x,-d.y,-d.z)
// 向量AB
const AB=new Vector3().subVectors( B, A )
// 向量AC
const AC=new Vector3().subVectors( C, A )
// AB和AC垂线,其长度是以AB,AC为临边的平行四边形的面积
const n=new Vector3().crossVectors( AB, AC )
// 矩阵[-d,AB,AC]的行列式,即以-d,AB,AC为临边的平行六面体的有向体积
let det = _d.dot( n )
if ( det < 0 ) {
if ( backfaceCulling ){
return null
}
} else if ( det === 0 ) {
return null;
}
// 点A到射线源点的向量
const AO=new Vector3().subVectors( O, A )
// 从射线的原点O 向着射线的方向d 推进的距离
const t=AO.dot( n )/det
// 当t<0的时候,向射线反方向推进,与实际不符
if ( t < 0 ) {
return null
}
// AO在AB上的投影坐标
const u = _d.dot(new Vector3().crossVectors( AO, AC ))/det;
if ( u < 0 ) {
return null
}
// AO在AC上的投影坐标
const v = _d.dot(new Vector3().crossVectors( AB, AO ) )/det;
if ( v < 0 ) {
return null
}
// 当u + v >1时会超出三角形的范围
if ( u + v >1 ) {
return null
}
// r ( t ) = O + t d
const p=O.clone().add(d.clone().multiplyScalar(t))

在three.js的Ray对象里有一个intersectTriangle() 方法。

这个方法略有冗余,比如其中的sign 代表了行列式的取向,但这是不需要的,因为我们可以在两个行列式相除的时候,得到行列式取向负负得正的结果。

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
kotlin复制代码intersectTriangle( a, b, c, backfaceCulling, target ) {
// Compute the offset origin, edges, and normal.
// from https://github.com/pmjoniak/GeometricTools/blob/master/GTEngine/Include/Mathematics/GteIntrRay3Triangle3.h
_edge1.subVectors( b, a );
_edge2.subVectors( c, a );
_normal.crossVectors( _edge1, _edge2 );

// Solve Q + t*D = b1*E1 + b2*E2 (Q = kDiff, D = ray direction,
// E1 = kEdge1, E2 = kEdge2, N = Cross(E1,E2)) by
// |Dot(D,N)|*b1 = sign(Dot(D,N))*Dot(D,Cross(Q,E2))
// |Dot(D,N)|*b2 = sign(Dot(D,N))*Dot(D,Cross(E1,Q))
// |Dot(D,N)|*t = -sign(Dot(D,N))*Dot(Q,N)
let DdN = this.direction.dot( _normal );
let sign;
if ( DdN > 0 ) {
if ( backfaceCulling ) return null;
sign = 1;
} else if ( DdN < 0 ) {
sign = - 1;
DdN = - DdN;
} else {
return null;
}
_diff.subVectors( this.origin, a );
const DdQxE2 = sign * this.direction.dot( _edge2.crossVectors( _diff, _edge2 ) );

// b1 < 0, no intersection
if ( DdQxE2 < 0 ) {
return null;
}

const DdE1xQ = sign * this.direction.dot( _edge1.cross( _diff ) );
// b2 < 0, no intersection
if ( DdE1xQ < 0 ) {
return null;
}

// b1+b2 > 1, no intersection
if ( DdQxE2 + DdE1xQ > DdN ) {
return null;
}

// Line intersects triangle, check if ray does.
const QdN = - sign * _diff.dot( _normal );

// t < 0, no intersection
if ( QdN < 0 ) {
return null;
}

// Ray intersects triangle.
return this.at( QdN / DdN, target );
}

扩展-克莱姆法则

image-20240427064402673

当我们说向量p 的x 位置是2的时候,我们可以这么理解这个2 的几何概念:

  • x 轴上的刻度
  • 用来缩放基向量x 的标量
  • 以向量p、基向量y 和基向量z为临边的平行六面体的有向体积,即矩阵[p,y,z] 的行列式det([p,y,z] )

image.png

最后一种理解就是理解克莱姆法则的关键。

y叉乘z的结果是一条长度为1的垂直于y和z的向量,而p与这个向量的叉乘,就是p在这个向量上的正射影乘以这个向量的长度。

根据当前的情况可知,y叉乘z的结果就是基向量z,z的长度是1。

p 在z 上的正射影就是2。

所以,det([p,y,z] ) 的值就是2*1=2

大家理解完了这个原理后,咱们再做个假设。

假设这个向量p 是通过一个矩阵M乘以向量a(ax,ay,az) 得到的。

image-20240427152509869

矩阵M 的基向量x是原来的3倍,其余的基向量不变:

image-20240427072850248

那a 应该在哪里?

理解矩阵变换关系的同学肯定可以看出,这是一个3*ax=2,ax=2/3 的问题。

因为矩阵M中其它的基向量没变,所以向量a的位置是(2/3,0,0)。

其矩阵关系是这样的:

image-20240427071410175

可是如果我只想知道ax 的值,不想知道其它值,应该怎么办呢?

那我们就得把这个矩阵变换的过程分解开来。

因为我们当前的变换都是线性变换,所以矩阵M的张成空间缩放了多少,就是ax 缩放了多少,即:

image-20240427152928894

det([p,y,z])代表了点P在ax所在的轴向上位置,也就是ax 被矩阵M缩放后的位置。

我基于各个原理,继续往后推。

当矩阵M的其它基向量方式改变的时候,[p,y,z]中的基向量也得做相应改变。

所以,当矩阵M是这样的时候:

image-20240427074029063

那ax 就应该这么算:

image-20240427075010216

其结果还是2/3,这是因为我们没有改变向量p和3x,而矩阵中的4y和5z代表的只是以其为临边的平行四边形的面积,上下一除,就被约掉了,我们就需要它被约掉。

向量a中的其它分量也可以按照同样的原来计算。

image-20240427075107742

注:0的出现是由于向量p与其它的两个向量共面导致的。

这就是克莱姆法则。

总结

在计算机图形学里,数学即代码。

坚实的数学基础,会让你写出的代码简洁优雅。

本文转载自: 掘金

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

SpringBoot 业务开发中的算法应用:递归+回溯算法

发表于 2024-04-27

前言

最近在做一个权限管理类的项目,在做首页菜单功能时,遇到一个问题:

当点击某个菜单时,页面顶部需要显示当前菜单的面包屑信息。

研究了一下,最后使用递归+回溯算法,完成了需求。感觉这个功能还挺常见的,在这里记录一下具体的方案,希望可以帮到大家,谢谢!

需求

面包屑导航其实就是这样的

image.png

这个功能还是挺常见的,具体效果就是:当打开某个菜单时,在页面的顶部 显示当前菜单的层级信息。一般是从当前菜单的根菜单开始。

例如:系统中菜单层级为:系统管理->权限管理->角色管理,那么当在点击角色管理菜单时,面包屑部分就会变成系统管理/权限管理/角色管理这样。

下面我们分步来实现:

  1. 封装树形菜单数据
  2. 封装菜单的面包屑层级数据

准备工作

表设计

菜单表设计如下:

image.png

这应该算是最简单的菜单表的设计了,同时也插入了一些测试数据(这里规范了根菜单的父菜单id为0):

image.png

代码

项目依赖为 SpringBoot v2.6.13,引入了mybatis-plus,如下,创建了菜单相关的实体类、dao层接口、service层接口,此外,还封装了一个菜单Vo。

1
2
3
4
5
6
7
8
java复制代码@Data
@TableName("t_menu")
public class MenuEntity {
@TableId(type = IdType.AUTO)
private Integer id;
private String name;
private Integer parentId;
}
1
2
3
java复制代码@Mapper
public interface MenuMapper extends BaseMapper<MenuEntity> {
}
1
2
3
java复制代码public interface MenuService extends IService<MenuEntity> {
List<MenuVo> listTree();
}
1
2
3
java复制代码@Service
public class MenuServiceImpl extends ServiceImpl<MenuMapper, MenuEntity> implements MenuService {
}

菜单vo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Data
public class MenuVo {
/**
* ID
*/
private Integer id;
/**
* 菜单名称
*/
private String name;
/**
* 子菜单集合
*/
private List<MenuVo> subMenus;
}

代码实现

递归封装树形菜单

service层,定义了一个方法listTree,用来返回树形菜单数据

1
2
3
java复制代码public interface MenuService extends IService<MenuEntity> {
List<MenuVo> listTree();
}

实现类中,重写了该方法,通过递归的方式,完成了功能。

封装了一个方法getSubMenus,根据传入的parentId,获取它的子菜单的树形数据。实际就是递归调用本方法,这里就不再赘述。

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
java复制代码@Service
public class MenuServiceImpl extends ServiceImpl<MenuMapper, MenuEntity> implements MenuService {
/**
* 返回树形菜单数据
*
* @return
*/
@Override
public List<MenuVo> listTree() {
List<MenuEntity> list = list();
return getSubMenus(list, 0);
}

/**
* 根据传入的菜单id 封装子菜单树形数据
*
* @param list
* @param parentId
* @return
*/
private List<MenuVo> getSubMenus(List<MenuEntity> list, Integer parentId) {
return list.stream()
//筛选出当前菜单id的直接子菜单
.filter(menu -> parentId.equals(menu.getParentId()))
.map(menu -> {
MenuVo vo = new MenuVo();
//封装vo
BeanUtils.copyProperties(menu, vo);
//通过递归的方式,封装当前菜单的子菜单
vo.setSubMenus(getSubMenus(list, menu.getId()));
return vo;
}).collect(Collectors.toList());
}
}

递归+回溯 封装面包屑信息

下面我们需要获取每一个菜单的面包屑信息,首先在MenuVo中新增一个属性,是一个List<String>集合,用来封装每一层菜单的名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码@Data
public class MenuVo {
/**
* ID
*/
private Integer id;
/**
* 菜单名称
*/
private String name;
/**
* 子菜单集合
*/
private List<MenuVo> subMenus;
/**
* 当前菜单层级信息:即面包屑数据
*/
private List<String> titles;
}

对应MenuService中的方法也要进行修改:

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
java复制代码@Service
public class MenuServiceImpl extends ServiceImpl<MenuMapper, MenuEntity> implements MenuService {
/**
* 返回树形菜单数据
*
* @return
*/
@Override
public List<MenuVo> listTree() {
List<MenuEntity> list = list();
return getSubMenus(list, 0, new ArrayList<>());
}

/**
* 根据传入的菜单id 封装子菜单树形数据
*
* @param list
* @param parentId
* @return
*/
private List<MenuVo> getSubMenus(List<MenuEntity> list, Integer parentId, List<String> titles) {
return list.stream()
//筛选出当前菜单id的直接子菜单
.filter(menu -> parentId.equals(menu.getParentId()))
.map(menu -> {
MenuVo vo = new MenuVo();
//封装vo
BeanUtils.copyProperties(menu, vo);
//封装面包屑信息
int size = titles.size();
titles.add(menu.getName());
vo.setTitles(new ArrayList<>(titles));
//通过递归的方式,封装当前菜单的子菜单
vo.setSubMenus(getSubMenus(list, menu.getId(), titles));
//进行回溯操作,将截至到本层菜单的标题信息都删除
//这里直接使用 titles.indexOf(title),也是因为菜单名称一般都不会重复。如果会重复,可以采取其他处理方式
titles.removeIf((title) -> titles.indexOf(title) >= size);
return vo;
}).collect(Collectors.toList());
}
}

上面的代码中:

  • getSubMenus方法新增了一个参数List<String> titles,这个List集合的作用就是:将每一层的菜单名称封装到里面,方便它的每一个子菜单去获取。
  • 第31行:将当前层的菜单名称,添加到titles中。
  • 第32行:创建一个新的List集合,将titles中的数据复制到里面,然后赋值给vo里面的titles属性。(这里是因为如果直接将titles赋值到vo中,因为这是一个共享变量,所以最后会导致所有菜单的titles值都一样了。所以这里需要新建一个List集合。)
  • 第34行:递归调用时,将共享变量titles,继续进行了传入。
  • 第37行:这里是进行了回溯操作,下面简单介绍一下回溯操作。

回溯操作

在菜单数据的封装过程中,菜单的数据结构,其实就是一个树,比方说:如果每个菜单都只有两个子菜单,那它就是一个二叉树。

而树的深度优先搜索,就是通过递归来完成的。

而在递归过程中,当遍历到某个节点时,如果想对于接下来的两个分支,分别进行计算。那么就可以用到回溯操作。因为我们当前titles是一个共享变量,所以肯定左右两个分支需要分别计算,不可能把左右分支的数据都封装到titles里边,那就乱了。

所以代码中,当进行了左分支的计算之后,就将之前添加进titles的元素,再删除掉。再进行右分支的计算,这样 左右分支的数据不会互相影响。

其实,如果不需要封装面包屑数据,根本用不到回溯,递归就可以了。

回溯算法的原理,一句两句说不清楚,大家可以去看力扣 第39题,能看懂官方题解中的搜索回溯解法的话,回溯算法也能明白了。我也是做这道题,才了解了回溯算法的原理的。

总结

总的来说,还是很兴奋的。之前刷过一段时间的算法题,一直觉得对于我这种CRUD Boy 用处不大。结果今天在业务开发中应用到了回溯算法,虽然可能没有多高级,但还是很高兴,感觉自己学到了很多。

感谢各位的阅读,文章中有不对的地方,感谢各位指正,谢谢~

本文转载自: 掘金

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

axios 跨端架构是如何实现的?

发表于 2024-04-27

本文是“axios源码系列”第三篇,你可以查看以下链接了解过去的内容。

  1. axios 是如何实现取消请求的?
  2. 你知道吗?axios 请求是 JSON 响应优先的

我们都知道,axios 是是一个跨平台请求方案,在浏览器端采用 XMLHttpRequest API 进行封装,而在 Node.js 端则采用 http/https 模块进行封装。axios 内部采用适配器模式将二者合二为一,在隐藏了底层的实现的同时,又对外开放了一套统一的开放接口。

那么本文,我们将来探讨这个话题:axios 的跨端架构是如何实现的?

从 axios 发送请求说起

我们先来看看 axios 是如何发送请求的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
javascript复制代码// 发送一个 GET 请求
axios({
method: 'get',
url: 'https://jsonplaceholder.typicode.com/comments'
params: { postId: 1 }
})

// 发送一个 POST 请求
axios({
method: 'post'
url: 'https://jsonplaceholder.typicode.com/posts',
data: {
title: 'foo',
body: 'bar',
userId: 1,
}
})

dispatchRequest() 方法

当使用 axios 请求时,实际上内部是由 Axios 实例的 .request() 方法处理的。

1
2
3
4
5
6
javascript复制代码// /v1.6.8/lib/core/Axios.js#L38
async request(configOrUrl, config) {
try {
return await this._request(configOrUrl, config);
} catch (err) {}
}

而 ._request() 方法内部会先将 configOrUrl, config 2 个参数处理成 config 参数。

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码// /v1.6.8/lib/core/Axios.js#L62
_request(configOrUrl, config) {
if (typeof configOrUrl === 'string') {
config = config || {};
config.url = configOrUrl;
} else {
config = configOrUrl || {};
}

// ...
}

这里是为了同时兼容下面 2 种调用方法。

1
2
3
4
5
6
7
javascript复制代码// 调用方式一
axios('https://jsonplaceholder.typicode.com/posts/1')
// 调用方式二
axios({
method: 'get',
url: 'https://jsonplaceholder.typicode.com/posts/1'
})

当然,这不是重点。在 ._request() 方法内部请求最终会交由 dispatchRequest() 处理。

1
2
3
4
5
6
javascript复制代码// /v1.6.8/lib/core/Axios.js#L169-L173
try {
promise = dispatchRequest.call(this, newConfig);
} catch (error) {
return Promise.reject(error);
}

dispatchRequest() 是实际调用请求的地方,而实际调用是采用 XMLHttpRequest API(浏览器)还是http/https 模块(Node.js),则需要进一步查看。

1
2
javascript复制代码// /v1.6.8/lib/core/dispatchRequest.js#L34
export default function dispatchRequest(config) { /* ... */ }

dispatchRequest() 接收的是上一步合并之后的 config 参数,有了这个参数我们就可以发送请求了。

跨端适配实现

1
2
javascript复制代码// /v1.6.8/lib/core/dispatchRequest.js#L49
const adapter = adapters.getAdapter(config.adapter || defaults.adapter);

这里就是我们所说的 axios 内部所使用的适配器模式了。

axios 支持从外出传入 adapter 参数支持自定义请求能力的实现,不过很少使用。大部分请求下,我们都是使用内置的适配器实现。

defaults.adapter

defaults.adapter 的值如下:

1
2
javascript复制代码// /v1.6.8/lib/defaults/index.js#L40
adapter: ['xhr', 'http'],

adapters.getAdapter(['xhr', 'http']) 又是在做什么事情呢?

适配器实现

首先,adapters 位于 lib/adapters/adapters.js。

所属的目录结构如下:

image.png

可以看到针对浏览器和 Node.js 2 个环境的适配支持:http.js、xhr.js。

adapters 的实现如下。

首先,将内置的 2 个适配文件引入。

1
2
3
4
5
6
7
8
javascript复制代码// /v1.6.8/lib/adapters/adapters.js#L2-L9
import httpAdapter from './http.js';
import xhrAdapter from './xhr.js';

const knownAdapters = {
http: httpAdapter,
xhr: xhrAdapter
}

knownAdapters 的属性名正好是和 defaults.adapter 的值 [‘xhr’, ‘http’] 是一一对应的。

而 adapters.getAdapter([‘xhr’, ‘http’]) 的实现是这样的:

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
javascript复制代码// /v1.6.8/lib/adapters/adapters.js#L27-L75
export default {
getAdapter: (adapters) => {
// 1)
adapters = Array.isArray(adapters) ? adapters : [adapters];

let nameOrAdapter;
let adapter;

// 2)
for (let i = 0; i < adapters.length; i++) {
nameOrAdapter = adapters[i];
adapter = nameOrAdapter;

// 3)
if (!isResolvedHandle(nameOrAdapter)) {
adapter = knownAdapters[String(nameOrAdapter).toLowerCase()];
}

if (adapter) {
break;
}
}

// 4)
if (!adapter) {
throw new AxiosError(
`There is no suitable adapter to dispatch the request `,
'ERR_NOT_SUPPORT'
);
}

return adapter;
}
}

内容比较长,我们会按照代码标准的序号分 4 个部分来讲。

1)、这里是为了兼容调用 axios() 时传入 adapter 参数的情况。

1
2
3
4
5
javascript复制代码// `adapter` allows custom handling of requests which makes testing easier.
// Return a promise and supply a valid response (see lib/adapters/README.md).
adapter: function (config) {
/* ... */
},

因为接下来 adapters 是作为数组处理,所以这种场景下,我们将 adapter 封装成数组 [adapters]。

1
2
javascript复制代码// /v1.6.8/lib/adapters/adapters.js#L28
adapters = Array.isArray(adapters) ? adapters : [adapters];

2)、接下来,就是遍历 adapters 找到要用的那个适配器。

到目前为止,adapters[i](也就是下面的 nameOrAdapter)既可能是字符串('xhr'、'http'),也可能是函数(function (config) {})。

1
2
3
javascript复制代码// /v1.6.8/lib/adapters/adapters.js#L37
let nameOrAdapter = adapters[i];
adapter = nameOrAdapter;

3)、那么,我们还要检查 nameOrAdapter 的类型。

1
2
3
4
javascript复制代码// /v1.6.8/lib/adapters/adapters.js#L42-L48
if (!isResolvedHandle(nameOrAdapter)) {
adapter = knownAdapters[(id = String(nameOrAdapter)).toLowerCase()];
}

isResolvedHandle() 是一个工具函数,其目的是为了判断是否要从 knownAdapters 获取适配器。

1
2
javascript复制代码// /v1.6.8/lib/adapters/adapters.js#L24
const isResolvedHandle = (adapter) => typeof adapter === 'function' || adapter === null || adapter === false;

简单理解,只有 adapter 是字符串的情况('xhr' 或 'http'),isResolvedHandle(nameOrAdapter) 才返回 false,才从 knownAdapters 获得适配器。

typeof adapter === ‘function’ || adapter === null 这个判断条件我们容易理解,这是为了排除自定义 adapter 参数(传入函数或 null)的情况。

而 adapter === false 又是对应什么情况呢?

那是因为我们的代码只可能是在浏览器或 Node.js 环境下运行。这个时候 httpAdapter 和 xhrAdapter 具体返回是有差异的。

1
2
3
4
5
6
7
javascript复制代码// /v1.6.8/lib/adapters/xhr.js#L48
const isXHRAdapterSupported = typeof XMLHttpRequest !== 'undefined';
export default isXHRAdapterSupported && function (config) {/* ...*/}

// /v1.6.8/lib/adapters/http.js#L160
const isHttpAdapterSupported = typeof process !== 'undefined' && utils.kindOf(process) === 'process';
export default isHttpAdapterSupported && function httpAdapter(config) {/* ... */}

也就是说:在浏览器环境 httpAdapter 返回 false,xhrAdapter 返回函数;在 Node.js 环境 xhrAdapter 返回 false,httpAdapter 返回函数。

因此,一旦 isResolvedHandle() 逻辑执行完成后。

1
javascript复制代码if (!isResolvedHandle(nameOrAdapter)) {/* ... */}

会检查 adapter 变量的值,一旦有值(非 false)就说明找到适配器了,结束遍历。

1
2
3
javascript复制代码if (adapter) {
break;
}

4)、最终在返回适配器前做空检查

1
2
3
4
5
6
7
8
9
javascript复制代码// 4)
if (!adapter) {
throw new AxiosError(
`There is no suitable adapter to dispatch the request `,
'ERR_NOT_SUPPORT'
);
}

return adapter;

如此,就完成了跨端架构的实现。

总结

本文我们讲述了 axios 的跨端架构原理。axios 内部实际发出请求是通过 dispatchRequest() 方法处理的,再往里看则是通过适配器模式取得适应于当前环境的适配器函数。

axios 内置了 2 个适配器支持:httpAdapter 和 xhrAdapter。httpAdapter 是 Node.js 环境实现,通过 http/https 模块;xhrAdapter 这是浏览器环境实现,通过 XMLHttpRequest API 实现。Node.js 环境 xhrAdapter 返回 false,浏览器环境 httpAdapter 返回 false——这样总是能返回正确的适配器。

本文转载自: 掘金

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

1…567…956

开发者博客

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