注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

我的 Android 应用安全方案梳理

作为独立开发者,应用被破解是一件非常让人烦恼的事情。之前有同学在我的一篇博文下面问,有没有一些 Android 防破解的方法。在多次加固、破解、再加固、再破解的过程中,我也积累了一些思路和方法。这里分享一下,如果需要用到,可以作一个参考。 先说一个结论,也是我...
继续阅读 »

作为独立开发者,应用被破解是一件非常让人烦恼的事情。之前有同学在我的一篇博文下面问,有没有一些 Android 防破解的方法。在多次加固、破解、再加固、再破解的过程中,我也积累了一些思路和方法。这里分享一下,如果需要用到,可以作一个参考。


先说一个结论,也是我在 Stackoverflow 上面的一个国外程序员的答案,


anti_debug.png


就是说,APK 包已经在别人手上了,我们能做的不过是提升被破解的难度,如果真的遇到非常“执着”的,要破解一样被破解。如果逻辑非常值钱,那么最好还是把逻辑放到服务器上面。此外,加固也是一个可选的方案。不过目前市面上专业的加固价格并不美丽,各大平台年费从 3 万至 8 万不等,并且对个人开发者并不友好。


下面是我开发过程中为了防止应用被破解采取的一些策略。


1、一些必要的基础知识


首先,别人要破解你的软件。如果只是在自己的手机上面使用,那么他可以修改系统的一些方法进行破解。这种不在我的考虑范围内,因为他们的修改只在自己的手机上生效,构不成传播。我关注的是 APK 文件被破解的情况。


我们在加密的时候会用到一些加密或者编码方法。常见的有,非对称加密算法 RSA 等;对称加密算法 DES、3DES 和 AES 等;不可逆的加密 MD5、SHA256 等。


另外,我们会把重要的加密逻辑放到 Native 层来实现,所以一些 JNI 编程的方法也是需要的。不过,如果仅仅是用来作加密的话,对 C/C++ 的要求是没那么高的。对在 Android 中使用 JNI,可以参考我之前的文章《在 Android 中使用 JNI 的总结》


2、签名校验


2.1 基础签名校验


在应用和 so 中作签名校验可以说是最基本的安全策略。在应用中作签名校验可以防止应用被二次打包。因为如果别人修改你的代码,肯定要重新打包,此时签名必然会改变。对 so 作签名校验是很有必要的,除了防止应用被打包,也可以防止你的 so 被别人盗用。


可以使用如下的代码在 java 中进行签名校验,


private static String getAppSignatureHash(final String packageName, final String algorithm) {
if (StringUtils.isSpace(packageName)) return "";
Signature[] signature = getAppSignature(packageName);
if (signature == null || signature.length <= 0) return "";
return StringUtils.bytes2HexString(EncryptUtils.hashTemplate(signature[0].toByteArray(), algorithm))
.replaceAll("(?<=[0-9A-F]{2})[0-9A-F]{2}", ":$0");
}

对于在 Native 层作签名校验,将上述方法翻译成对应的 JNI 调用即可,这里就不赘述了。


上面是签名校验的逻辑,看似美好,实际上稍微碰到有点破解的经验的就顶不住了。我之前遇到的一种破解上述签名校验的方法是,在自定义 Application 的 onCreate() 方法中读取 APK 的签名并存储到全局变量中,然后 Hook 获取应用签名的方法,并把上述读取到的真实的签名信息返回,以此绕过签名校验逻辑。


2.2 Application 类型校验


针对上述这种破解方式,我想到的第一个方法是对当前应用的 Application 类型作校验。因为他们加载 Hook 的逻辑是在自定义的 Application 中完成的,如果他们的 Application 和我们自己的 Application 类路径不一致,那么可以认定应用为破解版。


不过,这种方式作用也有限。我当时采用这种策略是考虑到有的破解者可能就是用一个脚本破解所有应用,所以改动一下可以防止这类破解者。但是,后来我也遇到一些“狠人”。因为我的软件用了 360 加固,所以如果加固壳工程的 Application 也认为是合法的。于是,我就看到了有的破解者在我的加固包之上又做了一层加固...


2.3 另一种签名校验方法


上述签名校验容易被 Hook 绕过,我们还可以采用另一种签名校验方法。


记得之前在《使用 APT 开发组件化框架的若干细节问题》 这篇文章中提到过,ARouter 在加载 APT 生成的路由信息的时候,一种方式是获取软件的 APK,然后从 APK 的 dex 中获取指定包名下的类文件。那么,我们是不是也可以借鉴这种方式来直接对 APK 进行签名校验呢?


首先,你可以采用下面的方法获取软件的 APK,


ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
File sourceApk = new File(applicationInfo.sourceDir);

获取 APK 签名信息的方法比较多,这里我提供的是 Android 源码中的打包文件的签名代码,代码位置是:android.googlesource.com/platform/to…


这样,当我们拿到 APK 之后,使用上述方法直接对 APK 的签名信息进行校验即可。


3、对重要信息的加密


上述我们提到了一些常用的加密方法,这里介绍下我在设计软件和系统的时候是如何对用户的重要信息作加密处理的。


3.1 使用签名字段防止伪造信息


首先,我的应用在做用户鉴权的时候是通过服务器下发的字段来验证的。为了防止服务器返回的信息被篡改以及在本地被用户篡改,我为返回的鉴权信息增加了签名字段。逻辑是这样的,



  • 服务器查询用户信息之后根据预定义的规则拼接一个字符串,然后使用 SHA256 算法对拼接后的字符串做不可逆向的加密

  • 从服务器拿到用户信息之后会直接丢到 SharedPreference 中(最好加密之后再存储)

  • 当需要做用户鉴权的时候,首先根据之前预定义的规则,对签名字段做校验以判断鉴权信息是否给篡改

  • 如果鉴权信息被篡改,则默认为普通用户权限


除了上述方法之外,为服务器配置 SSL 证书也是比不可少的。现在很多云平台都会提供一年免费的 Trust Asia 的证书(到期可再续费),免费使用即可。


3.2 对写入到本地的键值对做处理


为了防止应用的逻辑被破解,当某些重要的信息(比如上面的鉴权信息)写入到本地的时候,除了做上述处理,我对存储到 SharedPreference 中的键也做了一层处理。主要是使用设备 ID 和键名称拼接,做 SHA256 加密之后作为键值对的键。这里的设备 ID 就是 ANDROID_ID. 虽然 ANDROID_ID 用作设备 ID 并不可靠,但是在这个场景中它可以保证大部分用户存储到本地的键值对中的键是不同的,也就增加了破解者针对某个键值对进行破解的难度。


3.3 重要信息不要直接使用字符串


在代码中直接使用字符串很容易被别人搜索到,一般对于重要的字符串信息,我们可以将其先转换为整数数组。然后再在代码中通过数组得到最终的字符串。比如下面的代码用来将字符串转换为 short 类型的数组,


static short[] getShortsFromBytes(String from) {
byte[] bytesFrom = from.getBytes();
int size = bytes.length%2==0 ? bytes.length/2 : bytes.length/2+1;
short[] shorts = new short[size];
int i = 0;
short s = 0;
for (byte b : bytes) {
if (i % 2 == 0) {
s = (short) (b << 8);
} else {
s = (short) (s | b);
}
shorts[i/2] = s;
i++;
}
return shorts;
}

3.4 Jetpack 中的数据安全


除了上面的一些方法之外,Android 的 Jetpack 对数据安全开发了 Security 库,适用于运行 Android 6.0 和更高版本的设备。Security 库针对的是 Android 应用中读写文件的安全性。详情可以阅读官方文档相关的内容:



更安全地处理数据:developer.android.com/topic/secur…



4、增强混淆字典


混淆之后可以让别人反编译我们的代码之后阅读起来更加困难。这在一定程度上可以增强应用的安全性。默认的混淆字典是 abc 等英文字母组成,还是具有一定的可读性的。我们可以通过配置混淆字典进一步增加阅读的难度:使用特殊符号、0oO 这种相近的字符甚至 java 的关键字来增加阅读的难度。配置的方式是,


# 方法名等混淆指定配置
-obfuscationdictionary dict.txt
# 类名混淆指定配置
-classobfuscationdictionary dict.txt
# 包名混淆指定配置
-packageobfuscationdictionary dict.txt

一般来说,当我们自定义混淆字典的时候需要从下面两个方面呢考虑,



  1. 混淆字典增加反编译识别难度使代码可读性变差

  2. 减小方法和字段名长度从而减小包体积


对于 o0O 这种虽然可读性变差了,但是代码长度相比于默认混淆字典要长一些,这会增加我们应用的包体积。我在选择混淆字典的时候使用的是比较难以记忆的字符。我把混淆字典放到了 Github 上面,需要的可以自取,



混淆字典:github.com/Shouheng88/…



下面是混淆之后的效果,


QQ截图20220216230706.png


这既可以保证包体积不会增大,又增加了阅读的难度。不过当我们反混淆的时候可能会遇到反混淆乱码的问题,比如 SDK 默认的反混淆工具就有这个问题(工具本身的问题)。


5、so 安全性


对 so 的破解,我现在也没有特别好的方法。之前我已经把一些需要高级权限的逻辑搬到了 native 层,但是最终一样被破解。如果是专业的加固,会对 so 同时做加固。我个人目前对 so 也不是特别熟,之前被破解也是因为 so 的内容被修改。后面会对 so 相关的内容做进一步学习和补充。上面提到的 so 的签名校验可以作为安全性检查之一,下面还有一些开发过程中的其他建议可以做参考。


5.1 不要使用布尔类型作为重要 native 方法的返回类型


使用布尔类型作为 native 方法的返回值的一个不好的地方是,别人破解起来会非常容易。因为对于布尔类型,它只有 true 和 false 两种情况。所以,破解者可以很容易地通过将类地方法修改为直接返回 true 或者 false 来绕开校验的逻辑。相对来收更好的方式是返回一个整数或者字符串。


5.2 校验方法的 native 特性


如果一个方法是 native 方法,我们可以通过判断方法的属性信息来判断这个方法是否被修改。上面提到了有些 native 方法如果直接返回布尔类型,可能直接会被篡改为直接返回 true/false 的形式。此时,破解者就把 native 方法修改为普通的方法。所以,我们可以通过判断方法的 native 特性,来判断这个方法是否被别人做了手脚。下面是一个示例方法,


val method = cls.getMethod("method", Int::class.java)
Modifier.isNative(method.modifiers)

6、不要把校验逻辑封装到一个方法里


把一套逻辑封装成一个方法对于常规业务的开发是一个好的习惯。但是把权限校验的逻辑封装到一个方法中就不一定了。因为别人只要把注意力方法在你的这一个方法上面就足够了。这样,只要破解了这一个方法就可以破解你的应用中所有的安全校验逻辑。


但是如果把同一个权限校验的逻辑在所有需要做权限校验的地方都拷贝一份,后续代码维护起来也会非常困难。那么有没有比较折衷的手段,既可以实现逻辑集中维护,又可以把权限校验的逻辑分散到各个需要做权限校验的地方呢?答案是有,只不过要求应用中使用的是 kotlin 语言。


使用 inline 实现权限校验集中管理和分散调用:inline 是 kotlin 的一个关键字,效果类似于 C 语言中的内联。编译的时候会将 inline 方法中的逻辑内联到调用的地方。我们只需要将我们的权限校验的逻辑写到 inline 方法中,然后在需要鉴权的地方调用这个 inline 方法,就可以实现权限校验集中管理和分散调用。这样如果需要破解我们的校验逻辑,需要到每个地方依次进行破解。


此外,


1、权限校验的逻辑最好和业务代码交织在一起而不是分开写。原因如上,分开写别人只要破解这一个方法就够了。
2、C/C++ 层也可以尝试使用 inline 方法


7、使用服务器做安全校验


上面也说了最好的安全措施还是把重要的逻辑放到后端。不过,对于我开发的应用,因为它本身基本是离线使用的,所以,无法在操作过程中使用服务器做鉴权。对此,我使用了两个方案来让服务器参与到防破解中。


其一是,启用版本配置,在应用配置中下发强制升级信息。最初为应用设计服务器的时候我就设计了应用从后端拉取配置信息的接口。这个接口也会同时下发应用的版本信息以及升级的类型。如果是强制升级,那么会弹出一个无法取消的对话框。这样这个版本基本就无法继续使用了。通过这个配置,我们可以通过服务器配置直接禁用被破解的应用版本。


其二,在执行需要高级权限的操作的时候上报服务器。服务器通过后端存储的用户信息判断该用户是否具备该权限。如果不具备权限,那么增加一条违规记录,并记录违规用户的用户信息。后台通过可以配置的形式对单一用户进行禁用。至于这里为什么不直接对用户进行禁用的问题。正如《七武士》中的一个桥段一样,好的防守总是会留一个入口。直接禁用很容易被破解者发现并做相应处理。


另外,最好不要直接抛出异常,弹出的 toast 不要使用明文字符串。因为,上述两种方式都很容易让别人直接定位到我们校验的逻辑的位置。如果不得不抛异常,建议触发 OOM!


总结


写了那么多东西,我也无奈,破解比反破解要容易得多,以上是我在实践过程中总结的一些基本的技巧。对于 Android 应用安全,我还有很多东西需要学习和了解。毕竟,对于应用层开发来说,安全是另一个专业领域的事情。我也只能“防君子不防小人”。后续我学习了更多的内容,做了更多的攻防战,总结更多经验之后再补充。唉,“本是同根生,相煎何太急”!


作者:开发者如是说
来源:juejin.cn/post/7079794266045677575
收起阅读 »

初探 Kotlin Multiplatform Mobile 跨平台原理

一、背景 本文会尝试通过 KMM 编译产物理解一套 kt 代码是如何在多个平台复用的。 KMM 发流程简介 我以开发一个 KMM 日志库为例,简单介绍开发流程是什么: 在 CommonMain 定义接口,用 expect 关键字修饰,表示此接口在不同平台的...
继续阅读 »



一、背景



本文会尝试通过 KMM 编译产物理解一套 kt 代码是如何在多个平台复用的。


KMM 发流程简介


我以开发一个 KMM 日志库为例,简单介绍开发流程是什么:



  1. 在 CommonMain 定义接口,用 expect 关键字修饰,表示此接口在不同平台的实现不一样。

  2. 在具体平台实现接口,并用 actual 关键字修饰


// ----- commonMain -----

expect fun log(tag: String, msg: String)

// ----- androidMain -----

actual fun log(tag: String, msg: String) {
Log.i(tag, msg)
}

// ----- iosMain -----

actual fun log(tag: String, msg: String) {
NSLog("$tag:: %s", msg)
}


  1. 编译、打包、发布


publish_artifacts.png



  1. 依赖具体平台仓库

    1. 如果宿主为 Android App,则依赖对应的 kmm-infra-android

    2. 如果宿主为 iOS App,需要现将 kmm-infra-iosarm64 打包成 Framework,然后 iOS 依赖 Framework

    3. 如果宿主为 KMM 库,则依赖 kmm-infra




二、Common 和具体平台的联系



了解 KMM 基本的开发流程和发布产物后,我们需要继续深入了解发布产物的结构,再来理解 Common 层代码和具体平台代码是如何建立联系的。



Common 层编译产物


├── kmm-infra
   ├── 1.0.0-SNAPSHOT
      ├── kmm-infra-1.0.0-SNAPSHOT-kotlin-tooling-metadata.json
      ├── kmm-infra-1.0.0-SNAPSHOT-sources.jar
      ├── kmm-infra-1.0.0-SNAPSHOT.jar
      ├── kmm-infra-1.0.0-SNAPSHOT.module
      ├── kmm-infra-1.0.0-SNAPSHOT.pom
      └── maven-metadata-local.xml


  • kotlin-tooling-metadata.json,存放了编译工具的相关信息,比如 gradle 版本、KMM 插件版本以及具体平台编译工具的信息,比如 jvm 平台会有 jdk 版本,native 平台会有 konan 版本信息

  • source.jar,Kotlin 源码

  • .jar,存放 .knm (knm是什么,后文会具体介绍) ,其中描述了 expect 的接口

  • .module,见下文


.module 是什么?


用 json 描述编译产物文件结构的清单文件,以及关联 common 和具体平台产物的信息。里面描述的字段较多,我只放一些关键信息,剩余内容感兴趣的读者可以自己研究


{
"variants": [
{
"name": "",
"attributes": {
"org.gradle.category": "",
"org.gradle.usage": "",
"org.jetbrains.kotlin.platform.type": ""
}
"available-at": {
"url": "",
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-common",
"version": {
"requires": "1.8.0"
}
}
]
}
]
}



  • name,当前产物的名称,比如 common 层为 metadataApiElements,具体平台为 {target}{Api/Metadata}Elements-published




  • available-at,具体平台特有的字段,其中 url 指的是具体平台 .module 的文件路径,作为关联 common 和具体平台的桥梁




  • dependencies,描述有哪些依赖




具体平台的 .module


为方便大家更好的理解,这里还是贴出一份完整的 iOS 平台的 .module 文件


{
"formatVersion": "1.1",
"component": {
"url": "../../kmm-infra/1.0.0-SNAPSHOT/kmm-infra-1.0.0-SNAPSHOT.module",
"group": "com.gpt.jarvis.kmm",
"module": "kmm-infra",
"version": "1.0.0-SNAPSHOT",
"attributes": {
"org.gradle.status": "integration"
}
},
"createdBy": {
"gradle": {
"version": "7.4.2"
}
},
"variants": [
{
"name": "iosArm64ApiElements-published",
"attributes": {
"artifactType": "org.jetbrains.kotlin.klib",
"org.gradle.category": "library",
"org.gradle.usage": "kotlin-api",
"org.jetbrains.kotlin.native.target": "ios_arm64",
"org.jetbrains.kotlin.platform.type": "native"
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-common",
"version": {
"requires": "1.8.0"
}
}
],
"files": [
{
"name": "kmm-infra.klib",
"url": "kmm-infra-iosarm64-1.0.0-SNAPSHOT.klib",
"size": 6396,
"sha512": "2ebdb65f7409b86188648c1c9341115ab714ad5579564ce4ec0ee7fb6e0286351f01d43094bc7810d59ab1c4d4fa7887c21ce53bc087c34d129309396ceb85a5",
"sha256": "056914503154535806165c132df52819aedcc93a7b1e731667a3776f4e92ff79",
"sha1": "c43ed6cb8b5bf3f40935230ce3a54b2f27ec1d6a",
"md5": "d79166eda9f4bf67f5907b368f9e9477"
}
]
},
{
"name": "iosArm64MetadataElements-published",
"attributes": {
"artifactType": "org.jetbrains.kotlin.klib",
"org.gradle.category": "library",
"org.gradle.usage": "kotlin-metadata",
"org.jetbrains.kotlin.native.target": "ios_arm64",
"org.jetbrains.kotlin.platform.type": "native"
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-common",
"version": {
"requires": "1.8.0"
}
}
],
"files": [
{
"name": "kmm-infra-iosarm64-1.0.0-SNAPSHOT-metadata.jar",
"url": "kmm-infra-iosarm64-1.0.0-SNAPSHOT-metadata.jar",
"size": 5176,
"sha512": "fa828f456c3214d556942105952cb901900a7495f6ce6030e4e65375926a6989cd1e7b456f772e862d3675742ce2678925a0a12a1aa37f4795e660172d31bbff",
"sha256": "c4de0db2b60846e3b0dbbd25893f3bd35973ae790696e8d39bd3d97d443a7d4c",
"sha1": "e59036a081663f5c5c9f96c72c9c87788233c8bc",
"md5": "9293e982f84b623a5f0daf67c6e7bb33"
}
]
}
]
}


iOS 平台编译产物


我们其实可以通过上面 iOS 平台 .module 文件看到一些描述,有 metadata.jar.klib


├── kmm-infra-iosarm64
│   ├── 1.0.0-SNAPSHOT
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT-metadata.jar
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT-sources.jar
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT.klib
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT.module
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT.pom
│   │   └── maven-metadata-local.xml
│   └── maven-metadata-local.xml
└── kmm-infra-iosx64
├── 1.0.0-SNAPSHOT
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT-metadata.jar
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT-sources.jar
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT.klib
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT.module
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT.pom
│   └── maven-metadata-local.xml
└── maven-metadata-local.xml


  • metadata.jar,主要存放了 .knm

  • .klib,也存放了 metadata.jar 中相同的内容,除此以外还有 ir,方便编译器后端继续编程机器码

  • 如果不了解 ir 是什么,可以参考我之前写的 Kotlin Compiler】IR 介绍


.knm 和 .klib 是什么?后文会具体介绍


三、.klib 和 .knm 文件




  1. klib 的文件结构是怎样的?

  2. .knm 是什么文件?为什么只能用 IDEA 浏览?



klib 文件结构


klib 指 Kotlin Library


klib
├── ir
│   ├── bodies.knb
│   ├── debugInfo.knd
│   ├── files.knf
│   ├── irDeclarations.knd
│   ├── signatures.knt
│   ├── strings.knt
│   └── types.knt
├── linkdata
│   ├── module
│   ├── package_com
│   │   └── 0_com.knm
│   ├── package_com.jarvis
│   │   └── 0_jarvis.knm
│   ├── package_com.jarvis.kmm
│   │   └── 0_kmm.knm
│   ├── package_com.jarvis.kmm.infra
│   │   └── 0_infra.knm
│   └── root_package
│   └── 0_.knm
├── manifest
├── resources
└── targets
└── ios_arm64
├── included
├── kotlin
└── native


.knm 的生成过程


knm 指 kotlin native metadata


kt2knm.svg



  1. .kt 经过编译器 frontend, 生成 kotlinIr

  2. 经过 protobuf 序列化后,生成 .knm 文件,这也解释了 vim 打开是乱码的原因

  3. .knm 通过反序列化可以得到 KotlinIr

  4. KotlinIr 通过反编译可以得到代码的细节,这正是在 IDEA 里能看到 .knm 是什么的原因


使用安装 Kotlin Plugin 的 IDEA 查看 knm 文件


idea_knm.png


使用 vim 查看 knm 文件


vim_knm.png


四、iOS 和 KMM 库的关系



iOS 中的依赖库是一组 .h 和二进制文件,所以 KMM 库最终一定要转成 .h 和二进制文件。
KMM 中,iOS 平台的编译产物是 klib


问题:



  1. Kotlin 是怎样依赖并调用 iOS Objective-C 库的?

  2. iOS 是如何使用 KMM 库的?


为了解释上面的两个问题,需要了解 KMM 和 OC 互操作的机制(互相调用),以及 klib 是如何打包



OC 互操作流程


interop_ios.png



  1. Copy iOS 工程中需要用到的 .h 文件(此处也可以直接在 KMM 工程中通过 Cocoapods 插件直接依赖 pod 库)

  2. .h 文件通过 cinterop 工具生成 klib,由于 kotlin 不认识 oc 的 .h,所以需要通过 klib 将 .h 转成 kotlin 认识的形式后才能调用

  3. 将开发完成的 kotlin 代码编译打包,通过 fatFramework 工具输出最终 .h 和二进制文件

  4. iOS 依赖 Umbrella.h 和二进制文件,此流程已经走到 iOS 原生端,和 KMM 无关了


FatFrameWork 流程


assemble_ios.png



  1. KMM 工程打包 klib 并上传

  2. KMM_Umbrella (依赖了很多 KMM 库的全家桶工程) 工程拉取 klib 依赖

  3. 执行 iosFatFramework 任务,输出最终 framework.h 和二进制文件

    • klib 中的 ir 通过 kotlin 编译器后端,编译成对应平台的二进制文件

    • 链接

    • 合并不同架构的二进制文件,比如 iosArm64 iosX64,具体可参考【mac】lipo命令详解

    • 合并头文件

    • 创建 .modulemap 文件,具体细节可以参考 理解 iOS 中的 Modules

    • 生成 info.plist ,此文件是对 framework 的描述清单文件

    • 合成 DSYM( Debugger Symbols) 文件




最终输出结构如下


fat-framework
└── debug
└── KMMUmbrellaFramework.framework
├── Headers
│   └── KMMUmbrellaFramework.h
├── Info.plist
├── KMMUmbrellaFramework
└── Modules
└── module.modulemap


总结


conclusion.png



  1. 通过在 Common 层定义 expect 接口,生成 .knm,以及关联具体平台信息的 .module

  2. 在具体平台通过 actual 实现接口,生成 .klib/.aar/.jar

  3. Android 平台比较特殊,因为 Kotlin 以前只能编译成 JVM 字节码,不存在 ir 概念,K2 Compiler 出现后,统一抽象了编译流程,使得 JVM 也有了自己的编译器后端,也可以通过 IR 编译为 JVM 字节码

  4. iOS 平台通过 .klib 存放 ir,然后经过编译器后端打成 iOS 可以使用的 .framework

  5. 将对应产物接入到对应平台工程


通过对 KMM 编译产物的探索,能让我们更好地理解 KMM 是如何实现跨平台的。


参考



作者:ZzT
来源:juejin.cn/post/7214412608400212028
收起阅读 »

里程碑!ChatGPT插件影响几何?

上周三OpenAI推出了GPT-4,引起了全球轰动,仅仅过去一周多时间,OpenAI又宣布推出插件功能。如果说ChatGPT是AI的“iPhone时刻”,那么插件就是ChatGPT的“App Store”。超强的开发迭代能力,层出不穷的王炸级新产品,让我们不得...
继续阅读 »

上周三OpenAI推出了GPT-4,引起了全球轰动,仅仅过去一周多时间,OpenAI又宣布推出插件功能。如果说ChatGPT是AI的“iPhone时刻”,那么插件就是ChatGPT的“App Store”。超强的开发迭代能力,层出不穷的王炸级新产品,让我们不得不对OpenAI由衷赞叹。


插件发布


3月24日,OpenAI宣布ChatGPT能够支持第三方插件接入,同时为ChatGPT发布了多个插件,它们将帮助ChatGPT联网实时检索信息、运行计算或使用第三方服务。目前装上插件后,用户可以用 ChatGPT 执行以下操作:




  • 检索实时信息:例如体育比赛比分、股价、最新消息等;




  • 检索知识库信息:例如公司文件、个人笔记等;




  • 代表用户执行操作:例如,订机票、订餐等。




由此可以看出,ChatGPT不再是一个单一的知识问答机器人,它将完全走进我们的日常工作和生活,成为全天候平台生态系统。


image.png


根据OpenAI官方发布信息,ChatGPT的第一批第三方插件包含Expedia、FiscalNote、Instacart、KAYAK、Klarna、Milo、OpenTable、Shopify、Slack、Speak、Wolfram、Zapier等11种。例如Open Table插件允许用户搜索可预订的餐厅,Wolfram插件能够提高计算能力,Instacart插件允许ChatGPT在本地商店购物,Zapier可与谷歌Gmail等应用程序连接,适用于办公场景。除了第三方插件,OpenAI同时推出了自己的两款插件——网络浏览器(web browser)和代码解释器(code interpreter)。


网络浏览器


网络浏览器(web browser)是此次最受大家关注的插件,该插件使用微软必应搜索API从网络上检索内容,同时显示它在概括答案时所访问的网站,并在ChatGPT的回应中引用其来源。此前的ChatGPT包括GTP-4训练数据库的信息内容仅限于2021年9月之前的数据,现在通过该插件ChatGPT可以获得网络上最新的数据,极大提高问题回答的时效性。


例如下面的问题中,ChatGPT可以给出2023年的奥斯卡颁奖名单。


image.png


ChatGPT以往是被动接收数据进行推理,开发人员给它投喂大量历史数据,基于这些数据进行推理,从而给出答案。因此,ChatGPT能够获得巨大成功的原因是其出色的推理能力,人总是处于学习知识的过程,要求的是知识回答准确度,对于新鲜度反而要求不高,出色的推理能力有效保证了知识回答的准确性和条理性。相信掘友们没少让ChatGPT写诗,写小说,写代码,这些出色创作并不需要从网络上去获取最新的内容。


但随着大家对ChatGPT的越来越熟悉,使用需求也越来越多,从知识问答扩展到日常生活、工作和出行,个人事务、财务和交际,这就需要当前最新的回答,需要ChatGPT从最新网络数据去检索、分析和推理,也就是从被动接收数据变为主动获取数据,ChatGPT具备了眼睛和耳朵,能够去听去看,去与当前的网络世界进行同步。


代码解释器


代码解释器则能够执行数学问题、数据分析、可视化和文件格式转换等任务,使ChatGPT能够使用Python并在沙盒环境中处理上传和下载任务。简单来说,代码解释器能够为你复杂的问题直接提供代码,省去你大部分的编码工作,也能够阅读代码,修改代码bug。


根据官方的例子,当问到:一块砖的重量是这块砖自身 30% 的重量与 5 个 GPU 的重量之和,已知每吨重量包含了 100 块砖和 200 个 GPU,那么一块砖的重量是多少?


image.png


经过代码解释器后,给出了代码和答案


image.png


这里我们可以看到,回答问题不再简单的文字描述,而是升级到源码描述,说实话惊叹之余感觉到了一丝丝的危机感,假以时日,随着ChatGPT能力不断提升,如果能够完成系统级编程,程序员门槛会不断降低,对于大多数程序员都将形成冲击。


平台生态


通过上面对这次插件的分析可以看到,ChatGPT不再满足于单纯作为一个聊天机器人,插件功能就是ChatGPT的应用商店。在3月34日之前,我们认为的合作关系是ChatGPT接入到其他APP,为其他APP赋能,属于从属低位。在3月34日之后,我们发现所有APP将纳入ChatGPT,形成ChatGPT生态系统,ChatGPT去指挥其他APP工作,属于主导地位。


ChatGPT试图通过插件功能建立生态系统。在这个生态系统中,开发人员可以创建并发布他们自己的插件,借助ChatGPT获取用户和收益。目前手机厂商的应用商店也是这样,第三方应用经过应用商店安装,消费者发生购买行为后,应用商店可以进行抽成获取利润。三个月前,我们担心ChatGPT训练成本非常高,但没有良好的商业化模式,发展困难。现在插件功能出来后,没有一个人会再去担心ChatGPT商业能力,反而是担心自己能不能搭上人工智能的快车,自己会不会被时代所淘汰。


微软魄力


说完OpenAI的重大突破,回过头来,不得不佩服微软的魄力和决心。在必应接入GTP后,必应搜索引擎的访问量增长15%,谷歌降低了1%,一片形势大好,接入GTP的产品就是New Bing,因此这次插件功能的发布最尴尬的反而是New Bing。拥有插件功能的ChatGPT明显比搜索引擎属性加身的New Bing更有发展前景,对于市场前景来说,New Bing基本等同于完整版的必应,微软推陈出新,斩人先斩己。


这与腾讯有点相似,在QQ还是即时通信老大的情况下,坚决推出了微信,时至今日,微信建立起的生态帝国让腾讯无往不利。如果说插件功能即将构建的生态系统,未来将匹敌甚至超过App Store和谷歌搜索的商业价值,已经不会让人觉得是异想天开了。


总结


从GTP3.5到GTP4,从微软Office Copilot到ChatGPT插件,AI技术的演进和应用正以超乎想象的速度袭来,在过去的这几个月,尤其这两周,我们听惯了重磅、史诗、王炸等词,面对日新月异的技术发展,对于个人来说,我们需要选择拥抱新的领域,新的技术,通过了解来调整自己的工作方式和学习目标;对于企业来说,需要熟悉AI技术的现状和未来预期,积极评估面临的冲击和机遇。


作者:柒号华仔
来源:juejin.cn/post/7214489453399474232
收起阅读 »

用ChatGPT提高开发效率(Andoid)

一、前言 我们问了10个问题,开发的、生活中有疑问的,ChartGPT都给了我们答案。这些答案怎么样,请往下看吧。 ChatGPT-1、写一个车牌号的正则表达式 ChatGPT-2、写一个中国大陆车牌号的正则表达式 ChatGPT-3、用kotlin写一个车...
继续阅读 »

一、前言


我们问了10个问题,开发的、生活中有疑问的,ChartGPT都给了我们答案。这些答案怎么样,请往下看吧。



  • ChatGPT-1、写一个车牌号的正则表达式

  • ChatGPT-2、写一个中国大陆车牌号的正则表达式

  • ChatGPT-3、用kotlin写一个车牌号的校验函数

  • ChatGPT-4、用Kotlin写一个String装换成金额的扩展函数

  • ChatGPT-5、用Kotlin写一个String装换成人民币的扩展函数

  • ChatGPT-6、用Kotlin写一个String转换成人民币的扩展函数

  • ChatGPT-7、用Kotlin写一个将TextView的文本内容复制到粘贴板的扩展函数

  • ChatGPT-8、美食探店怎么做?

  • ChatGPT-9、台湾卤肉饭?

  • ChatGPT-10、牛皮弃面馆(柏庐南路店)


二、ChatGPT使用初体验


ChatGPT-1、写一个车牌号的正则表达式
企业微信截图_16796251027919.png


ChatGPT-2、写一个中国大陆车牌号的正则表达式
企业微信截图_1679625500776.png
^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-HJ-NP-Z](?!DD)[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]$


ChatGPT-3、用kotlin写一个车牌号的校验函数
企业微信截图_16796251209911.png


三、ChatGPT PK 5年+年程序员


ChatGPT-4、用Kotlin写一个String装换成金额的扩展函数
企业微信截图_16796251209911.png


ChatGPT-5、用Kotlin写一个String装换成人民币的扩展函数
企业微信截图_16796251209911.png


ChatGPT-6、用Kotlin写一个String转换成人民币的扩展函数
1111.png
PK
image2023-3-24_11-2-10.png


ChatGPT-7、用Kotlin写一个将TextView的文本内容复制到粘贴板的扩展函数
image2023-3-24_11-2-10.png
PK
image2023-3-24_11-3-0.png


四、总结


ChatGPT-1、写一个车牌号的正则表达式

ChatGPT-2、写一个中国大陆车牌号的正则表达式

ChatGPT-3、用kotlin写一个车牌号的校验函数


以上问题,明确的告诉了我们答案,并且配上了讲解和使用方法。


极大的减少了我们自己写正则的错误概率,或者网上查,答案质量参差不齐的筛选验证的烦恼。


对这种重复劳动、通用的功能函数,ChatGPT很好用


ChatGPT-4、用Kotlin写一个String装换成金额的扩展函数

ChatGPT-5、用Kotlin写一个String装换成人民币的扩展函数

ChatGPT-6、用Kotlin写一个String转换成人民币的扩展函数

ChatGPT-7、用Kotlin写一个将TextView的文本内容复制到粘贴板的扩展函数


以上问题,实现了在开发过程中实际的问题,直接Copy就可以使用


请注意我的问题,用Kotlin写一个String转换成人民币的扩展函数,只要你问题问的明确,ChatGPT就会给你想要的答案


ChatGPT-4、ChatGPT-5转换写错了,写成了装换,ChatGPT理解了我的意思,并且他自我纠正了


ChatGPT-6、ChatGPT-7,ChatGPT与5年+程序员的PK,功能都实现了,实现思路基本相同。但是5+程序员写的更简洁,自由 (程序员的个人素质了,Lewis对个人要求标准较高,这就是通往大佬的之路)。


你在通往IT大佬的路上,不要轻视ChatGPT哦,我们使用的ChatGPT是通用版,训练出来的模型是面向所有用户的。


一个假设,如果使用Githut上所有的代码训练ChatGPT,训练出来的模型还会比不过5年的程序员吗?Githut+ChatGPT好像都为微软的,这个假设可能已经在实验室阶段了。


以上假设已经有了,GitHub Copilot X GitHub + GPT-4联手的产品


五、ChatGPT最后的胡说八道


ChatGPT-8、美食探店怎么做?
企业微信截图_16796255651978.png


ChatGPT-9、台湾卤肉饭?
企业微信截图_16796256304717.png


ChatGPT-10、牛皮弃面馆(柏庐南路店)
企业微信截图_1679625678718.png


ChatGPT-8、美食探店怎么做?

ChatGPT-9、台湾卤肉饭?

ChatGPT-10、牛皮弃面馆(柏庐南路店)


以上问题,初一看ChatGPT回答的很专业,以下详细说一下


ChatGPT-8、ChatGPT-9,很多人都有疑问的问题,网上资料很多,回答的很专业,也很有条理,挺好。作为一个技术就不都说了,可以看看这个「打不过,就加入」,我和ChatGPT的故事


ChatGPT-10,昆山一家小店,没有名气,主打台湾牛肉面和台湾小吃,ChatGPT就开始胡说八道了,但是显得很专业,他谈的模板就是按照逻辑去介绍一家店。如果你问他一家有名的店或者连锁店(例如:海底捞),他会回答的应该很专业。


其实还有很多疑问的,移动开发未来前景怎么样?ETH今天会不会大跌,短线做空可以吗?,但是毕竟今天周五了,让自己过一个愉快的周末吧^_^。


六、延伸阅读


mp.weixin.qq.com/s/PyH2SzFQ9…


juejin.cn/post/721177…


juejin.cn/post/719767…


mp.weixin.qq.com/s/prXyAUf_8…


mp.weixin.qq.com/s/Y1LUxcInd…


mp.weixin.qq.com/s/a3xZZLRgD…


作者:佳应科技
来源:juejin.cn/post/7215020908238209083
收起阅读 »

我的家庭观影方案

前言 一直都想做到在多个设备上都能够观看电影、追剧,甚至不需要手动下载新剧集。 本来网络平台能够解决我的需求,但是以下问题让我望而却步: 国内的爱优腾平台资源有限,而且会员广告、超前点播等骚操作层出不穷,许多影视资源都没法正常观看(删减)或者根本没有上架。 ...
继续阅读 »

前言


一直都想做到在多个设备上都能够观看电影、追剧,甚至不需要手动下载新剧集。


本来网络平台能够解决我的需求,但是以下问题让我望而却步:



  • 国内的爱优腾平台资源有限,而且会员广告、超前点播等骚操作层出不穷,许多影视资源都没法正常观看(删减)或者根本没有上架。



  • 国外的流媒体平台需要科学手段才能访问,而且资源也越来越分散到包括HBONetflixDisney在内的几个平台上,不再是Netflix一家独大,订阅费也不便宜。



  • 第三方网站不是很稳定,而且码率较低。


因此,我决定采取以下方案:先将影片离线下载到本地,再进行播放。


 


以前一直没能付诸实践,最近刚好有空余时间,并且还有许多剧集想要看,所以来试试这个方案,下面记录我在实践中的折腾过程。


一些方案的转变


SMB + Kodi / infuse + qBittorrent



  • 用我的 windows 电脑24小时开机,充当一个SMB服务器的角色,把影视文件夹共享出来;



  • 下载则使用qBittorrent通过BT和PT站点下载一些资源;



  • 电视端使用Kodi连接SMB服务器进行播放,iPad则用infuse观看。


这个方案其实能用,但是不是很优雅,多端设备的播放记录也无法同步是一个大问题。


Jellyfin + qBittorrent


Jellyfin是一款免费、开源的媒体服务器软件,可以让你在家中的任何设备上流式传输和管理你的媒体文件。



  • 在电脑上安装一个 jellyfin server



  • 各端使用jellyfin客户端即可


这个方案也不错,但是使用下来也有几个问题:



  1. Jellyfin的客户端支持不如Kodi和infuse,有些字幕格式、视频格式只能通过服务端解码;



  1. 最重要的是,Jellyfin的界面在我的电视上显示得不是很美观,而且有时候会出现一些小毛病。


 


现在的方案


文件共享方案


由于之前SMB挂在电脑上需要整天开机,并且对机器的硬盘有损害,所以现在给软路由加了一块移动硬盘来存放共享文件,算是一个轻量的NAS系统了



  • 这里用的是luci-app-samba4这个插件


notion image



  • 需要先挂载一下硬盘,这里我用的ntfs格式的硬盘,可以用ntfs-3g来挂载,开机启动时挂载一下即可 mount -t ntfs-3g /dev/sdb2 /mnt/sdb


notion image



  • 最后只需要配置共享名称,路径,用户名参数就可以开始使用了(可以创建一个单独的用户用于共享)


image.png


剧集管理


因为又想达到自动追剧的目的,这里是用的一个比较常见的方案:


下载软件


因为文件统一存放在软路由上,因此下载也转移到软路由上,也方便24小时做种。


这里直接用了docker 作为容器层来管理,镜像是 linuxserver 管理的 qbit 镜像 docker-qbittorrent,对内网暴露 webui 端口来达到管理的目的


剧集追踪


Sonarr 负责剧集的管理追踪,像这里只需要添加剧集并追踪,就可以自动下载所有剧集,并且更新后第一时间完成下载,这里也用了 linuxserver 的docker镜像


image.png
这里追踪的规则也挺多的,不赘述;也可以手动勾选来完成精细化的控制


notion image


电影追踪


Radarr 负责电影的追踪,与 Sonarr 类似,界面还支持中文 i18n,这里同样用了 linuxserver 的docker镜像


image.png


搜刮器


Prowlarr 搜刮器是用于Sonarr和Radarr对于一些BT、PT站资源的访问,相当于一个转发聚合层,这里再次用了 linuxserver 的docker镜像


支持大多数BT、PT站,手动添加即可


image.png


image.png
添加后在这里添加Sonarr和Radarr客户端即可


notion image


媒体服务器


现在我媒体服务器用的是 Plex,免费的服务对我来说就足够了,已经可完成资源的整理和展示



  • Plex 作为我的媒体服务器,可以在 server 端存储我的观影记录,而且客户端支持很好,不易出现播放不流畅的情况;



  • 媒体文件则通过qBittorrent进行离线下载,然后传输到Plex的媒体库中;



  • 各端使用 Plex 客户端,infuse,kodi 挂载均可


成果


这是最后的效果,可以看到漂亮的海报墙


image.png


总结


目前整套方案结构如下


notion image


作者:Hongzzz
来源:juejin.cn/post/7213332433168990269
收起阅读 »

大喊一声Fuck!代码就能跑了是什么体验?

大喊一声Fuck!代码就能跑了是什么体验? 1 前言 大家好,我是心锁,23届准毕业生。 程序员的世界,最多的不是代码,而是💩山和bug。 近期我在学习过程中,在github找到了这么一个项目,能在我们输错命令之后,大喊一声Fuck即可自动更正命令,据说喊得越...
继续阅读 »

大喊一声Fuck!代码就能跑了是什么体验?


1 前言


大家好,我是心锁,23届准毕业生。


程序员的世界,最多的不是代码,而是💩山和bug。


近期我在学习过程中,在github找到了这么一个项目,能在我们输错命令之后,大喊一声Fuck即可自动更正命令,据说喊得越大声效果越好。


c37237b03e45fed8c2828c6f7abb93b9


2 项目基本介绍


thefuck是一个基于Python编写的项目,它能够自动纠正你在命令行中输入的错误命令。如果你输错了一个命令,只需要在命令行中输入“fuck”,thefuck就会自动纠正你的错误。该项目支持众多的终端和操作系统,包括Linux、macOS和Windows。


43885f5e1f8c7ff2b3392d297c855609


2.1 环境要求



  • python环境(3.4+)


2.2 安装方式


thefuck支持brew安装,非常方便,在macOS和Linux上都可以通过brew安装。


brew install thefuck

也支持通过pip安装,便携性可以说是一流了。


pip3 install thefuck

2.3 配置环境变量


建议将下边的代码配置在环境变量中(.bash_profile.bashrc.zshrc),不要问为什么,问就是有经验。


eval $(thefuck --alias)
eval $(thefuck --alias FUCK)
eval $(thefuck --alias fuck?)
eval $(thefuck --alias fuck?)

接着运行source ~/.bashrc(或其他配置文件,如.zshrc)确认更改立即可用。


3 使用效果


Untitled


03cf7e926946b7d8a3da902841c3c5b1


4 thefuck的工作原理


thefuck的工作原理非常简单。当你输入一个错误的命令时,thefuck会根据你输入的命令和错误提示自动推测你想要输入的正确命令,并将其替换为正确的命令。thefuck能够自动推测正确的命令是因为它内置了大量的规则,这些规则能够帮助thefuck智能地纠正错误的命令。


所以,该项目开放了自定义规则。


4.1 创建自己的规则


如果thefuck内置的规则不能够满足你的需求,你也可以创建自己的规则。thefuck的规则是由普通的Python函数实现的。你可以在~/.config/thefuck/rules目录下创建一个Python脚本,然后在其中定义你的规则函数。


以创建一个名为my_rule的规则为例,具体步骤如下:


4.1.1 创建rule.py文件


~/.config/thefuck/rules目录下创建一个Python脚本,比如my_rules.py


4.1.2 遵循的规则


在自定义脚本中,必须实现以下两个函数,match显然是用来匹配命令是否吻合的函数,而get_new_command则会在match函数返回True时触发。


match(command: Command) -> bool
get_new_command(command: Command) -> str | list[str]

同时可以包含可选函数,side_effect的作用是开启一个副作用,即除了允许原本的命令外,你可以在side_effect做更多操作。


side_effect(old_command: Command, fixed_command: str) -> None

5 yarn_uninstall_to_remove


以创建一个名为yarn_uninstall_to_remove的规则为例,该规则会在我们错误使用yarn uninstall …命令时,自动帮助我们修正成yarn remove … 。具体步骤如下:


5.1 创建yarn_uninstall_to_move.py文件


~/.config/thefuck/rules目录下创建一个Python脚本,yarn_uninstall_to_remove.py


5.2 编写代码


from thefuck.utils import for_app

@for_app('yarn')
def match(command):
return 'uninstall' in command.script

def get_new_command(command):
return command.script.replace('uninstall', 'remove')

priority=1 # 优先级,数字越小优先级越高

5.3 效果


Untitled


6 总结


世界之大,无奇不有。不得不说的是,伴随着AI的逐渐发展,类似这种项目未来一定是优先接入AI者才可以继续发展。


友情提示,喊fuck的时候先设置后双击control打开听写功能,喊完再点击一下control完成输入。


Untitled





作者:源心锁
来源:juejin.cn/post/7213651072145244221
收起阅读 »

怎么算是在工作中负责?

作为打工人,受人之禄,忠人之事。但就像呼兰说的,躺有躺的价格,卷有卷的价格。身为程序员,我们在平时工作中要做到怎样才能算是“负责”了呢? 我们可以把工作边界和范围分为三部分: 个人基本能力 工作内容 工作时间 对自己的基本能力负责 基本能力包括两部分:1)...
继续阅读 »

作为打工人,受人之禄,忠人之事。但就像呼兰说的,躺有躺的价格,卷有卷的价格。身为程序员,我们在平时工作中要做到怎样才能算是“负责”了呢?


我们可以把工作边界和范围分为三部分:



  • 个人基本能力

  • 工作内容

  • 工作时间


对自己的基本能力负责


基本能力包括两部分:1)技术能力,2)熟悉公司系统的能力。


程序员是一个非常需要持续学习的职业,我们在实际工作中,遇到自己不会的问题在所难免,这时可以向别人请教,但是千万不要觉得请教完就没事儿了,我们需要思考复盘自己为什么不会,要想办法补齐自己的知识和技能短板。


我们学的东西一定要在实际工作中使用,这样才能够激发学习的积极性,同时验证自己的学习成果。当公司准备技术升级或者技术转型时,这也是我们为自己的技能升级的好机会。


很多公司都会有自己的内部系统,熟练掌握和使用这些系统,也是我们需要做到的,它的价值在于,内部系统一般都是和公司的整个监控系统集成好的,例如公司内部的SOA框架或者微服务框架,都是和公司内部的监控系统有集成的,即使这个框架再“不好”,公司内部的项目还是会使用,我们需要让自己站得高一些,去看待内部系统在整个公司级别的作用和地位,这样才能更好地发挥自己的技术能力。


对安排的工作负责


程序员职业的特殊性在于,工作本身的具体内容和难度,会随着被安排的工作内容的改变而改变。从对工作负责的角度来说,我们大部分人会付出比当初预想的更多的时间,才能让自己按时完成工作。


如果一件事情的复杂度远远超过之前的预估,在规定的时间内,自己确实无法完成,这时正确的态度不是硬着头皮上,而是将情况理清楚,早点找经理或者负责人,让他们知道事情的进度和之前没有预想到的难度,把事情重新安排一下。


从管理者的角度来看,一件事情安排的不合理,就应该早发现,早计划,重新安排资源。


对工作时间负责


对工作时间负责,是说最好在“实际上班”时间之前到,避免有人找你却找不到的情况。


这不只是为了保证工作时间,而是想强调程序员的工作不止是写代码,还有很多沟通交流的事情,要保证基本的工作时间,才能更有效的和团队交流,确保我们的工作的价值。


对于项目和团队安排的各种会议,要准时参加,如果不能参加,需要提前告知经理或者会议组织者,避免浪费大家的事情。


总之,我们工作中的责任是一点点增加的,负责任的态度和习惯,也是从平时工作中一件件事情中养成的。形成这样的习惯,成为一个受人信任的人,是我们在职场中要培养的重要品质。



作者:技术修行者
来源:juejin.cn/post/7214435063181623351

收起阅读 »

从小到大为何一谈学习就愁眉苦脸

谈“学习”色变 从小到大,我们总有许多东西要学习:除了各个科目如数学、英语的学习,还有一些被爸妈安排学习的舞蹈、钢琴等等。 总之,我们必须持续不断地学习,如果不学习就会学习成绩落后,别无他法。 因此,谈到“学习”这个词,总会可能给人一些不舒服的感觉,不信你看看...
继续阅读 »

谈“学习”色变


从小到大,我们总有许多东西要学习:除了各个科目如数学、英语的学习,还有一些被爸妈安排学习的舞蹈、钢琴等等。


总之,我们必须持续不断地学习,如果不学习就会学习成绩落后,别无他法。


因此,谈到“学习”这个词,总会可能给人一些不舒服的感觉,不信你看看:


爷爷奶奶会说:“把电视关了,快去学习。”


老师会说:“你家孩子啥都好,就是不爱学习。”


每次我爸送我出门,也绝对不会忘记加一句“学习努力一点。”


工作了,老板会说:“一会开个会,组织学习。”公司安排各种培训,也是为了学习...


于是,“学习”这个词会带给我们一些不舒服的感觉,不是让我们想起年轻时埋头于做题参加各种考试的岁月,就是给人一种单调枯燥的“复读机”式的培训、会议之类的低质量教育活动。


其实以上种种都是被动学习,我们一开始就没体会到真正的学习。


羊浸式培训



羊浸(现实中)是指把毫无防备的羊浸到一个大水箱里面做清洗,去除它们身上的寄生虫。



羊排成一队,你抓起一只浸到水箱里,让它感受一次强烈的、陌生的、中毒性的清洗经历。但是,药性会逐渐失效,所以过段时间你不得不对它们再次做清洗。


这种模式,在公司可能会很流行,也称为羊浸式培训。比如召集一大堆不知情的员工,在一个陌生的环境中通过密集的方式,花三到五天的时间培训他们,然后培训完颁发一个证书,宣布他们获得了什么优秀头衔。但培训的效果会逐渐减弱,于是第二年必需再来一次“进修”课程。


说一个自己的故事吧。


在我刚进大一那会,就有学校的师兄师姐向我们推销英语学习,到后面才知道是在培训机构支持下的俱乐部。还加入了 4 天 3 夜的训练营,来自不同学校的学生聚集到一起,其中感受到各种励志的故事,觉得很受用,也的确获得了优秀营员的证书,发誓今后一定要改头换面,学好英语。


但是,现在我还是没能学好口语。是的,"羊浸式"培训不起作用。


殊不知,我们容易把受教育的过程,当做学习者被动接受的过程。我们会被灌输各种知识,而不是自己主动进行学习。


再说一个身边的例子,在我们大三的时候,面临找工作或者考研的两难问题时,培训机构出现了。某 Java 培训机构告诉我们,学 Java 非常好找工作,毫无编程基础也可以快速入门。只要学好基础,会点数据库和SQL基础,学会网络编程,编写 JSP 页面,就可以找到一份 Java 编程的工作了;如果再下点功夫,学习 Structs、Hibernate、Spring 等流行框架,就可以找到好工作了,至少月薪 10K ...。


在我们那个时候,这样的条件和出路是多么诱人。本来是不确定与迷茫的大三时期,突然有这么好的机会摆在面前,不抓住怎么能行?于是,专业半数以上的同学都选择了这个培训班。于是,一个专业通过突击一下子拥有了大片的 Java 开发人员,或者说“快餐式”程序员。


但是,几年下来。当我再问曾经上过培训班的同学,迫于无奈,好几个已经退出 Java 开发人员的队列了。


脱离学校教育之后,我们再谈“学习”


为什么羊浸式方法没有用呢?



  • 学习不是强加于你的,而应该是你主动做的事情

  • 仅仅掌握知识,而不是实践,没有用。要学以致用

  • 随机的方法,没有目标和反馈,往往会导致随机的结果


单纯密集的课堂教育最多只能给你正确的方向,而且紧紧掌握知识的提纲,并不会提高专业水平。


于是,我们不仅该问,脱离学校教育之后,我们该如何学习?


1、 你需要持续的详细目标。



  1. 无论是在职业生涯,还是个人生活中。为了学习和成长,需要设定一些目标,比如我要学好 Python、我要减肥。

  2. 但是这些目标有点泛,我们需要详细一点:什么时候开始学 Python,学好 Python 用来爬虫;要减多少斤才算减肥,是通过控制食量还是增加运动?


2、 持续的反馈以了解你的进展


比如,这个月减了多少斤,这个月学了哪些章节 Python 知识点,只有确切的数字带给我们真实的反馈。



你不必看清你去往何处,不必看清你的目的地和沿途的一切。你只需看清面前的一两米即可。



3 更加主动全面的学习目标


除了个人的目标,还可以指定更大背景下的目标:比如事业、家庭、财务...


将学习变成一件我们必须掌握的事情,用科学的方式。更多关于目标的制定方法可以参考 SMART 模型。


总结


脱离学校后,为了满足我们的兴趣和需求,主

作者:宇宙之一粟
来源:juejin.cn/post/7214427834994982971
动学习才是破解之道。

收起阅读 »

扒了一千多条关于文心一言的评论,我发现这届网友真的不好糊弄…

前两天,百度紧随GPT-4发布了自己的语言模型文心一言。 讲道理,对于国内能够发布这样一个敢于对标CHAT GPT的高质量语言模型,大家应该更多感受到的是赛博朋克与现实生活贴近的真实感,对这个模型应该有着更多的鼓励或赞美。 可不知是因为整个发布会搞的过于像没有...
继续阅读 »

前两天,百度紧随GPT-4发布了自己的语言模型文心一言。


讲道理,对于国内能够发布这样一个敢于对标CHAT GPT的高质量语言模型,大家应该更多感受到的是赛博朋克与现实生活贴近的真实感,对这个模型应该有着更多的鼓励或赞美。


可不知是因为整个发布会搞的过于像没有好好准备的学生毕业答辩PPT,还是它的实际表现并没有那么如人意,大家貌似对文心一言并不那么买账。


于是我决定看一下知乎大神们对文心一言的评价,哪想到随便打开一个问题,居然有600多条回答…



要是我这一条一条翻完所有回答, 估计就得拿出一天来全职摸鱼了 ,那么有没有什么办法能够最快的分析出对待这个问题大家的综合评价呢?


那么今天就让我纱布擦屁股,给大家露一小手,写一个爬虫扒下来所有的回答,再对结果进行一下分析。


WebMagic


正式开始前,咱们得先搞定工具。虽然python写起爬虫来有天然的框架优势,不过鉴于大家都是搞java的,那么我们今天就用java框架来实现一个爬虫。


咱们要使用的工具 WebMagic,就是一款简单灵活的java爬虫框架,总体架构由下面这几部分构成:




  • Downloader:负责从互联网上下载页面,以便后续处理。WebMagic默认使用了Apache HttpClient作为下载工具。

  • PageProcessor:负责解析页面,抽取有用信息,以及发现新的链接。WebMagic使用Jsoup作为HTML解析工具,并基于其开发了解析XPath的工具Xsoup。

  • Scheduler:负责管理待抓取的URL,以及一些去重的工作。WebMagic默认提供了JDK的内存队列来管理URL,并用集合来进行去重。也支持使用Redis进行分布式管理。

  • Pipeline:负责抽取结果的处理,包括计算、持久化到文件、数据库等。WebMagic默认提供了输出到控制台和保存到文件两种结果处理方案。


在4个主要组件中,除了PageProcessor之外,其他3个组件基本都可以复用。而我们实际爬虫中的重点,就是要针对不同网页进行页面元素的分析,进而定制化地开发不同的PageProcessor


下面我们开始准备实战,先引入webmagiccoreextension两个依赖,最新0.8.0版本搞里头:


<dependency>
<groupId>us.codecraftgroupId>
<artifactId>webmagic-coreartifactId>
<version>0.8.0version>
dependency>
<dependency>
<groupId>us.codecraftgroupId>
<artifactId>webmagic-extensionartifactId>
<version>0.8.0version>
dependency>

PageProcessor 与 xpath


在正式开始抓取页面前,我们先看看知乎上一个问题的页面是怎么构成的,还是以上面图中那个问题为例,原问题的地址在这里:



如何看待百度发布的文心一言?



我们先做个简单的测试,来获取这个问题的标题,以及对这个问题的描述


通过浏览器的审查元素,可以看到标题是一个h1的标题元素,并且它的class属性是QuestionHeader-title,而问题的描述部分在一个div中,它的class中包含了QuestionRichText



简单分析完了,按照前面说的,我们要对这个页面定制一个PageProcessor组件抽取信息,直接上代码。


新建一个类实现PageProcessor接口,并实现接口中的process()这个方法即可。


public class WenxinProcessor implements PageProcessor {
private Site site = Site.me()
.setRetryTimes(3).setSleepTime(1000);

@Override
public void process(Page page) {
String title = page.getHtml()
.xpath("//h1[@class='QuestionHeader-title']/text()").toString();
String question= page.getHtml()
.xpath("//div[@class='QuestionRichText']//tidyText()").toString();

System.out.println(title);
System.out.println(question);
}

public Site getSite() {
return site;
}

public static void main(String[] args) {
Spider.create(new WenxinProcessor())
.addUrl("https://www.zhihu.com/question/589929380")
.thread(2)
.run();
}
}

查看运行结果:



可以看到,在代码中通过xpath()这样一个方法,成功拿到了我们要取的两个元素。其实说白了,这个xpath也不是爬虫框架中才有的新玩意,而是一种XML路径语言(XML Path Language),是一种用来确定XML文档中某部分位置的语言。它基于XML的树状结构,提供在数据结构树中找寻节点的能力。


常用的路径表达式包括:


表达式描述
nodename选取此节点的所有子节点。
/从根节点选取。
//从匹配选择的当前节点选择文档中的节点,而不考虑它们的位置。
.选取当前节点。
..选取当前节点的父节点。
@选取属性。

在上面的代码中,//h1[@class='QuestionHeader-title']就表示选取一个类型为h1的节点,并且它有一个class为QuestionHeader-title的属性。


至于后面的text()tidyText()方法,则是用于提取元素中的文本,这些函数不是标准xpath中的,而是webMagic中特有的新方法,这些函数的使用可以参考文档:



webmagic.io/docs/zh/pos…



看到这,你可能还有个问题,这里对于问题的描述部分没有显示完全,你需要在页面上点一下这个显示全部它才会显示详细的信息。



没关系,这里先留个坑,这个问题放在后面解决。


获取提问的答案


我们完善一下上面的代码,尝试获取问题的解答。按照老套路,还是先分析页面元素再用xpath写表达式获取。修改process方法:


@Override
public void process(Page page) {
String contentPath= "div[@class='QuestionAnswers-answers']"+
"//div[@class='RichContent RichContent--unescapable']" +
"//div[@class='RichContent-inner']"+
"/tidyText()";
List answerList = page.getHtml().xpath(contentPath).all();
for (int i = 0; i < answerList.size(); i++) {
System.out.println("第"+(i+1)+"条回答:");
System.out.println(answerList.get(i)+"\n=======");
}
}

在上面的代码中,使用了xpath获取页面中具有相同属性的元素,并将它们存入了List列表中。看一下运行结果:



纳尼?这个问题明明有着689条的回答,为什么我们只爬到了两条答案?


如果你经常用知乎来学习摸鱼的话,其实就会知道对于这种有大量回答的问题,页面刚开始只会默认显示很少的几条的消息,随着你不断的下拉页面才会把新的回答显示出来。


那么如果我想拿到所有的评论应该怎么做呢?这时候就要引出webMagic中另一个神奇的组件Selenium了。


Selenium


简单来说,selenium是一个用于Web应用程序测试的工具,selenium测试可以直接运行在浏览器中,就像真正的用户在操作一样,并且目前主流的大牌浏览器一般都支持这项技术。


所以在爬虫中,我们可以通过编写模仿用户操作的selenium脚本,模拟进行一部分用互操作,比如点击事件或屏幕滚动等等。


WebMagic-Selenium需要依赖于WebDriver,所以我们先进行本地WebDriver的安装操作。


安装WebDriver


查看自己电脑上Chrome版本,可以点击设置->关于chrome查看,也可以直接在地址栏输入chrome://settings/help



可以看到版本号,然后需要下载对应版本的WebDriver,下载地址:



chromedriver.storage.googleapis.com/index.html



打开后,可以看到各个版本,选择与本地浏览器最接近的版本:



点击进入后,根据我们的系统选择对应版本下载即可。



下载完成后,解压到本地目录中,之后在使用selenium模块中会使用到。这个文件建议放在chrome的安装目录下,否则之后在代码中可能会报一个WebDriverException: unknown error: cannot find Chrome binary找不到chrome文件的错误。


修改Selenium源码


webMagic中已经封装了selenium模块的代码,但官方版本的代码有些地方需要修改,我们下载源码后要自己简单改动一下然后重新编译。我这下载了0.8.1-SNAPSHOT版本的代码,官方git地址:



github.com/code4craft/…



修改配置文件地址,在WebDriverPoolselenium配置文件路径写死了,需要改变配置路径:


// 修改前
// private static final String DEFAULT_CONFIG_FILE = "/data/webmagic/webmagic-selenium/config.ini";
// 修改后
private static final String DEFAULT_CONFIG_FILE = "selenium.properties";

resources目录下添加配置文件selenium.properties


# What WebDriver to use for the tests
driver=chrome
# PhantomJS specific config (change according to your installation)
chrome_driver_loglevel=DEBUG

js模拟页面操作


修改SeleniumDownloaderdownload()方法,在代码中的这个位置,作者很贴心的给我们留了一行注释:



意思就是,你可以在这添加鼠标事件或者干点别的什么东西了。我们在这添加页面向下滚动这一模拟事件,每休眠2s就向下滚动一下页面,一共下拉20次:


//模拟下拉,刷新页面
for (int i=0; i < 20; i++){
System.out.println("休眠2s");
try {
//滚动到最底部
((JavascriptExecutor)webDriver)
.executeScript("window.scrollTo(0,document.body.scrollHeight)");
//休眠,等待加载页面
Thread.sleep(2000);
//往回滚一点,否则不加载
((JavascriptExecutor)webDriver)
.executeScript("window.scrollBy(0,-300)");
} catch (InterruptedException e) {
e.printStackTrace();
}
}

修改完成后本地打个包,注意还要修改一下版本号,改成和发行版的不同即可,我这里改成了0.8.1.1-SNAPSHOT


mvn clean install

调用


回到之前的爬虫项目,引入我们自己打好的包:


<dependency>
<groupId>us.codecraftgroupId>
<artifactId>webmagic-seleniumartifactId>
<version>0.8.1.1-SNAPSHOTversion>
dependency>

修改之前的主程序启动时的代码,添加Downloader组件,SeleniumDownloader构造方法的参数中传入我们下好的chrome的webDriver的可执行文件的地址:


public static void main(String[] args) {
Spider.create(new WenxinProcessor())
.addUrl("https://www.zhihu.com/question/589929380")
.thread(2)
.setDownloader(new SeleniumDownloader("D:\\Program Files\\Google\\Chrome\\Application\\chromedriver.exe")
.setSleepTime(1000))
.run();
}

进行测试,可以看到在拉动了40秒窗口后,获取到的答案条数是100条:



通过适当地添加下拉页面的循环的次数,我们就能够获取到当前问题下的全部回答了。


另外,在启动爬虫后我们会看到webDriver弹出了一个chrome的窗口,在这个窗口中有一个提示:Chrome正受到自动测试软件的控制,并且可以看到页面不断的自动下拉情况:



如果不想要这个弹窗的话,可以修改selenium模块的代码进行隐藏。修改WebDriverPoolconfigure()方法,找到这段代码:


if (driver.equals(DRIVER_CHROME)) {
mDriver = new ChromeDriver(sCaps);
}

添加一个隐藏显示的选项,并且在修改完成后,重新打包一下。


if (driver.equals(DRIVER_CHROME)) {
ChromeOptions options=new ChromeOptions();
options.setHeadless(true);
mDriver = new ChromeDriver(options);
}

获取问题详细描述


不知道大家还记不记得在前面还留了一个坑,我们现在获取到的对问题的描述是不全的,需要点一下这个按钮才能显示完全。



同样,这个问题也可以用selenium来解决,在我们下拉页面前,加上这么一个模拟点击事件,就可以获得对问题的详细描述了:


((JavascriptExecutor)webDriver)
.executeScript("document.getElementsByClassName('Button QuestionRichText-more')[0].click()");

看一下执行结果,已经可以拿到完整内容了:



Pipeline


到这里,虽然要爬的数据获取到了,但是要进行分析的话,还需要进行持久化操作。在前面的webMagic的架构图中,介绍过Pipeline组件主要负责结果的处理,所以我们再优化一下代码,添加一个Pipeline负责数据的持久化。


由于数据量也不是非常大,这里我选择了直接存入ElasticSearch中,同时也方便我们进行后续的分析操作,ES组件我使用的是esclientrhl,为了方便我还是把项目整个扔到了 spring里面。


定制一个Pipeline也很简单,实现Pipeline接口并实现里面的process()接口就可以了,通过构造方法传入ES持久化层组件:


@Slf4j
@AllArgsConstructor
public class WenxinPipeline implements Pipeline {
private final ZhihuRepository zhihuRepository;

@Override
public void process(ResultItems resultItems, Task task) {
Map map = resultItems.getAll();
String title = map.get("title").toString();
String question = map.get("question").toString();
List answer = (List) map.get("answer");

ZhihuEntity zhihuEntity;
for (String an : answer) {
zhihuEntity = new ZhihuEntity();
zhihuEntity.setTitle(title);
zhihuEntity.setQuestion(question);
zhihuEntity.setAnswer(an);
try {
zhihuRepository.save(zhihuEntity);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

把selenium向下拉取页面的次数改成200后,通过接口启动程序:


@GetMapping("wenxin")
public void wenxin() {
new Thread(() -> {
Request request = new Request("https://www.zhihu.com/question/589929380");
WenxinProcessor4 wenxinProcessor = new WenxinProcessor4();
Spider.create(wenxinProcessor)
.addRequest(request)
.addPipeline(new WenxinPipeline(zhihuRepository))
.setDownloader(new SeleniumDownloader("D:\\Program Files\\Google\\Chrome\\Application\\chromedriver.exe")
.setSleepTime(1000))
.run();
}).start();
}

运行完成后,查询一下es中的数据,可以看到,实际爬取到了673条回答。



另外,我们可以在一个爬虫程序中传入多个页面地址,只要页面元素具有相同的规则,那么它们就能用相同的爬虫逻辑处理,在下面的代码中,我们一次性传入多个页面:


Spider.create(new WenxinProcessor4())
.addUrl(new String[]{"https://www.zhihu.com/question/589941496",
"https://www.zhihu.com/question/589904230","https://www.zhihu.com/question/589938328"})
.addPipeline(new WenxinPipeline(zhihuRepository))
.setDownloader(new SeleniumDownloader("D:\\Program Files\\Google\\Chrome\\Application\\chromedriver.exe")
.setSleepTime(1000))
.run();

一顿忙活下来,最终扒下来1300多条数据。



分析


数据落到了ES里后,那我们就可以根据关键字进行分析了,我们先选择10个负面方向的词语进行查询,可以看到查到了403条数据,将近占到了总量的三分之一。



再从各种回答中选择10个正向词语查询,结果大概只有负面方向的一半左右:



不得不说,这届网友真的是很严厉…


Proxy代理


说到爬虫,其实还有一个绕不过去的东西,那就是代理。


像咱们这样的小打小闹,爬个百八十条数据虽然没啥问题,但是如果要去爬取大量数据或是用于商业,还是建议使用一下代理,一方面能够隐藏我们的IP地址起到保护自己的作用,另一方面动态IP也能有效的应对一些反爬策略。


个人在使用中,比较推荐的是隧道代理。简单的来说,如果你购买了IP服务的话,用普通代理方式的话需要你去手动请求接口获取IP地址,再到代码中动态修改。而使用隧道代理的话,就不需要自己提取代理IP了,每条隧道自动提取并使用代理IP转发用户请求,这样我们就可以专注于业务了。


虽然网上也有免费的代理能够能用,但要不然就是失效的太快,要不就是很容易被网站加入黑名单,所以如果追求性能的话还是买个专业点的代理比较好,虽然可能价格不那么便宜就是了。


题外话


看了一大顿下来,从大家略显犀利的言辞来看,大家总体上对文心一言还是不那么满意的。毕竟,在有着CHAT-GPT这么一个优秀的产品做背景板的前提下,这届网友可能没有那么好糊弄。


但是话又说回来,丑媳妇总得见公婆不是?提早暴露缺陷,也有利于国内的这些大厂,看清和一流AI产品之间的真实差距,知难而进,迎头赶上。


那么,这次的分享就到这里,我是Hydra,我们下篇再见。



文中代码已经传到我的git上啦,github.com/trunks2008/… ,欢迎大家来个star鼓励一下~




参考资料:


webmagic.io/docs/zh/pos…


blog.csdn.net/panchang199…





作者:码农参上
来源:juejin.cn/post/7213384511890800696
收起阅读 »

使用 Kotlin 委托,拆分比较复杂的 ViewModel

需求背景 在实际的开发场景中,一个页面的数据,可能是由多个业务的数据来组成的。 使用 MVVM 架构进行实现,在 ViewModel 中存放和处理多个业务的数据,通知 View 层刷新 UI。 传统实现 比如上面的例子,页面由3 个模块数据构成。 我们可...
继续阅读 »

需求背景




  1. 在实际的开发场景中,一个页面的数据,可能是由多个业务的数据来组成的。

  2. 使用 MVVM 架构进行实现,在 ViewModel 中存放和处理多个业务的数据,通知 View 层刷新 UI。


传统实现


比如上面的例子,页面由3 个模块数据构成。


我们可以创建一个 ViewModel ,以及 3个 LiveData 来驱动刷新对应的 UI 。


    class HomeViewModel() : ViewModel() {

private val _newsViewState = MutableLiveData<String>()
val newsViewState: LiveData<String>
get() = _newsViewState

private val _weatherState = MutableLiveData<String>()
val weatherState: LiveData<String>
get() = _weatherState

private val _imageOfTheDayState = MutableLiveData<String>()
val imageOfTheDayState: LiveData<String>
get() = _imageOfTheDayState

fun getNews(){}
fun getWeather(){}
fun getImage(){}

}

这样的实现会有个缺点,就是随着业务的迭代,页面的逻辑变得复杂,这里的 ViewModel 类代码会变复杂,变得臃肿。


这个时候,就可能需要考虑进行拆分 ViewModel


一种实现方法,就是直接简单地拆分为3个 ViewModel,每个 ViewModel 处理对应的业务。但是这样会带来其他问题,就是在 View 层使用的时候,要判断当前是什么业务,然后再去获取对应的ViewModel,使用起来会比较麻烦。


优化实现


目标:



  • 将 ViewModel 拆分成多个子 ViewModel,每个子 ViewModel 只关注处理自身的业务逻辑

  • 尽量考虑代码的可维护性、可扩展性


Kotlin 委托



  • 委托(Delegate)是 Kotlin 的一种语言特性,用于更加优雅地实现代理模式

  • 本质上就是使用了 by 语法后,编译器会帮忙生成相关代码。

  • 类委托: 一个类的方法不在该类中定义,而是直接委托给另一个对象来处理。

  • 基础类和被委托类都实现同一个接口,编译时生成的字节码中,继承自 Base 接口的方法都会委托给BaseImpl 处理。


// 基础接口
interface Base {
fun print()
}

// 基础对象
class BaseImpl(val x: Int) : Base {
override fun print() { print(x) }
}

// 被委托类
class Derived(b: Base) : Base by b

fun main(args: Array<String>) {
val b = BaseImpl(10)
Derived(b).print() // 最终调用了 Base#print()
}

具体实现


定义子 ViewModel 的接口,以及对应的实现类


    interface NewsViewModel {
companion object {
fun create(): NewsViewModel = NewsViewModelImpl()
}

val newsViewState: LiveData<String>

fun getNews()
}

interface WeatherViewModel {
companion object {
fun create(): WeatherViewModel = WeatherViewModelImpl()
}

val weatherState: LiveData<String>

fun getWeather()
}

interface ImageOfTheDayStateViewModel {
companion object {
fun create(): ImageOfTheDayStateViewModel = ImageOfTheDayStateImpl()
}

val imageState: LiveData<String>

fun getImage()
}

class NewsViewModelImpl : NewsViewModel, ViewModel() {
override val newsViewState = MutableLiveData<String>()

override fun getNews() {
newsViewState.postValue("测试")
}
}

class WeatherViewModelImpl : WeatherViewModel, ViewModel() {
override val weatherState = MutableLiveData<String>()

override fun getWeather() {
weatherState.postValue("测试")
}
}

class ImageOfTheDayStateImpl : ImageOfTheDayStateViewModel, ViewModel() {
override val imageState = MutableLiveData<String>()

override fun getImage() {
imageState.postValue("测试")
}
}


  • 把一个大模块,划分成若干个小的业务模块,由对应的 ViewModel 来进行处理,彼此之间尽量保持独立。

  • 定义接口类,提供需要对外暴漏的字段和方法

  • 定义接口实现类,内部负责实现 ViewModel 的业务细节,修改对应字段值,实现相应方法。

  • 这种实现方式,就不需要像上面的例子一样,每次都要多声明一个带划线的私有变量。并且可以对外隐藏更多 ViewModel 的实现细节,封装性更好


组合 ViewModel


image.png


    interface HomeViewModel : NewsViewModel, WeatherViewModel, ImageOfTheDayStateViewModel {
companion object {
fun create(activity: FragmentActivity): HomeViewModel {
return ViewModelProviders.of(activity, object : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return if (modelClass == HomeViewModelImpl::class.java) {
@Suppress("UNCHECKED_CAST")

val newsViewModel = NewsViewModel.create()
val weatherViewModel = WeatherViewModel.create()
val imageOfTheDayStateImpl = ImageOfTheDayStateViewModel.create()

HomeViewModelImpl(
newsViewModel,
weatherViewModel,
imageOfTheDayStateImpl
) as T
} else {
modelClass.newInstance()
}

}
}).get(HomeViewModelImpl::class.java)
}
}
}

class HomeViewModelImpl(
private val newsViewModel: NewsViewModel,
private val weatherViewModel: WeatherViewModel,
private val imageOfTheDayState: ImageOfTheDayStateViewModel
) : ViewModel(),
HomeViewModel,
NewsViewModel by newsViewModel,
WeatherViewModel by weatherViewModel,
ImageOfTheDayStateViewModel by imageOfTheDayState {

val subViewModels = listOf(newsViewModel, weatherViewModel, imageOfTheDayState)

override fun onCleared() {
subViewModels.filterIsInstance(BaseViewModel::class.java)
.forEach { it.onCleared() }
super.onCleared()
}
}


  • 定义接口类 HomeViewModel,继承了多个子 ViewModel 的接口

  • 定义实现类 HomeViewModelImpl,组合多个子 ViewModel,并通过 Kotlin 类委托的形式,把对应的接口交给相应的实现类来处理

  • 通过这种方式,可以把对应模块的业务逻辑,拆分到对应的子 ViewModel 中进行处理

  • 如果后续需要新增一个新业务数据,只需新增相应的子模块对应的 ViewModel,而无需修改其他子模块对应的 ViewModel。

  • 自定义 ViewModelFactory,提供 create 的静态方法,用于外部获取和创建 HomeViewModel。


使用方式



  • 对于 View 层来说,只需要获取 HomeViewModel 就行了。

  • 调用暴露的方法,最后会委托给对应子 ViewModel 实现类进行处理。


        val viewModel = HomeViewModel.create(this)

viewModel.getNews()
viewModel.getWeather()
viewModel.getImage()

viewModel.newsViewState.observe(this) {

}
viewModel.weatherState.observe(this) {

}
viewModel.imageState.observe(this) {

}

扩展



  • 上面的例子,HomeViewModel 下面,可以由若干个子 ViewMdeol 构成。

  • 随着业务拓展,NewsViewModel、WeatherViewModel、ImageOfTheDayStateViewModel,也可能是分别由若干个子 ViewModel 构成。那也可以参照上面的方式,进行实现,最后会形成一棵”ViewModel 树“,各个节点的 ViewModel 负责处理对应的业务逻辑。


image.png


总结


这里只是提供一种拆分 ViewModel 的思路,在项目中进行应用的话,可以根据需要进行改造。


参考文章


slicing-your-viewmodel-with-delegates


Kotlin | 委托机制 & 原理 & 应用 - 掘金


作者:入魔的冬瓜
来源:juejin.cn/post/7213257917254860861
收起阅读 »

写给Android工程师的协程指南

这是一份写给 Android工程师 的协程指南,希望在平静的2023,给大家带来一些本质或者别样的理解。 引言 在 Android 的开发世界中,关于 异步任务 的处理一直不是件简单事。 面对复杂的业务逻辑,比如多次的异步操作,我们常常会经历回调嵌套的情况,对...
继续阅读 »



这是一份写给 Android工程师 的协程指南,希望在平静的2023,给大家带来一些本质或者别样的理解。


引言


Android 的开发世界中,关于 异步任务 的处理一直不是件简单事。


面对复杂的业务逻辑,比如多次的异步操作,我们常常会经历回调嵌套的情况,对于开发者而言,无疑苦不堪言。😟


Kotlin协程 出现之后,上述问题可以说真正意义上得到了好的解法。其良好的可读性及api设计,使得无论是新手还是老手,都能快速享受到协程带来的舒适体验。


但越是使用顺手的组件,背后也往往隐藏着更复杂的设计。


故此,在本篇,我们将由浅入深,系统且全面的聊聊 Kotlin协程 的思想及相关问题,从而帮助大家更好的理解。



本篇没有难度定位、更多的是作为一个 Kotlin 使用者的基本技术铺垫。



ps: 在B站也有视频版本,结合观看,体验更佳,Android Kotlin 协程分享


写在开始


大概在三年前,那时的我实习期间刚学会 Kotlin ,意气风发,协程Api 调用的也是炉火纯青,对外自称api调用渣渣工程师。


那时候的客户端还没这么饱和,也不像现在这样稳定。


那个时期,曾探寻过几次 Kotlin协程 的设计思想,比如看霍老师、扔物线视频、相关博客等。


但看完后处于一种,懂了,又似乎不是很懂的状态,就一直迷迷糊糊着。


记得后来去面试,有人问我,协程到底是什么?



我回答: 一个在 Kotlin 上以 同步方式写异步代码 的线程框架,底层是使用了 线程池+状态机 的概念,诸如此类,巴拉巴拉。


面试官: 那它到底和线程池有啥区别,我为啥不直接用线程池呢?


我心想:上面不是已经回答了吗,同步方式,爽啊!… 但奈何遭到了一顿白眼。


事后回想,他可能想问的是更深层,多角度的解释,但显然我只停留在使用层次,以及借着别人的几句碎片经验,冠冕堂皇、看似Easy。



直到现在为止,我仍然没有认真去看过协程的底层实现,真是何其的尴尬,再次想起,仍觉不安。


随着近几年对协程的使用以及一些cv经验,相关的api理解也逐渐像那么回事,也有些对Kt代码背后实现进行同步转换的经验。


故此,这篇文章也是对自己三年来的一份答卷。


当然网上对于协程的解析也有很多,无论是从原理或是顶层抽象概括,其中更是不乏优秀的文章与作者。


本文会尽量在这两者中间找到一个合适的折中点,并增加一些特别思考,即不缺深度,又能使初学者对于协程能够有较清晰明了的认知。


好了,让我们开始吧! 🏃🏻


基础铺垫


在开始之前,我们先对基础做一些铺垫,从而便于更好的理解 Kotlin协程


线程


我们知道,线程是 cpu调度 的最小单元,每个cpu所能启动的线程数量往往也是有限的。


在常见的业务开发中,尽管大多数时候我们都是基于单线程,或者最多开启子线程去请求网络,与多线程的 [多] 似乎关系不大。但其实这也属于多线程的一种,不过是少任务的情况。但就算这样,线程在执行时的切换,也是存在这一些小成本,比如从主线程切到子线程去执行异步计算,完成后再从子线程切到主线程去执行UI操作,而这个切换的过程在学术上又被称之为 [上下文切换]


协程


在维基百科中,是这样解释的:



协程是计算机程序的一类组件,推广了协作式多任务子例程,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务异常处理事件循环迭代器无限列表管道



上面这些词似乎拆开都懂,但连在一起就不懂了。


说的通俗一点就是,协程指的是一种特殊的函数,它可以在执行到某个位置时 暂停 ,并 保存 当前的执行状态,然后 让出 CPU控制权,使得其他代码可以继续执行。当CPU再次调用这个函数时,它会从上次暂停的位置继续执行,而不是从头开始执行。从而使得程序在执行 长时间任务 时更加高效和灵活。


协作式与抢占式


这两个概念通常用于描述操作系统中多任务的处理方式。



  • 协作式指的是 多个任务共享CPU时间 ,并且在没有主动释放CPU的情况下,任务不会被强制中断。相应的,在协作式多任务处理中,任务需要自己决定何时放弃CPU,否则将影响其他任务的执行。

  • 抢占式指的是操作系统可以在没有任务主动放弃CPU的情况下,强制中断 当前任务,以便其他任务可以获得执行。这也就意味着,抢占式多任务通常是需要硬件支持,以便操作系统可以在必要时强制中断任务。


如果将上述概念带入到协程与线程中,当一个线程执行时,它会一直运行,直到被操作系统强制中断或者自己放弃CPU;而协程的协作式则需要协程之间互相配合协作,以便让其他协程也可以获得执行机会,通常情况下,这种协作关系是由应用层(开发者)自行控制。也就意味着相比线程,协程的切换与创建开销比较小,因为其并不需要多次的上下文切换,或者说,线程是真实的操作系统内核线程的隐射,而协程只是在应用层调度,故协程的切换与创建开销比较小。


协程与线程的区别



  • 线程是操作系统调度的基本单位,一个进程可以拥有多个线程,每个线程独立运行,但它们共享进程的资源。线程切换的开销较大,且线程间的通信需要通过共享内存或消息传递等方式实现,容易出现资源竞争、死锁等问题。

  • 协程是用户空间下的轻量级线程,也称为“微线程”。它不依赖操作系统的调度,而是由用户自己控制协程的执行。协程之间的切换只需要保存和恢复少量的状态,开销较小。协程通信和数据共享的方式比线程更加灵活,通常使用消息传递或共享状态的方式实现。

  • 简单来说,协程是一种更加高效、灵活的并发处理方式,但需要用户 自己控制执行流程和协程间的通信 ,而线程则由操作系统负责调度,具有更高的并发度和更强的隔离性,但开销较大。在不同的场景下,可以根据需要选择使用不同的并发处理方式。


那Kotlin协程呢?


在上面,我们说了 线程协程 ,但这个协程指的是 广义协程 这个概念,而不是 Kotlin协程 ,那如果回到 Kotlin协程 呢?


相信不少同学在学习 Kotlin协程 的时候,常常会看到很多人(包括官网)会将线程与协程拉在一起比较,或者经常也能看见一些实验,比如同时启动10w个线程与10w个协程,然后从结果上看两者差距巨大,线程看起来性能巨差,协程又无比的优秀。



此时就会有同学喊,你上个线程池与协程试试啊!用线程试谈什么公平(很有道理)😂。


ps: 如果你真的使用了线程池并且使用了schedule代替Thread.sleep(),会发现,线程比协程显然要更快。当然,这也并不难理解。



那协程到底是什么呢?它和线程池的区别呢?或者说协程的职责呢?


这里我们用 Android官方 的一句话来概括:



协程是一种并发设计模式,您可以在 Android 平台上使用它来 简化 异步执行的代码。协程是我们在 Android 上进行异步编程的推荐解决方案。



简单明了,协程就是用于 Android 上进行 异步编程 的推荐解决方案,或者说其就是一个 异步框架 ,仅此而已,别无其他🙅🏻‍♂️。


那有些同学可能要问了,异步框架多了,为什么要使用协程呢?


因为协程的设计更加先进,比如我们可以同步代码写出类似异步回调的逻辑。这一点,也是Kotlin协程在Android平台最大的特点,即 简化异步代码


相应的,Kotlin协程 具有以下特点:



  • 轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。挂起比阻塞节省内存,且支持多个并行操作。

  • 内存泄漏更少:使用结构化并发机制在一个作用域内执行多项操作。

  • 内置取消支持取消操作会自动在运行中的整个协程层次结构内传播。

  • Jetpack 集成:许多 Jetpack 库都包含提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供您用于结构化并发。



上述特点来自Android官网-Android上的Kotlin协程



协程进展



:如非特别标注,本文接下来的协程皆指Kotlin协程。



本小节,我们将看一下Kotlin协程的发展史,从而为大家解释kotlin协程的背景。


image-20230220152147904


Kotlin1.6 之前,协程的版本通常与 kotlin 版本作为对应,但是 1.6 之后,协程的大版本就没有怎么更新了(目前最新是1.7.0-beta),反而是 Kotlin 版本目前最新已经 1.8.10


基本示例


在开始之前,我们还是用一个最基本的示例看一下协程与往常回调写法的区别,在哪里。



比如,我们现在有这样一个场景,需要请求网络,获取数据,然后显示到UI中。



回调写法


fun main() {
// 示例,一般为线程池
thread(name="t1") {
val message = getMessage()
// 或者其他切线程方式,底层都是这样,handler复用
val handler = Handler(Looper.getMainLooper())
handler.post {
showMessage(message)
}
}
}

fun getMessage(): String {
Thread.sleep(1000)
return "123"
}

如上所示,创建了一个线程t1,并在其中调用了 getMessage() 方法,该方法我们使用 Thread.sleep() 模拟网络请求,然后返回一个String数据, 最后使用 handler 将当前要执行的任务发送到主线程去执行从而实现线程切换。


协程写法


fun main() {
val coroutineScope = CoroutineScope(Dispatchers.Main)
coroutineScope.launch {
val message = getMessages()
showMessage(message)
}
}

suspend fun getMessages(): String {
return withContext(Dispatchers.IO) {
delay(1000)
"123"
}
}

如上所示,创建了一个协程作用域,并启动了一个新的子协程c1,该协程内部调用了 getMessages() 方法,用于获得一个 String类型 的消息。然后调用 showMessage() 方法,显示刚才获取的消息。在相应的 getMessages() 方法上,我们增加了 suspend 标记,并在内部使用withContext(Dispatcher.IO) 将当前上下文环境切换到IO协程中,用于延迟等待(假设网络请求),最终返回该结果。



在不谈性能的背景下,上述这两种方式,无疑是协程的代码更加直观简洁,毕竟同步的写法去写异步,这没什么可比性,当然我们也允许部分的性能损失。



挂起与恢复


站在初学者的视角,当聊到挂起与恢复,开发者到底想了解什么?


什么是挂起恢复?挂起是挂起什么?挂起线程吗?还是挂起一个函数?恢复又是具体指什么?又是如何做到恢复的呢?


基础概念


在标准的解释中,如下所示:



在协程中,当我们的代码执行到某个位置时,可以使用特定的关键字来暂停函数的执行,同时保存函数的执行状态,这个过程叫做 [挂起],挂起操作会将控制器交还给调用方,调用方可以继续执行其他任务。


当再次调用被挂起的函数时,它会从上一次暂停的位置开始继续执行,这个过程称为 [恢复]。在恢复操作之后,被挂起的函数会继续执行之前保存的状态,从而可以在不重新计算的情况下继续执行之前的逻辑。



如果切换到 Kotlin 的世界中中,这个特定的关键字就是 suspend 。但并不是说加了这个关键字就一定会挂起,suspend 只是作为一个标记,用于告诉编译器,该函数可能会挂起并暂停执行(即该函数可能会执行耗时操作,并且好事期间会暂停执行并等待耗时操作完成,同时需要将控制权返回给调用方),但至于要不要挂起及保存函数当前的执行状态,最终还是要取决于函数内部是否满足条件。


如下所示,我们用一个示例Gif(出处已找不到)来表示:


img


那用程序员的语言该怎么理解呢?我们用一段代码举例:


coroutineScope.launch(Dispatchers.Main) {
val message = getNetMessages()
showMessage(message)
}

suspend fun getNetMessages(): String {
return withContext(Dispatchers.IO) {
delay(1000)
"123"
}
}


  • 当我们的程序运行到 coroutineScope.launch(Dispatchers.Main) 时,此时会创建一个新协程,并将这个协程放入默认的协程调度器(即Main调度器),同时当前新创建的协程也会成为 coroutineScope 的子协程。

  • 当执行到 getNetMssage() 方法时,此时遇到了 withContext(Dispatchers.IO) ,此时会切换当前协程的上下文到IO调度器(可以理解将当前协程放入IO线程池中执行),此时协程将被挂起,然后我们当前 withContext() 被挂起的状态会通知给外部的调用者,并将当前的状态保存到协程的上下文中,直到IO操作完成。

    • 当遇到 delay(1000) 时,此时再次挂起(这里不是切换线程,而是使用了协程的调度算法),并保存当前的函数状态;

    • delay(1000) 结束后,再次恢复到先前所在的IO调度器,并开始返回 “123”;

    • 当上述逻辑执行完成后,此时 withContext() 会将协程的调度器再次切换到之前开始时的调度器(这里是Main),并恢复之前的函数状态;



  • 此时我们获得了 getNetMssage() 的返回值,继续执行 showMessage()


挂起函数


在上面我们聊到了 Kotlin 的挂起函数,与相关的 挂起恢复 。那 suspend 标志到底做了什么呢?


本小节,我们将就这个问题,从字节码层,展开分析。


我们先看一下 suspend 方法是如何被编译器识别的?如下代码所示:


image-20230304225541849


不难发现,我们带有suspend的函数最终会被转变为一个带 Continutaion 参数,并且返回值为Object(可null)的函数。



上述示例中,原函数没带返回值,你也可以使用带返回值的原函数,结果也是与上述一致。



1. Continucation 是什么?为什么要携带它呢?


在前文中,我们已经提及,suspend 只是一个标志,它的目的是告诉编译器可能会挂起,类似与我们开发中常使用的注解一样,但又比注解更加强大,suspend 标志是编译器级别,而注解是应用级别。从原理上来看,那最终的代码运行时应该怎么记住这些状态呢,或者怎么知道这个方法和其他方法不一样?故此,kotlin编译器 会对带有 suspend 的方法在最终的字节码生成上进行额外更改,这个过程又被称作 CPS转换 (下面会再解释),如下所示:


suspend fun xx()
->
Object xx(Continucation c)

在字节码中,我们原有的函数方法参数中会再增加一个 Continucation ,而 Continuation 就相当于一个参数传递的纽带(或者你也可以理解其就是一个 CallBack ),负责保存函数的执行状态、执行 挂起与恢复 操作,具体如下:


public interface Continuation<in T> {
public val context: CoroutineContext

public fun resumeWith(result: Result<T>)
}

context 参数类似于 Android 开发中的 context 一样,其代表了当前的配置,对使用协程的同学而言,context就相当于当前协程所运行的环境与参数 ,而 resumeWith() 则是负责对我们函数方法进行挂起与恢复(这块我们先这样理解即可)。




1 什么是CPS转换?



CPS(Continuation Passing Style)转换是一种将函数转换为回调函数的编程技术。在 CPS 转换中,一个函数不会像通常那样直接返回结果,而是接受一个额外的回调函数作为参数,用于接收函数的结果。这个回调函数本身也可能接受一个回调函数,形成一个连续的回调链。这种方式可以避免阻塞线程,提高代码的并发性能。



比如,协程通过 CPS 转换来实现异步编程。具体来说,协程在被挂起时,会将当前的执行状态保存到一个回调函数(即挂起函数的 Continuation)中,然后将控制权交回给调用方。当协程准备好恢复时,它会从回调函数中取回执行状态,继续执行。这种方式可以使得异步代码的逻辑更加清晰和易于维护。




2. 为什么还要增一个 Object 类型返回值呢?


这块的直接解释比较麻烦,但是我们可以先思考一下,代码运行时,该怎么知道该方法真的被挂起呢?难道是增加了suspend就要被挂起吗?


故此,还是需要一个返回值,用于确定,该挂起函数是否真的被挂起。


在IDE中,对于使用了suspend的方法而言,如果内部没有其他挂起函数,那么编译器就会提示我们移除suspend标记,如下所示:


image-20230304225126237




3. 为什么返回值类型是Object?


对于挂起函数而言,在协程,是否真的被挂起,通过函数返回值来确定,但相应的,如果我们有挂起函数需要具备返回类型呢?那如果该函数没有挂起呢?如下示例所示:


image-20230304224957432


对于挂起函数而言,返回值有可能是 COROUTINE_SUSPENDEDUnit.INSTANCE 或者最终返回我们方法需要的返回类型结果,所以采用 Object 作为返回值以适应所有结果。


深入探索


在上面,我们看到了 suspend 在底层的转换细节,那回到挂起函数本质上,它到底是怎么做到 **挂起 ** 与 恢复 的呢?


故此,本小节,我们将就着这个问题,从字节码层次,展开分析,力求流程完整明了,不过相对而言可能有点繁琐。


如下代码所示:


fun main() = runBlocking {
val isSuccess = copyFileTo(File("old.mp4"), File("new.mp4"))
println("---copy:$isSuccess")
}


suspend fun copyFileTo(oldFile: File, newFile: File): Boolean {
val isCopySuccess = withContext(Dispatchers.IO) {
try {
oldFile.copyTo(newFile)
// 示例代码,通常这里需要验证字节流或者MD5
true
} catch (e: Exception) {
false
}
}
return isCopySuccess
}

这是一段用于将文件复制到指定文件的示例代码,具体伪字节码如下:


image-20230306214535952



上述的步骤实在是难读,思路整理起来比较绕圈,不过还是建议开发者多理解几遍。



上述的步骤如下:


当左侧 main() 方法开始执行时,因为示例中使用的 runBlocking(),其需要传递一个函数式接口对象,通常我们会以 lambda表达式 的形式去实例化这个函数对象,然后在其中写入我们的业务代码。


所以根据最终的字节码对比,我们的lambda会被转化为如下的形式:


suspend CoroutineScope.() -> Unit
⚡️ ->
(Function2) (new Function2((Continuation) null){}
// 具体伪代码如下所示,为什么会是这样的,下面会解释
class xxx(Continucation) : Function2<CoroutineScope,Continucation,Any> {
fun invoke(Any,Continucation) : Any {}
}

接着当我们的函数被调用时,会触发 invoke() 方法,即我们的函数体开始执行,开始进入我们的业务代码中。因为 invoke() 需要返回一个Object(因为我们的函数体本身也是suspend),这时候,会先创建一个 Continuation 对象,用于执行协程体逻辑,然后去调用 invokeSuspend() 方法从而获得本次的执行结果。



这里为什么要再去创建一个 Continuation?不是在runBlocking()里已经利用lambda表达式实例化了函数对象了吗?


不知道是否会有同学有这个疑问,所以这里依然需要解释一遍。


我们知道,在 kotlin 中,lambda 是匿名内部类的一种实例化方式(简化),所以这里只是给 runBlocking() 函数传递了所需要的方法参数。但是这个 lambda 内部的 invoke() 依然是挂起函数(因为增加过suspend),所以这里的匿名内部类实际上也是实现了 Continuation(默认的只有Funcation1,2,3等等),为了便于底层调用 invoke() 时传递 Continuation ,否则后续挂起恢复流程就断了🔺。相应的,为了延续 invoke() 里的挂起函数流程,编译器在当前匿名类内部又创建了一个 anonymous constructor(无类型) 的内部类(实际上是继承自SuspendLambda),从而在其 ivokeSuspend() 里执行当前挂起函数的状态机。


所以来说,大家可以理解我们传递的 lambda 相当于一个入口,但是其内部(即invoke)的触发方法,又是一个 挂起函数 ,这也就是为什么 invoke() 里需要创建 Continuation ,以及为什么 invoke() 方法参数里需要有 continuation 的原因,以及为什么字节码中会出现 new Function2((Continuation) null) ,Continuation 为null 的情况🤔,因为它压根没有 continuation 啊(不在挂起函数内部😂)。


这里的解释稍许有些啰嗦,但这对于理解全流程将非常有用,如果不是很理解,建议多读几遍。



invokeSuspend() 方法里,即正式进入了函数的状态机,这里的状态标记使用了一个 int 类型的 label 表示。



  • 默认执行 case 0,因为我们接下来要进入 copyFileTo() 方法,而该方法也是一个挂起函数,所以执行该方法后会获得一个返回状态,用于判断该函数是否真的已经挂起。如果返回值是 COROUTINE_SUSPENDED,则证明该函数已经挂起,然后直接 return 当前函数的挂起状态(相当于告诉父callback,当前我内部已经在忙了,你可以先执行自己的事了,等我执行完再通知你),否则继续执行当前剩余逻辑。

  • copyFileTo() 执行结束后,会再次触发当前 invokeSuspend(),因为我们在 case0 里已经更新了label为1,然后正常执行接下来的流程。


我们再去看一下 copyFileTo() 方法,我们在字节码中可以看到,其默认先创建了当前的 ContinuationImpl() ,并在初始化时将父 Continuation 也保存在其中,接着进入状态机开始执行逻辑,因为我们在该方法里有使用 withContext() 切换到IO调度器,所以这里也需要获取 withContext() 的挂起状态,如果成功挂起,则直接 return 当前状态,类似上述 invokeSuspend() 里的流程。


需要注意的,我们 withContext() 范围内,虽然经历了CPS转换,但因为不存在其他挂起函数,所以并不会再返回是否挂起,而是直到我们的逻辑执行结束 ,从而触发 withContext() 内部去调用 resumeWith(),从而恢复外部 copyFileTo() 的执行,重复此流程,从而恢复 runBlocking() 内部的继续执行,然后拿到我们的最终结果。


总结


关于Kotlin协程的挂起与恢复,从字节码去看,核心的 continuation 似乎有点像 callback 的嵌套,但相比 callback ,协程做的更加完善。比如当触发挂起函数调用时,会进入其内部对应的状态机,从而触发状态流转。并且为了避免了 callback 的 重复创建,而每一个挂起函数内部都会复用当前已创建好的 continuation


比如说,对于挂起函数,编译器会对其进行 CPS转换 ,从而使其从:


supend fun test()
->
fun test:(Continuation):Any?

当我们在挂起函数中调用该函数时,编译器就会将当前的 continuation 也一并传入并获得当前函数的结果。在具体调用时,如果挂起函数内部真的挂起(函数返回值为 COROUTINE_SUSPENDED ),则将调用权交还给调用方,然后当前的状态+1。而当该挂起函数内部执行结束时,因为其持有着外部的 continuation ,所以会调用 continuation.resume() 恢复挂起的协程,即调用了 invokeSuspend() ,从而恢复执行先前的逻辑。


而我们常说的状态机,从根本上,其实就是构造了一个 switch 结构的label流转,每个 case 内部都可能又会再对应着一个类似的结构(如果存在挂起函数)。如果我们称其为分层,那每一层也都持有上层的对象,而当我们最底层的函数执行结束时,即开始触发恢复上层逻辑,此时状态回传,从而将子函数的结果返回出去。


协程的矛与盾


当我们在讨论协程时,首先要明确,我们是在说 Kotlin协程 ,下述论点也都是基于这个背景下开始。



相应的,我们也需要一个参照物,如果直接对比线程,未免有些太过于不公平,所以我们选用 线程池协程 进行对比分析。



协程是线程框架吗?


Jvm 平台,因为 协程 底层离不开 Java线程模型 ,故最终的任务也是需要 线程池 最终去承载。所以从底层而言,我们可以通俗且大胆的认为协程就是一个线程框架,这没问题。


[但],这显然不是很合适,或者说,这有点过于糙了!


在文章开始,我们已经提过了,Android官方对其的描述:



协程是一种并发设计模式,您可以在 Android 平台上使用它来简化异步执行的代码。



所以,如果我们从协程本质与设计思想去看待,显然其相比线程池具有更高层次的编程模型,故此时称其为 异步编程框架 也许更为合适。具体原因与分析有如下几点:




  • 从编程模型而言


    协程与线程池两者都是用于处理异步任务或者耗时任务的工具,但两者的编程模型完全不同。线程池或者其他线程框架,往往使用回调函数来处理任务,这种方式常常比较繁琐,业务复杂时,代码可读性较差;而协程则是异步任务同步写法,基于挂起恢复的理念,由程序员自己控制执行顺序,可读性高;




  • 从异常的处理角度而言


    在线程池中,处理异常时,我们可以通过 tryCach 业务代码,或者可以在创建线程池时,自定义 ThreadFactory , 然后使用 Thread.setDefaultUncaughtExceptionHandler() 设置一个默认异常处理方式。相应的,协程通过 异常处理机制 来捕获和处理异常,相对于线程池而言,更加先进。




  • 从调度方式而言


    线程池通过创建一个固定数量的线程池来执行并发任务。每个任务将在一个可用的线程上运行,任务执行结束后,线程将返回线程池以供以后使用,并且通过在队列中等待任务来保持活动状态。如果使用协程,它并不创建新的线程,在jvm平台,其是利用少量的线程来实现并发执行,支持在单线程中执行,并使用 挂起与恢复 机制来允许并发执行。




协程性能很高?



先给结论,通常情况,协程的性能与线程池相差不大,甚至大多数常见场景,协程性能其实是不如直接使用线程池。



同时启动10w线程和协程


在协程官网,我们大概都能看到这样一句话,同时启动10w和线程和协程等等。


我们举个例子来看看,如下所示:


同时启动10w线程同时启动10w协程
image-20230319122629722image-20230319122642399

协程果然比线程快多了,那此时肯定就有同学说了,你拿协程欺负线程,咋不用线程池呢?


使用线程池替代线程


我们继续测试,这次改为线程池:


image-20230319122953566



线程池就是快啊!⚡️



如果你这样想,证明你可能理解错了🙅🏻‍♂️,我们这里只是往线程池里添加了10w个任务,因为我们用例里核心线程数是10,所以,同一时刻,只有10个任务在被处理,所以剩下的任务都在队列中等待。即这里打印的耗时仅仅只是上述代码的耗时,而不是线程池执行任务的总耗时,相比之下协程可是真真实实把10w个都跑完了,所以这两者根本没法比较。


所以我们对上面的逻辑进行更改,如下所示:


image-20230319123353502


总耗时…,没工夫等待了,不过我们可以大概算一下,总耗时16分钟多(10w/10*0.1/60)🤔。


为什么呢?明明底层都是线程池?


如果注意观察的话,线程的等待我们使用的是 sleep() ,而协程是 delay() ,两者的区别在于,前者是真真实实让我们的线程阻塞了指定时间,而后者则是语言级别,故差距很大。所以如果要做到相对公平,我们应该选用支持定时任务的线程池。


使用线程池模拟delay


为了保证相对公平,我们使用 ScheduledExecutorService ,并且将这个线程池转为协程的调度器。


结果如下:


添加10w个任务启动10w个协程
image-20230319131156258image-20230319131213967

???为什么线程池更快呢?😟


因为协程底层,最终任务还是需要我们的线程池来承载,但协程还需要维护自己的微型线程,而这个模型又是语言级别的控制,所以当协程代码转为字节码之后,即需要更多的代码才能实现。相比之下,线程池就简单直接很多,故这也是为什么线程池会快一点的原因。


场景推荐


通常情况下,我们真正耗时的任务都是IO网络 或者其他操作,所以此时协程的应用层的额外操作几乎并不影响大局。或者说面对复杂的异步场景是,此时性能也许并不是我们首先考虑,而如何更清晰的编码与封装实现,才是我们所更关心的。相应的,相比线程池,协程就很擅长这个处理异步任务。比如协程可以通过简化异步操作,也能在很大程度上,能避免我们不当的操作行为导致阻塞UI线程行为,从而提高应用性能。故在某个角度而言,协程的性能相比不恰当的使用线程池,是会更高。


所以如果我们的场景对性能有这极致要求,比如应用启动框架等,那么此时使用协程往往并不是最佳选择。但如果我们的场景是日常的业务开发,那么协程绝对是你的最佳选择。


协程的使用技巧


将协程设置为可取消


在协程中,取消属于协作操作,也就是说,当我们cancel掉某个job之后,相应的协程在挂起与恢复之前并不会立即取消(原因是协程的check时机是在我们状态机的每个步骤里),即也就是说,如果你有某个阻塞操作,协程此时并不会被取消。


如下所示:


image-20230319110607834


如上所示,我们会发现,当我们 cancel() 子协程后,我们的 readFile() 依然会正常执行。


要解释原理也非常简单:


因为 readFile() 并不是挂起函数,并且该方法内部也没有做协程 状态判断


在协程中,我们常用的函数 delay()withContext()ensureActive()yield() 等都提供了检查功能。


我们改动一下上述示例,如下所示:


image-20230319183838882image-20230319183911944

如上所示,我们在 readFile() 中增加了 yield() 方法,而当我们 cancel() 掉子协程时,当 Thread.sleep() 执行结束后,遇到 yield()时,该方法就会判断当前协程作用域是否已经不在活跃,如果满足条件,则直接抛出 CancellationException 异常。


协程的同步问题?


因为 Kotlin协程 是运行在 Java线程模型 基础之上,所以相应的,也存在 同步 问题。


在多线程的情况下,操作执行的顺序是不可预测的。与编译器优化操作的顺序不同,线程无法保证以特定的顺序运行,而上下文切换的操作随时有可能发生。所以如果在访问一个未经处理的状态时,线程很有可能就会访问到过时的数据,丢失必要的更新,或者遇到 资源竞争 等情况。


所以,使用了协程并且涉及可变状态的类必须采取措施使其可控,比如保证协程中的代码所访问的数据是最新的。这样一来,不同的线程之间就不会互相干扰。


如下示例:


image-20230314225515905


上述代码很简单,需要注意的是,为了防止 println() 先于我们的 repeat() 执行结束,我们使用measureTimeMillis()+coroutineScope() 进行嵌套,从而等待 coroutineScope() 内部所有子协程全部执行结束,才退出 measureTimeMillis()


不过从结果来看,不出意外的也存在同步问题,那该怎么解决?



按照Java开发中的习惯,我们可以使用 synchronized ,或者使用 AtomicInteger 管理sum。



常规方式解决


如下所示,我们选用 synchronized 来解决:


image-20230319111855237


如上所示,我们使用了 synchronized 对象锁来解决同步问题。



注意:这里我们锁的是 this@coroutineScope ,而不是 this ,前者代表着我们循环外的作用域对象,而直接使用this则代表了当前协程的作用域对象,并不存在竞争关系。



使用Mutex解决


除去传统的解决方式之外,Kotlin 中还增加了额外的辅助类去解决协程同步问题,其使用起来也更加简单,即 Mutex(互斥锁) ,这也是协程中解决同步问题的推荐方式。


如下示例:


image-20230314230330867


我们创建了一个 Mutex 对象,并使用其 加锁方法 withLock() ,从而避免多协程下的同步问题。相应的,Mutex 也提供了 lock()unLock() 从而控制对共享资源的访问(withLock()是这两者的封装)。


从原理上而言,Mutex 是通过 一个 AtomicInteger 类型的状态记录锁的状态(是否被占用),并使用一个 ConcurrentLinkedQueue 类型的队列来持有 等待持有锁 的协程,从而解决多个协程并发下的同步问题。


相比传统的 synchronized 阻塞线程,Mutex 内部使用了 CAS机制,并且支持协程的挂起恢复,其可扩展性,其都更具有优势;并且在协程的挂起函数中使用 synchronized,也可能会影响协程的正常调度和执行。故无论是上手难度及可读性,Mutex 无疑是更适合协程开发者的。


Mutex是性能的最佳选择吗?


在过往,我们提到 synchronized 都会觉得,它会直接阻塞线程,大家都会不约而同的推荐CAS作为更好的替代。但其实 synchronizedjdk1.6 之后,已经增加了各种优化,比如增加了各种锁去减缓直接加锁所导致的上下文切换耗时。


所以,我们对比一下上述的耗时:


image-20230319185125581image-20230319185132651

为什么 Mutex 的性能其实不如 synchronized 呢?


原因如下



  • Mutex 在处理并发访问时会产生额外的开销,由于 Mutex 是一个互斥锁,它需要操作系统层面的支持来实现,包括支持挂起和恢复、上下文切换和内核态和用户态之间的切换等操作,这些操作都需要较大的系统开销和时间,导致 Mutex 的性能较差。

  • synchronized 采用了一种更加灵活的方式来实现锁的机制,它会检查锁状态,如果没有被持有,则可以立即获取锁。如果锁被持有,则选择等待,或者继续执行其他的任务。从具体的实现上来说,synchronized 底层由jvm保证,在运行过程中,可能会出现偏向锁、轻量级锁、重量级锁等。关于 synchronized 相关的问题,大家也可以去看看我这篇文章 浅析 synchronized 底层实现与锁相关


最后,我们再看一下 KotlinFlow 中关于同步问题的解决方法:


image-20230319120056743


嗯,所以Mutex还要不要用了?🤨


如果我们把视线向上提一级,就会理解,当我们在选用 Kotlin 协程的时候,就已经选择了为了使用方便去容忍牺牲一部分性能。再者说,如果你的业务真的对性能要求极致,那么协程本身其实并不是首选推荐的,此时你应该选用线程池去处理,从而得到性能的最大化,因为协程本身的微型机制就需要做更多的额外操作。


再将视角切回到同步问题的处理上,Mutex 是协程中的推荐解决同步问题的方式,而且支持挂起与恢复,这点是其他同步解决方式无法具备的;再者说,Mutex 的上手难度相比 synchronized 低了不少。而至于性能上的差距,对于我们的业务开发而言,几乎是不会感知到,所以在协程中,Kotlin团队建议我们使用Mutex。


协程的异常处理方式


关于协程的异常处理,其实一直都不是一个简单事,或者说,优雅的处理异常并没那么简单。


在传统的原生的异常处理中,我们处理异常无在乎是这两种:



  • tryCatch();

  • Thread.setDefaultUncaughtExceptionHandler();


后者常用于非主线程的保底,前者用于几乎任何位置。


因为协程底层也是使用的java线程模型,所以上述的方式,在协程的异常处理中,同样有效,如下所示:


image-20230319163635334



上述的 runCatching() 是kotlin中对 tryCatch() 的一种封装。



使用CoroutineExceptionHandler


在协程中,官方建议我们使用 CoroutineExceptionHandler 去处理协程中异常,或者作为协程异常的保底手段,如下所示:


image-20230319164039472


我们定义了一个 CoroutineExceptionHandler,并在初始化 CoroutineScope 时将其传入,从而我们这个协程作用域下的所有子协程发生异常时都将被这个 handler 所拦截。



这里使用了 SupervisorJob() 的原因是,协程的异常是会传递的,比如当一个子协程发生异常时,它会影响它的兄弟协程与它的父协程。而使用了 SupervisorJob() 则意味着,其子协程的异常都将由其自己处理,而不会向外扩散,影响其他协程。



还有一点需要注意的是, CoroutineExceptionHandler 只能用于初始化 CoroutineScope 本身的初始化或者其直接子协程(即scope.launch),否则就算创建子协程时携带了 CoroutineExceptionHandler,也不会生效。


关于协程的异常处理,具体可以看我的这篇文章,里面有详细讲解:Kotlin | 关于协程异常处理,你想知道的都在这里


常见高阶函数


在开发中,有一些高阶函数,对我们特别有用,这里就将其列出来,以便大家开发中进行使用:


image-20230319190852334


如果你对上述的方法都非常了解,那不妨为自己鼓鼓掌。👏


总结


在本篇,我们着力于从全盘看起,理清 Kotlin协程 的方方面面。从 协程背景 到 挂起函数字节码实现,一瞥挂起与恢复的底层实现,从而体会其相应的设计魅力,并针对一些常见问题进行分析与解析,从而建立起协程彻底理解。文章中挂起函数部分的源码部分可能稍显繁琐,但依然建议大家多看几遍流程,从而更好理解。相应的细节问题,也都有详细注释。


最后,让我们再回到这个问题,协程到底是什么呢?



在JVM平台,Kotlin协程就是一个异步编程框架,它可以帮助我们简化异步代码,提升可读性,从而极大减少异步回调所带来的复杂逻辑。



从底层实现来看:



  • kotlin协程基于 java线程模型 ,故底层依然是使用了 线程池 作为任务承载,但相比传统的线程模型,协程在其基础上搭建了一套基于语言级别的 ”微型“ 线程模型。并定义了挂起函数作为相应的子任务,其内部采用了状态机的思想,用于实现协程中的挂起与恢复。

  • 在挂起与恢复的实现上,使用了 suspend 关键字标记的函数被称为挂起函数。其在字节码中,会经过 CPS转换 为一个带有 Continuation 参数,返回值为 Object 的方法。而 Continuation 正是用于保存我们的函数状态、步骤,从而实现挂起恢复,其内部也都包含着上一个 Continuation,正如 callback 的嵌套一样。

  • 当我们的函数被挂起时,我们当前的函数内部会实例化一个 ContinuationImpl() ,其内部 invokeSuspend() 又维护着当前的函数逻辑,并使用一个 label 作为状态进行流转,如果我们的函数内部依然有其他挂起函数,此时也会将当前的 Continuation 对象传入子挂起函数内部,从而实现 Continuation 的传递,并更改当前的函数状态。而当我们最底层的方法执行结束后,此时就会再次触发父 ContinuationImpl 内部的 invokeSuspend() 方法,从而回到调用方的逻辑内部,从而完成挂起函数的恢复。以此类推,直到我们最开始的调用方法内;


从性能上去看:



  • 协程的性能并不优于线程池或者其他异步框架,主要是其做了更多语言级别步骤,但通常情况下,与其他框架的性能几乎一致,因为相比IO的耗时,语言级别的损耗可以几乎忽略不计;


从设计模式去看:



  • 协程使得开发者可以自行管理异步任务,而不同于线程的抢占式任务,并且写成还支持子协程的嵌套关闭、更简便的异常处理机制等,故相比其他异步框架,协程的理念更加先进;


参照



关于我


我是 Petterp ,一个 Android工程师 ,如果本文对你有所帮助,欢迎 点赞、评论、收藏,你的支持是我持续创作的最大鼓励!



欢迎关注我的 公众号(Petterp) ,期待与你一同前进 :)



作者:Petterp
来源:juejin.cn/post/7212311942613385253
收起阅读 »

多端登录如何实现踢人下线

1:项目背景 一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。 或者你登录了PC端,这时候你登陆了AP...
继续阅读 »

1:项目背景


一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。


或者你登录了PC端,这时候你登陆了APP或者小程序,这时候PC端的账号也会被强制下线


2:项目只有PC端


假设我们现在的项目只有PC端,没有小程序或者APP,那么这时候就是很简单了,用户的sessin(也就是所谓的Token)一般都是存储在redis中,session中包括用户ID等一些信息,当然还有一个最重要的就是登录的ip地址。


image.png


1:用户在登录的时候,从redis中获取用户session,如果没有就可以直接登录了


2:用户在另外一台电脑登录,从redis中获取到用户session,这时候用户session是有的,说明用户之前已经登录过了


3:这时候从用户session中获取IP,判断二者的ip是不是相同,如果不同,这时候就要发送一个通知给客户端,让另外一台设备登录的账号强制下线即可


3:项目有PC端和APP端和小程序端


当你的应用有PC端和APP端的时候,我们用户的session如果还是只存一个ip地址,那明显就是不够的,因为很多情况下,我们PC端和APP端是可以同时登录的,比如淘宝,京东等都是,也就是所谓的双端登录


这时候就会有多种情况


单端登录:PC端,APP端,小程序只能有一端登录
双端登录:允许其中二个端登录
三端登录:三个端都可以同时登录

对于三端可以同时登录就很简单,但是现在有个限制,就是app端只能登录一次,不能同时登录,也就是我一个手机登录了APP,另外一个手机登录的话,之前登录的APP端就要强制下线


所以我们的用户session存储的格式如下


{
userId:用户的id
clientType:PC端,小程序端,APP端
imei:就是设备的唯一编号(对于PC端这个值就是ip地址,其余的就是手机设备的一个唯一编号)
}


单端登录


首先我们要知道,用户登录不同的设备那么用户session是不一样的。对于单端登录,那么我们可以拿到用户的所有的session,然后根据clientType和imei号来强制将其它端的用户session删除掉,然后通知客户端强制下线


双端登录


同样拿到所有用户的session,然后根据自己的业务需求来判定哪一端需要强制下线,比如我们现在已经登录了PC端和APP端,这时候登录小程序,现在要让APP端的强制下线。


这时候登录之后获取用户所有的session,这时候会有二个用户session,首先拿到clientType = APP的session,然后来通知客户端这个端需要强制下线。


如果这时候我登录了PC端和一个APP端,这时候我用另外一台手机登录APP端,那么之前那台手机上登录的APP端就要被强制下线,这个时候仅通过clientType是不行的,因为我二个手机登录的clientType都是APP端。所以这时候就要根据imei号来判断了。因为不同的手机imei号是不一样的。


这时候我拿到用户所有的session



PC端的session
sessionA{
userId: 1,
clientType: PC,
imei: "123"
}

APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "12345"
}

这时候我从另外一台手机登录的时候,生成的session应该是这样的


 APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "1234567"
}

我发现同一个clientType的session已经有了,这时候我要判断imei号是否一样,imei一样说明是同一台设备,不同说明不是同一台设备,我们只需要把对应设备的账号强制下线即可了


总结


不管是单端登录,双端登录还是多端登录,我们都是根据用户session来判断。只要根据clientType和imei号来就可以满足我们大部分的踢人下线需求了。


作者:我是小趴菜
来源:juejin.cn/post/7213598216884486204
收起阅读 »

百度文心大模型不好用,可能是你打开方式不正确

大众向科普文章,谁都能看懂。 群友AI作画失败了 今天百度文心一言大模型发布会,我看到摸鱼群里 文心模型被群嘲了 。 大图慎点,确实有点吓人。 群里大家用的都是这个:基于ERNIE-ViLG的文心一格 然后大家都在说百度这个好垃圾,还是国外的模型好,还放...
继续阅读 »

大众向科普文章,谁都能看懂。




群友AI作画失败了


今天百度文心一言大模型发布会,我看到摸鱼群里 文心模型被群嘲了


image.png


大图慎点,确实有点吓人。


image.png


群里大家用的都是这个:基于ERNIE-ViLG的文心一格


然后大家都在说百度这个好垃圾,还是国外的模型好,还放了一下国外模型生成的妹子:


image.png


其实可能不是模型垃圾,是没找到正确的打开方式


我生成的


我生成的是下图这样的,我觉得不阴间,并且 还挺好看的


WeChat Screenshot_20230316202725.png


WeChat Screenshot_20230316202735.png


WeChat Screenshot_20230316202744.png


先不要急着说为什么你不生成真人,拿二次元出来算什么。


为什么拿二次元图作对比,我后边会解释。


使用默认风格,虽说没多可爱,但是也没那么阴间吧……


image.png


1.png


国外模型真就比文心好?


先来通俗地说两个概念:


模型


给我们生成图像的就是模型,我们给模型输入文字,模型给我们生成图片。


模型只是一堆代码写好的,像一个什么也不会的小朋友,我们需要去模型怎么生成东西。




  • 如果我们给模型一堆美少女,模型就能画出来一堆美少女,画不出男的。




  • 如果我们给模型一堆风景,那模型就能画风景,画不出来人。




检查点


你训练好的模型,学到了对应的知识,我们把这个知识称为cheakpoint,也就是检查点


比如一个小孩子不会说话:




  • 你教他英语,他以后就会说英语,那我们可以认为这个孩子的checkpoint是英语。




  • 你教他汉语,他以后就是说汉语的,那我们可以说这个孩子的checkpoint是汉语。




checkpoint在这里代指我们教给他的东西。


对比


国外现在我们接触到的是基于Stable Diffusion的。


百度的文心一格是基于ERNIE-VilG的。


为什么国外模型效果好?


国外模型效果好,是因为国外发展早,生态好,因为国外模型的cheakpoint多啊。你使用不同的Checkpoint就能生成不同类型的图片了。


下图是国外社区中Stable Diffusion的Checkpoint的冰山一角:


image.png


那百度呢?百度现在展示的是自己训练的checkpoint,怎么可能和人家那么多比啊。


俗话说双拳难敌四手,俗话说三个臭皮匠顶个诸葛亮……所以你拿人家专门微调好的checkpoint和百度去比,就相当于让一个母语是英语的人一个学了几天英语的中国人比英语水平。


当然你让国外模型的一个Checkpoint去生成它不擅长的东西,它出来的结果也很阴间。


效果对比


先说画妹子


prompt: ((masterpiece,best quality)),1girl, long hair, black hair, long black dress, pigtails, outdoor, red bows, loafer


文本提示: 女,黑头发,双马尾,黑裙子,学生,小皮鞋,黑眼睛,长裙,细节,高清,学生


国外


先展示一下我用国外模型画的二次元人物。这三个checkpoint都是专门生成二次元人物的。


CheakPoint File:meinamix_meinaV8



CheakPoint File:mixProV3_v3



CheakPoint File:pastelMixStylizedAnime_pastelMixPrunedFP16



再来看一下百度


我感觉百度的生成效果已经蛮好的了,毕竟上边三个模型,人家是在专门数据集上训练好的专门用于画二次元妹子的checkpoint,百度作为一个比较通用的模型,能做到这样真的OK了。


WeChat Screenshot_20230316202712.png


不是我尬吹,你直接用Stable Diffusion的原始模型,效果还不如文心一格呢……不信你看:


image.png


所以百度真的不拉胯的……


再来看一下风景


文本提示:雪山,河流,麋鹿,森林,风景,写实,照片,细节,高清


prompt:((masterpiece,best quality)),a high-quality, detailed, and professional image,snowy mountains, rivers, elk, deer, reindeer, forests, scenery,realistic,photos,details,high-definition



因为识别不出elk 麋鹿,所以我还多加了几个buff:鹿deer,驯鹿reindeer



我让一个国外二次元模型画


我的麋鹿呢?那是什么牛马?


WeChat Image_20230316192042.png


文心一格基础模型生成好歹有个麋鹿样子的,至少称得上差强人意:


image.png


但是直接使用Stable Diffusion当前最高版本的基础模型,效果真的没那么牛。


你看到的优秀照片都是选到了很厉害的checkpoint生成出来的成功样例罢了,幸存者偏差。


image.png


然后我换了几个CheckPoint:


checkpoint1:根本找不到麋鹿,训练时候应该就没告诉它麋鹿怎么画。


image.png


checkpoint2:有麋鹿了,还有两只,不过一只没有头。


image.png


checkpoint3:终于有麋鹿了,虽然也是不尽人意,但是这个checkpoint本来画风就比较奇幻,所以我觉得这个麋鹿还OK的。


image.png


那我怎么生成好看的图?


你可能还想说:你是狗托吧,为啥模型在你手里输出的就是正常图片,在我手里就不正常?


看一下群友的:


image.png


看一下我的:


WeChat Screenshot_20230316202838.png


我的老婆是一位身材窈窕的美女可不可以改成老婆,身材好,漂亮呢?然后顺带加上高清,细节修饰词。



高清,细节不加也行的,文心一格画质还挺好,加上这俩纯属我使用stable diffusion后遗症



功能给你了,你好好用啊!好好写写文字提示(prompt),好好选风格,生成出来的东西绝对不会阴间的。


如果你觉得没有说服力,那我拿国外模型给你展示一下:


同一个cheakpoint出来效果完全不同的两个图,而他们的区别仅在于我用的prompt不一样


在第二个图中我在文本提示中写了高画质高质量等词语。


image.png


image.png


这就好比:


你:安安你快夸我。


我:6


你:安安你快夸我,字多一点。


我:牛逼


你:安安你快夸我,字多一点,再多点。


我:牛哇牛哇


你:安安你快夸我,字多一点,再多点,最好能像动漫里软萌的妹子那样。


我:哇,欧尼酱好棒呀!今天也有好好工作呢!


文本提示越精准,模型生成图片的质量就越好。所以想要正常图片你就好好写文本提示。


当然还有其他因素,比如采样步数、负提示等等,但是百度这里没设置,也没法对比,就不说了。




为什么你不用真人模型举例子


因为我还没摸索到怎么生成好看的真人。


比如我造周淑怡:


image.png


有点像,但是也一般,看起来比较假。


但是你们看看人家造的周姐:


高清,还很像! 是模型不行吗?不是的,别人能造出来就证明不是模型的问题,是我的问题,是我还没摸索到怎么生成好看的周姐


image.png


因为我造真人也不好看,我要是拿真人照片举例子,就会显得国外模型也很拉胯,所以我选二次元图举例子。


不信你看我拿外国模型生成的双马尾妹子,也是有点恐怖……


image.png




为什么要写这个文章?


国内本来就不如国外生态好,其实模型本身效果还OK了,但是一些用户打开方式不正确,导致模型被群嘲。


其实没掌握使用技巧的话,试用国外模型也不见得会有多好的结果。


文心大模型也好,还是别的模型也好,背后是工作人员的呕心沥血。文心确实不够完美,但是也不能因为错误的使用方法或怎么一味地去否认它。


刷朋友圈看到一个评论,我感觉特别好:


image.png



“见惯了大场面的Robin今天似乎有些紧张。但不得不说,文心一言或许确实不够完美,但能够在这种环境下把他推出来,值得一些掌声了。”



作为最底层的NLP人,我向百度致敬,致敬他们的勇气,致敬他们的努力和付出。



作者:Ann⁠⁣
链接:https://juejin.cn/post/7211116982513631292
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

还没用上chatGPT? 看看这几个替代方案吧

chatGPT面世都小半年了, 但是我总在使用的时候发现之前能访问的代理又挂掉了. 非常恼火. 于是楼主整理了一些低门槛且比较稳定的使用 chatGPT 的方案, 供掘友们参考: 1.NAT 该网站是 github 前 CEO, Nat Friedman 创建...
继续阅读 »

chatGPT面世都小半年了, 但是我总在使用的时候发现之前能访问的代理又挂掉了. 非常恼火.


于是楼主整理了一些低门槛且比较稳定的使用 chatGPT 的方案, 供掘友们参考:


1.NAT


该网站是 github 前 CEO, Nat Friedman 创建的, 集合了多种模型,


网站一个有趣的功能模块叫做 Compare, 提问一次可以对比多个模型的回答


image.png


免费+不用科学上网. 注册无需手机号, 只需要邮箱,


2.POE


POE 是国外问答网站 Quora 创立的, Quora大家应该都知道, 就相当于是国外版的知乎.


在POE集合了好几种大语言模型, 主要的两个是chatGPT 和 Claude.


Claude是 Anthropic公司出的模型, 它的最大特点是底层用卷积神经网络实现的, 在资源受限的环境下比较适用



Claude 是一种基于卷积神经网络 (Convolutional Neural Network, CNN) 的图像分类模型。该模型最初由 Google Brain 的研究人员开发,旨在提供一个高效且准确的图像分类解决方案。Claude 模型的设计基于 ResNet 架构,但引入了一些新的特性,包括 SE 模块和 Mixup 数据增强技术。这些特性使得 Claude 在 ImageNet 数据集上达到了 86.1% 的 Top-1 准确率和 98.7% 的 Top-5 准确率,超过了其他一些常用的模型,如 Inception-v3 和 ResNet-152。



image.png


注册需要提供邮箱+手机号. 亲测国内手机号可用


3.newBing


需要下载 Edge 浏览器, 注册一个微软账号, 并申请 newBing 体验


之前体验需要排队, 不过目前可以秒开体验资格


在使用体验上觉得 newBing 最好. newBing 的亮点是会给出信息来源网站, 以及会给出几个更具体更贴切的问题选项, 相当于用户就从做填空题变成了做选择题, 提问起来更高效


image.png


注意在使用 newBing 时候需要翻墙


4.其他国内代理服务


近期伴随着 chatGPT 的爆火, 也涌现了大量的国内代理, 这里就不一一介绍了. 不过国内代理的 chatGPT 服务与官网相比, 使用体验上还是差点, 而且有些代理商还会收费. 如果有能力的话, 还是建议自己注册chatGPT账号, 以获得最佳的使用体验。


结语


以上几种方案楼主都亲测可用. 在使用体验上, newBing 是最好的, 不过需要翻墙; NAT 有别致的 Compare 对比功能, POE 里有独特的卷积神经网络模型


大家可以自行选择, '驯服'属于自己的大语言模型.


作者:Jerakal
链接:https://juejin.cn/post/7212864793652117565
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

ChatGPT能否取代程序员?仍然是一个需要认真探讨的问题,对此你怎么看?

导言 ChatGPT能否取代程序员?作为一个AI语言处理程序,ChatGPT已经取得了重大的进展,它可以与人类进行流畅的对话,并能够接受和解释自然语言输入,并输出人类可理解、有意义的回复。然而,它是否能够取代程序员,仍然是一个需要认真探讨的问题。 ChatGP...
继续阅读 »

导言


ChatGPT能否取代程序员?作为一个AI语言处理程序,ChatGPT已经取得了重大的进展,它可以与人类进行流畅的对话,并能够接受和解释自然语言输入,并输出人类可理解、有意义的回复。然而,它是否能够取代程序员,仍然是一个需要认真探讨的问题。


ChatGPT的优势


首先,应该看到的是,ChatGPT具备的一些优势。ChatGPT已经被广泛应用于自然语言处理和对话生成等方面,这使得它可以代替程序员进行一些简单而重复性的工作。对于某些简单的外部调用或数据预处理等流程,ChatGPT可以通过自然语言输入和输出来完成,而无需程序员的干预。


此外,ChatGPT可以减少程序员在编写代码时的错误率。编程需要高度的精确性和细节,人们的犯错率很高,这是因为疏忽或思维方式不同。与人不同,ChatGPT能够在不需要任何帮助的情况下准确地执行指令,这意味着可以减少不必要的人为错误。


ChatGPT的缺陷和局限性


然而,ChatGPT作为一种工具,它仍然存在缺陷和局限性。 ChatGPT不能为程序员提供与业务需求紧密相连的解决方案。程序员不仅需要解决问题,还需要理解业务需求和目标,并设计出相应的解决方案。这需要程序员具备能力在技术和业务级别上进行交互,在这个问题上,ChatGPT的应用有限。



此外,ChatGPT无法在编程过程的所有阶段,提供与人类程序员相同或超过的创造性能力。创造力是人类的重要特征之一,表现在很多场合,如想象、创新等。在设计解决方案时,程序员需要花费大量时间进行创造性思考,以找到最佳的解决方案。这是ChatGPT无法做到的。


此外,ChatGPT也无法处理逻辑错误和紧急情况。在编程过程中,程序员需要处理各种问题和情况,如逻辑错误、紧急情况、文档和代码管理等。这些问题需要人类程序员处理,才能保证程序的稳定性和可靠性。 ChatGPT不能像人类程序员一样处理这些问题。


最后,需要考虑到的是,ChatGPT是AI的一种形式,它本身也存在缺陷和问题。在某些情况下,ChatGPT可能受到训练数据的影响,导致生成的结果不可靠。此外,ChatGPT还无法处理高度复杂的问题。这使得在某些情况下,程序员仍然需要进行人工编程。


总结


总的来说,ChatGPT可以在一定程度上替代程序员,但不能完全取代。尤其在需要进行创意思考、理解业务需求和解决紧急问题等方面,人类程序员的价值得到了体现,而ChatGPT的应用有限。ChatGPT和程序员是可以共存的,ChatGPT的发展将更加有助于程序员将更多的时间投入到解决问题本身。对此你怎么看?


作者:兴科Sinco
链接:https://juejin.cn/post/7213184860676210725
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

面向 ChatGPT 开发 ,我是如何被 AI 从 “逼疯” 到 “觉悟” ,未来又如何落地

对于 ChatGPT 如今大家应该都不陌生,经过这么长时间的「调戏」,相信大家应该都感受用 ChatGPT 「代替」搜索引擎的魅力,例如写周报、定位 Bug、翻译文档等等,而其中不乏一些玩的很「花」的场景,例如: ChatPDF :使用 ChatPDF...
继续阅读 »

对于 ChatGPT 如今大家应该都不陌生,经过这么长时间的「调戏」,相信大家应该都感受用 ChatGPT 「代替」搜索引擎的魅力,例如写周报、定位 Bug、翻译文档等等,而其中不乏一些玩的很「花」的场景,例如:




  • ChatPDF :使用 ChatPDF 读取 PDF 之后,你可以和 PDF 文件进行「交谈」,就好像它是一个完全理解内容的「人」一样,通过它可以总结中心思想,解读专业论文,生成内容摘要,翻译外籍,并且还支持中文输出等





  • BiBiGPT : 一键总结视频内容,主要依赖字幕来做总结,绝对是「二创」作者的摸鱼利器。





所以把 ChatGPT 理解为「搜索引擎」其实并不正确,从上述介绍的两个落地实现上看, ChatGPT 不是单纯的统计模型,它的核心并不是完全依赖于它的「语料库」,更多来自于临场学习的能力「 in-context learning」,这就是 ChatGPT 不同于以往传统 NLP「一切都从语料的统计里学习」的原因



当然,我本身并非人工智能领域的开发者,而作为一个普通开发者,我更关心的是 ChatGPT 可以如何提升我的开(mo)发(yu)效率,只是没想到随手一试,我会被 ChatGPT 的 「 in-context learning」 给「逼疯」。



ChatGPT & UI


相信大家平时「面向」 ChatGPT 开发时,也是通过它来输出「算法」或者「 CURD」 等逻辑居多,因为这部分输出看起来相对会比较直观,而用 ChatGPT 来绘制前端 UI 的人应该不多,因为 UI 效果从代码上看并不直观 ,而且 ChatGPT 对与 UI 的理解目前还处于 「人工智障」的阶段。



但是我偏偏不信邪。。。。。



因为近期开发需求里恰好需要绘制一个具有动画效果的 ⭐️ 按键,面对这么「没有挑战性」的工作我决定尝试交给 ChatGPT 来完成,所以我向 ChatGPT 发起了第一个命令:



「用 Flutter 画一个黄色的五角星」











结果不负众望,关键部分如下代码所示,Flutter 很快就提供了完整的 Dart 代码,并且还针对代码提供了代码相关实现的讲解,不过运行之后可以看到,这时候的 ⭐️ 的样式并不满足我们的需求。



此时顶部的角也太「肥」了 。











所以我随着提出了调整,希望五角星的五个角能够一样大,只是没想到我的描述,开始让 ChatGPT 放飞自我



也许是我的描述并不准确?




在我满怀期待的 cv 代码并运行之后,猝不及防的「五角星」差点没让我喷出一口老血,虽然这也有五个角,但是你管这个叫 「五角星」 ???



这难道不是某个红白机游戏里的小飞机??











甚至于在看到后续 ChatGPT 关于代码的相关讲解时,我觉得它已经开始在「一本正经的胡说八道」,像极了今天早上刚给我提需求的产品经理



哪里可以看出五个角相同了???




接着我继续纠正我的需求,表示我要的是 「一个五个角一样大的黄色五角星」 ,我以为这样的描述应过比较贴切,须不知·····



如下代码所示,其实在看到代码输出 for 循环时我就觉得不对了,但是秉承着「一切以实物为准」的理念,在运行后不出意外的发生了意外,确实是五个角一样大,不过是一个等边五边形。



算一个发胖的 ⭐️ 能解(jiao)释(bian)过去不?











再看 ChatGPT 对于代码的描述,我发现我错了,原来它像的是「理解错需求还在嘴硬的我」,只是它在说「这是一个五角星」的时候眼皮都不会眨一下



AI:确实五个角一样大,五个角一样大的五边形为什么就不能是五角星?你这是歧视体型吗?




所以我继续要求:「我要的是五角星,不是五边形」,还好 ChatGPT 的临场学习能力不错,他又一次「重新定义五角星」,不过我此时我也不抱希望,就是单纯想看看它还能给出什么「惊喜」



不出意外,这个「离谱」的多边形让我心头一紧,就在我想着是否放弃的时候,身为人类无法驯服 AI 「既爱又恨」的复杂情绪,让我最终坚持一定要让 ChatGPT 给我画出一个 ⭐️。










不过心灰意冷之下,我选择让 ChatGPT 重新画一个黄色五角星,没想道这次却有了意外的惊喜,从下面的图片可以看到,此时的 ⭐️ 除了角度不对,形状已经完全满足需求。



所以一个问题我多问几遍,也许就能接近我要的答案?




事实上这也是目前 ChatGPT 的现状,因为「临场学力」能力等因素影响,同一个问题它可能会给出不同的答案,而有的答案其实和我们要的根本不沾边










那么,接下来只要让 ChatGPT 把 ⭐️ 旋转一下角度,应该就可以完成需求····了吧?所以我提出「帮我旋转 180度」的要求。



结果不出意外的还是发生了意外,可能 ChatGPT 理解的 180 度和我不大一样,如下图所示,确实旋转了,只是还是歪的,而基于前面的尝试,我觉得有必要再给它一次机会。










终于,在我换了以下描述之后,ChatGPT 似乎「开窍」了,终于让我如愿以偿的得到了我想要的 ⭐️ 代码。



可以看到,其实这段代码并不复杂,你自己写或者去搜索引擎扒一扒大概会比用 ChatGPT 更快,但是 ChatGPT 的魅力就在于:它也许会错,但是总有一次它会「蒙对」,如果现在还不对,那么未来也会有算对的时候,这就是我感受到的 AI 魅力所在










既然五角星都出来,那么接下来我们来完成最后一步的动画效果,我们希望的是点击 ⭐️ 的时候可以有一个爆炸粒子的动画,所以我提出了:



「希望加上一个动画效果,就是点击的时候星星爆炸成颗粒的动画效果」




结果还是「一口老血」,如下图所示,「星星确实爆炸了」,只是这个爆炸动画看起来是那么的「一言难尽」,感兴趣的可以 cv 下面的源代码自己体验下,不出意外接下来又是新的一轮人类和 AI 的「斗智斗勇」。




class YellowStar extends StatefulWidget {
@override
_YellowStarState createState() => _YellowStarState();
}

class _YellowStarState extends State<YellowStar> with TickerProviderStateMixin {
bool _isExploded = false;
late AnimationController _animationController;
late Animation<double> _animation;

@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
}

@override
void dispose() {
_animationController.dispose();
super.dispose();
}

void _handleTapDown(TapDownDetails details) {
setState(() {
_isExploded = true;
_animationController.forward(from: 0.0);
});
}

void _resetStar() {
setState(() {
_isExploded = false;
_animationController.reset();
});
}

@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _handleTapDown,
onTap: _resetStar,
child: AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: _isExploded
? BorderRadius.circular(50)
: BorderRadius.zero,
),
child: _isExploded
? _buildParticles()
: Container(
width: 100,
height: 100,
child: CustomPaint(
painter: YellowStarPainter(),
),
),
),
);
}

Widget _buildParticles() {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: 100,
height: 100,
child: Stack(
children: List.generate(
50,
(index) {
double radius = _animation.value * 50;
double angle = 2 * pi * index / 50;
double x = 50 + cos(angle) * radius;
double y = 50 + sin(angle) * radius;
return Positioned(
left: x,
top: y,
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.yellow,
),
),
);
},
),
),
);
},
);
}
}



class YellowStarPainter extends CustomPainter {
final double starSizeRatio = 0.4;
final double centerOffsetRatio = 0.2;
final double rotationOffset = -pi / 2;

@override
void paint(Canvas canvas, Size size) {
double starSize = min(size.width, size.height) * starSizeRatio;
double centerX = size.width / 2;
double centerY = size.height / 2;
double centerOffset = starSize * centerOffsetRatio;

Path path = Path();
Paint paint = Paint()
..color = Colors.yellow
..style = PaintingStyle.fill;

for (int i = 0; i < 5; i++) {
double radians = 2 * pi / 5 * i + rotationOffset;
double x = centerX + cos(radians) * starSize / 2;
double y = centerY + sin(radians) * starSize / 2;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}

radians += 2 * pi / 10;
x = centerX + cos(radians) * centerOffset;
y = centerY + sin(radians) * centerOffset;
path.lineTo(x, y);
}

path.close();
canvas.drawPath(path, paint);
}

@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}

经过一轮「鏖战」如下图所示,面对同一个问题,ChatGPT 总能找到不同的实现方式来回答你,只是这些实现里有的无法运行的,有的是运行没有效果的,有的甚至是给你输出了一个 //TODO 爆炸动画 ,总之就是让你「血压飙升」。





当然,「胜利终将属于人类」,如下图所示,在经历了无数次「血压飙升」的场景之后,最终我还是得到了我想要的:点击 ⭐️,然后出现了💥的动画效果,尽管还是不完美,但是基本满足了我后续开发的需求。



这个代码完完全全由 ChatGPT 输出,我一句没改,完整代码贴在了后面。





class YellowStarPage extends StatefulWidget {
@override
_YellowStarPageState createState() => _YellowStarPageState();
}

class _YellowStarPageState extends State<YellowStarPage>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
bool _isExploded = false;

@override
void initState() {
super.initState();
_animationController =
AnimationController(vsync: this, duration: Duration(milliseconds: 500))
..addListener(() {
setState(() {});
});
}

@override
void dispose() {
_animationController.dispose();
super.dispose();
}

void _handleStarTap() {
if (!_isExploded) {
_isExploded = true;
_animationController.forward(from: 0);
Future.delayed(Duration(milliseconds: 500), () {
setState(() {
_isExploded = false;
});
});
}
setState(() {});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Yellow Star')),
body: Center(
child: GestureDetector(
onTap: _handleStarTap,
child: Container(
width: 300,
height: 300,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return CustomPaint(
painter: YellowStarPainter(_animationController.value,
isExploded: _isExploded),
);
},
),
),
),
),
);
}
}

class YellowStarPainter extends CustomPainter {
final double starSizeRatio = 0.4;
final double centerOffsetRatio = 0.2;
final double rotationOffset = -pi / 2;

final double animationValue;
final bool isExploded;

YellowStarPainter(this.animationValue, {this.isExploded = false});

@override
void paint(Canvas canvas, Size size) {
double starSize = min(size.width, size.height) * starSizeRatio;
double centerX = size.width / 2;
double centerY = size.height / 2;
double centerOffset = starSize * centerOffsetRatio;

Path path = Path();
Paint paint = Paint()
..color = Colors.yellow
..style = PaintingStyle.fill;

if (isExploded) {
double particleSize = starSize / 30;
paint.strokeWidth = 1;
paint.style = PaintingStyle.fill;
paint.color = Colors.yellow;
Random random = Random();

for (int i = 0; i < 30; i++) {
double dx = random.nextDouble() * starSize - starSize / 2;
double dy = random.nextDouble() * starSize - starSize / 2;
double x = centerX + dx * (1 + animationValue);
double y = centerY + dy * (1 + animationValue);

canvas.drawCircle(Offset(x, y), particleSize, paint);
}
} else {
for (int i = 0; i < 5; i++) {
double radians = 2 * pi / 5 * i + rotationOffset;
double x = centerX + cos(radians) * starSize / 2;
double y = centerY + sin(radians) * starSize / 2;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}

radians += 2 * pi / 10;
x = centerX + cos(radians) * centerOffset;
y = centerY + sin(radians) * centerOffset;
path.lineTo(x, y);
}

path.close();
canvas.drawPath(path, paint);
}
}

@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}


最后,给大家欣赏一下我让 ChatGPT 画一只米老鼠的「心路历程」,很明显这一次「人类一败涂地」,从目前的支持上看,让 ChatGPT 输出复杂图像内容并不理想,因为它不的笔画「不会拐弯」。













真的是又爱又恨。



最后


经过上面的一系列「折腾」,可以看到 ChatGPT 并没有我们想象中智能,如果面向 GPT 去开发,甚至可能并不靠谱,因为它并不对单一问题给出固定答案,甚至很多内容都是临场瞎编的,这也是因为大语言模型本身如何保证「正确」是一个复杂的问题,但是 ChatGPT 的魅力也来自于此:



它并不是完全基于语料来的统计来给答案



当然这也和 ChatGPT 本身的属性有关系, ChatGPT 目前的火爆有很大一部分属于「意外」,目前看它不是一个被精心产品化后的 2C 产品,反而 ChatPDFBiBiGPT 这种场景化的包装落地会是它未来的方向之一。


而现在 OpenAI 发布了多模态预训练大模型 CPT-4GPT-4 按照官方的说法是又得到了飞跃式提升:强大的识图能力;文字输入限制提升至 2.5 万字;回答准确性显著提高;能够生成歌词、创意文本,实现风格变化等等



所以我很期待 ChatGPT 可以用 Flutter 帮我画出一只米老鼠, 尽管 ChatGPT 现在可能会让你因为得到 1+1=3 这样的答案而「发疯”」,但是 AI 的魅力在于,它终有一天能得到准确的结果


作者:恋猫de小郭
链接:https://juejin.cn/post/7210605626501595195
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Kotlin委托的原理与使用,以及在Android开发中常用的几个场景

Kotlin委托的常见使用场景 前言 在设计模式中,委托模式(Delegate Pattern)与代理模式都是我们常用的设计模式(Proxy Pattern),两者非常的相似,又有细小的区分。 委托模式中,委托对象和被委托对象都是同一类型的对象,委托对象将任务...
继续阅读 »

Kotlin委托的常见使用场景


前言


在设计模式中,委托模式(Delegate Pattern)与代理模式都是我们常用的设计模式(Proxy Pattern),两者非常的相似,又有细小的区分。


委托模式中,委托对象和被委托对象都是同一类型的对象,委托对象将任务委托给被委托对象来完成。委托模式可以用于实现事件监听器、回调函数等功能。


代理模式中,代理对象与被代理对象是两种不同的对象,代理对象代表被代理对象的功能,代理对象可以控制客户对被代理对象的访问。代理模式可以用于实现远程代理、虚拟代理、安全代理等功能。


以类的委托与代理来举例,委托对象和被委托对象都实现了同一个接口或继承了同一个类,委托对象将任务委托给被委托对象来完成。代理模式中,代理对象与被代理对象实现了同一个接口或继承了同一个类,代理对象代表被代理对象,客户端通过代理对象来访问被代理对象。


两者的区别:


他们虽然都有同一个接口,主要区别在于委托模式中委托对象和被委托对象是同一类型的对象,而代理模式中代理对象与被代理对象是两种不同的对象。总的来说,委托模式是为了将方法的实现交给其他类去完成,而代理模式则是为了控制对象的访问,并在访问前后进行额外的操作。


而我们常用的委托模式怎么使用?在 Java 语言中需要我们手动的实现,而在 Kotlin 语言中直接通过关键字 by 就可以实现委托,其实现更加优雅、简洁了。


我们在开发一个 Android 应用中,常用到的委托分为:



  1. 接口/类的委托

  2. 属性的委托

  3. 结合lazy的延迟委托

  4. 观察者的委托

  5. Map数据的委托


下面我们就一起看看不同种类的委托使用以及在 Android 常见的一些场景中的使用。


一、接口/类委托


我们可以选择使用接口来实现类似的效果,也可以直接传参,当然接口的方式更加的灵活,比如我们这里就以接口比如我定义一个攻击与防御的行为接口:


interface IUserAction {

fun attack()

fun defense()
}

定义了用户的行为,有攻击和防御两种操作!接下来我们就定义一个默认的实现类:


class UserActionImpl : IUserAction {

override fun attack() {
YYLogUtils.w("默认操作-开始执行攻击")
}

override fun defense() {
YYLogUtils.w("默认操作-开始执行防御")
}
}

都是很简单的代码,我们定义一些默认的操作,如果任意类想拥有攻击和防御的能力就直接实现这个接口,如果想自定义攻击和防御则重写对应的方法即可。


如果使用 Java 的方式实现委托,大致代码如下:


class UserDelegate1(private val action: IUserAction) : IUserAction {
override fun attack() {
YYLogUtils.w("UserDelegate1-需要自己实现攻击")
}

override fun defense() {
YYLogUtils.w("UserDelegate1-需要自己实现防御")
}
}

如果使用 Kotlin 的方式实现则是:


class UserDelegate2(private val action: IUserAction) : IUserAction by action

如果 Kotlin 的实现不想默认的实现也可以重写部分的操作:


class UserDelegate3(private val action: IUserAction) : IUserAction by action {

override fun attack() {
YYLogUtils.w("UserDelegate3 - 只重写了攻击")
}
}

那么使用起来就是这样的:


    val actionImpl = UserActionImpl()

UserDelegate1(actionImpl).run {
attack()
defense()
}

UserDelegate2(actionImpl).run {
attack()
defense()
}

UserDelegate3(actionImpl).run {
attack()
defense()
}

打印日志如下:


image.png


其实在 Android 源码中也有不少委托的使用,例如生命周期的 Lifecycle 委托:


Lifecycle 通过委托机制实现其功能。具体来说,组件可以将自己的生命周期状态委托给 LifecycleOwner 对象,LifecycleOwner 对象则负责管理这些组件的生命周期。


例如,在一个 Activity 中,我们可以通过将 Activity 对象作为 LifecycleOwner 对象,并将该对象传递给需要注册生命周期的组件,从而实现组件的生命周期管理。 页面可以使用 getLifecycle() 方法来获取它所依赖的 LifecycleOwner 对象的 Lifecycle 实例,并在需要时将自身的生命周期状态委托给该 Lifecycle 实例。


通过这种委托机制,Lifecycle 实现了一种方便的方式来管理组件的生命周期,避免了手动管理生命周期带来的麻烦和错误。



class AnimUtil private constructor() : DefaultLifecycleObserver {

...

private fun addLoopLifecycleObserver() {
mOwner?.lifecycle?.addObserver(this)
}

// 退出页面的时候释放资源
override fun onDestroy(owner: LifecycleOwner) {
mAnim?.cancel()
destory()
}

}


除此之外委托还特别适用于一些可配置的功能,比如 Resutl-Api 的封装,如果当前页面需要开启 startActivityForResult 的功能,就实现这个接口,不需要这个功能就不实现接口,达到可配置的效果。


/**
* 定义是否需要SAFLauncher
*/
interface ISAFLauncher {

fun <T : ActivityResultCaller> T.initLauncher()

fun getLauncher(): GetSAFLauncher?

}

由于代码是固定的实现,目标Activity也不需要重新实现,我们只需要实现默认的实现即可:


class SAFLauncher : ISAFLauncher {

private var safLauncher: GetSAFLauncher? = null

override fun <T : ActivityResultCaller> T.initLauncher() {
safLauncher = GetSAFLauncher(this)
}

override fun getLauncher(): GetSAFLauncher? = safLauncher

}

使用起来我们直接用默认的实现即可:


class DemoActivity : BaseActivity, ISAFLauncher by SAFLauncher() {

override fun init() {
initLauncher() // 实现了接口还需要初始化Launcher
}

fun gotoOtherPage() {
//使用 Result Launcher 的方式启动,并获取到返回值
getLauncher()?.launch<DemoCircleActivity> { result ->
val result = result.data?.getStringExtra("text")
toast("收到返回的数据:$result")
}

}

}

这样是不是就非常简单了呢?具体如何使用封装 Result Launcher 可以看看我去年的文章 【传送门】


二、属性委托


除了类与接口对象的委托,我们还常用于属性的委托。


我知道了!这么弄就行了。


    private val textStr by "123"

哎?怎么报错了?其实不是这么用的。


属性委托和类委托一样,属性的委托其实是对属性的 set/get 方法的委托。


需要我们把 set/get 方法委托给 setValue/getValue 方法,因此被委托类(真实类)需要提供 setValue/getValue 方法,val属性只需要提供 getValue 方法。


我们修改代码如下:


    private val textStr by TextDelegate()

class TextDelegate {

operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

}

打印的结果:


image.png


而我们定义一个可读写的属性则可以


  private var textStr by TextDelegate()

class TextDelegate {

operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
YYLogUtils.w("设置的值为:$value")
}

}

YYLogUtils.w("textStr:$textStr")
textStr = "abc123"

打印则如下:


image.png


为了怕大家写错,我们其实可以用接口来限制,只读的和读写的属性,我们分别可以用 ReadOnlyProperty 与 ReadWriteProperty 来限制:



class TextDelegate : ReadOnlyProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
return "我是赋值给与的文本"
}
}

class TextDelegate : ReadWriteProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

override fun setValue(thisRef: Any, property: KProperty<*>, value: String) {
YYLogUtils.w("设置的值为:$value")
}
}

那么实现的方式和上面自己实现的效果是一样的。如果要使用属性委托可以选用这种接口限制的方式实现。


我们的属性除了委托给类去实现,同时也能委托给其他属性(Kotlin 1.4+)来实现,例如:


    private var textStr by TextDelegate2()
private var textStr2 by this::textStr

其实是内部委托了对象的 get 和 set 函数。相对委托对象而言性能更好一些。而委托对象去实现,不仅增加了一个委托类,而且还还在初始化时就创建了委托类的实例对象,算起来其实性能并不好。


所以属性的委托不要滥用,如果要用,可以选择委托现成的其他属性来完成,或者使用延迟委托Lazy实现,或者使用更简单的方式实现:


    private val industryName: String
get() {
return "abc123"
}

对于只读的属性,这种方式也是我们常见的使用方式。


三、延迟委托


如果说使用类来实现委托不那么好的话,其实我们可以使用延迟委托。延迟关键字 lazy 接收一个 lambda 表达式,最后一行代表返回值给被推脱的属性。


默认的 Lazy 实现:


    val name: String by lazy {
YYLogUtils.w("第一次调用初始化")
"abc123"
}

YYLogUtils.w(name)
YYLogUtils.w(name)
YYLogUtils.w(name)

只有在第一次使用此属性的时候才会初始化,一旦初始化之后就可以直接获取到值。


日志打印:


image.png


它的内部其实也是使用的是类的委托实现。


public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)


最终的实现是由 SynchronizedLazyImpl 类生成并实现的:


private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required to enable safe publication of constructed instance
private val lock = lock ?: this

override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}

return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}

override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

private fun writeReplace(): Any = InitializedLazyImpl(value)
}

我们可以直接看 value 的 get 方法,如果_v1 !== UNINITIALIZED_VALUE 则表明已经初始化过了,就直接返回 value ,否则表明没有初始化过,调用initializer方法,也就是 lazy 的 lambda 表达式返回属性的赋值。


跟我们自己实现类的委托类似,也是实现了getValue方法。只是多了判断是否初始化的一些相关逻辑。


lazy的参数分为三种类型:



  1. SYNCHRONIZED:添加同步锁,使lazy延迟初始化线程安全

  2. PUBLICATION:初始化的lambda表达式,可以在同一时间多次调用,但是只有第一次的返回值作为初始化值

  3. NONE:没有同步锁,非线程安全


默认情况下,对于 lazy 属性的求值是同步锁的(synchronized),是可以保证线程安全的,但是如果不需要线程安全和减少性能花销可以可以使用 lazy(LazyThreadSafetyMode.NONE){} 即可。


四、观察者委托


除了对属性的值进行委托,我们甚至还能对观察到这个变化过程:


使用 observable 委托监听值的变化:


    var values: String by Delegates.observable("默认值") { property, oldValue, newValue ->

YYLogUtils.w("打印值: $oldValue -> $newValue ")
}

values = "第一次修改"
values = "第二次修改"
values = "第三次修改"

打印:


image.png


我们还能使用 vetoable 委托,和 observable 一样可以观察属性的变化,不同的是 vetoable 可以决定是否使用新值。


    var age: Int by Delegates.vetoable(18) { property, oldValue, newValue ->
newValue > oldValue
}

YYLogUtils.w("age:$age")
age = 14
YYLogUtils.w("age:$age")
age = 20
YYLogUtils.w("age:$age")
age = 22
YYLogUtils.w("age:$age")
age = 20
YYLogUtils.w("age:$age")

我们需要返回 booble 值觉得是否使用新值,比如上述的例子就是当新值大于老值的时候才赋值。那么打印的日志就是如下:


image.png


虽然这种方式我们并不常用,一般我们都是使用类似 Flow 之类的工具在源头就处理了逻辑,使用这种方式我们就可以在属性的赋值过程中进行拦截了。在一些特定的场景下还是有用的。


五、Map委托


我们的属性不止可以使用类的委托,延迟的委托,观察的委托,还能委托Map来进行赋值。


当属性的值与 Map 中 key 相同的时候,我们可以把对应 key 的 value 取出来并赋值给属性:


class Member(private val map: Map<String, Any>) {

val name: String by map
val age: Int by map
val dob: Long by map

override fun toString(): String {
return "Member(name='$name', age=$age, dob=$dob)"
}

}

使用:


        val member = Member(mapOf("name" to "guanyu", "age" to 36, Pair("dob", 1234567890L)))
YYLogUtils.w("member:$member")

打印的日志:


image.png


但是需要注意的是,map 中的 key 名字必须要和属性的名字一致才行,否则委托后运行解析时会抛出 NoSuchElementException 异常提示。


例如我们在 Member 对象中加入一个并不存在的 address 属性,再次运行就会报错。


image.png


而我们把 Int 的 age 属性赋值给为字符串也会报类型转换异常:


image.png


所以一定要一一对应才行哦,我怎么感觉有一点 TypeScript 结构赋值的那味道 - - !


image.png


总结


委托虽好不要滥用。委托毕竟还是中间多了一个委托类,如果没必要可以直接赋值实现,而不需要多一个中间类占用内存。


我们可以通过接口委托来实现一些可选的配置。通过委托类实现属性的监听与赋值。可以减少一些模板代码,达到低耦合高内聚的效果,可以提高程序的可维护性、可扩展性和可重用性。


对于属性的类委托,我们可以将属性的读取和写入操作委托给另一个对象,或者另一个属性,或者使用延迟委托来推迟对象的创建直到第一次访问。


对于 map 的委托,我们需要仔细对应属性与 key 的一致性。以免出现错误,这是运行时的错误,有可能出现在生产环境上的。


那么大家都是怎么使用的呢?有没有更好的方式呢?或者你有遇到的坑也都可以在评论区交流一下,大家可以互相学习进步。如有本文有一些错漏的地方,希望同学们可以指出。


如果感觉本文对你有一点点的帮助,还望你能点赞支持一下,你的支持是我最大的动力。


本文的部分代码可以在我的 Kotlin 测试项目中看到,【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


Ok,这一期就此完结。



作者:newki
链接:https://juejin.cn/post/7213267574770090039
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

StartService别乱用,小心IllegalStateException

startService可以很方便的启动一个service服务,也可以运行在单独的进程。但是如果在后台调用了startService,则很可能会抛出一个崩溃。Caused by: java.lang.IllegalStateException: Not all...
继续阅读 »

startService可以很方便的启动一个service服务,也可以运行在单独的进程。

但是如果在后台调用了startService,则很可能会抛出一个崩溃。

Caused by: java.lang.IllegalStateException: Not allowed to start service Intent { cmp=com.test.broadservice/.MyService }: app is in background uid UidRecord{b67c471 u0a86 RCVR idle change:uncached procs:1 seq(0,0,0)}
at android.app.ContextImpl.startServiceCommon(ContextImpl.java:1577)
at android.app.ContextImpl.startService(ContextImpl.java:1532)
at android.content.ContextWrapper.startService(ContextWrapper.java:664)
at android.content.ContextWrapper.startService(ContextWrapper.java:664)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1661)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:193)
at android.app.ActivityThread.main(ActivityThread.java:6669)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)

错误原因

Android8.0之后,系统增加了对后台Service的限制,如果应用处于后台,调用startService会抛出IllegalStateException

意思是,在后台的应用,不允许调用startService启动一个后台服务,否则就会抛出异常。

解决方法

1. 使用startForegroundService

使用方法比较简单,这里就不列出代码了。

有几点需要注意:

  • 用此方法启动前台服务,会在用户的通知栏上显示
  • 必须在5s内调用服务的startForeground方法,否则会发生ANR
2. 使用JobScheduler

使用JobScheduler可以实现在后台运行任务。

定义一个JobService:

public class MyJobService extends JobService {
@Override
public boolean onStartJob(JobParameters params) {
return false;
}

@Override
public boolean onStopJob(JobParameters params) {
return false;
}
}

onStartJob

  • 运行在主进程,需要避免执行耗时的操作。
  • 返回true:表示任务还在继续执行
  • 返回false:表示任务已执行完
  • 执行完之后,可以调jobFinished方法来通知系统任务已完成。

onStopJob:

  • 当条件不满足的时候,会回调这个方法。
  • 返回true:表示条件满足时,再次执行任务
  • 返回false:表示任务完全结束

注册JobService

        <service
android:name=".MyJobService"
android:permission="android.permission.BIND_JOB_SERVICE" />

定义一个JobInfo

JobInfo.Builder builder = new JobInfo.Builder(111, new ComponentName(this, MyJobService.class));
// todo 设置任务的参数

将任务发布给系统:

JobScheduler jobScheduler = (JobScheduler) getSystemService(Context.JOB_SCHEDULER_SERVICE);
jobScheduler.schedule(builder.build());
3. 使用WorkManager

WorkManagerJobScheduler的升级版本,且支持多进程,可以将任务运行到单独的进程中。具体使用方法可以参考之前的文章:应用退出后继续运行后台任务,来试试WorkManager吧!

总结

Android 8.0版本以上,在后台调用startService会抛出IllegalStateException异常,需要改用其他的方式来使用。

常用的方法有如下三种:

  • startForegroundService: 这种方式会在用户的通知栏显示UI。
  • JobScheduler:可以实现后台无感知运行任务。
  • WorkManagerJetpack里的库,JobScheduler的升级版,支持多进程。


作者:尹学姐
链接:https://juejin.cn/post/7212960463730360375
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

从SharedPreferences和MMKV看本地数据迁移

1. 前言 之前也有听说过MMKV,但是一直没时间去看,前段时间去简单看看它的相关内容之后觉得挺有意思的,然后就想要不要用MMKV把SP给替换了,这时就又想到了一些数据迁移的问题,所以这次简单谈谈SharedPreferences和MMKV,主要我还是想谈谈数...
继续阅读 »

1. 前言


之前也有听说过MMKV,但是一直没时间去看,前段时间去简单看看它的相关内容之后觉得挺有意思的,然后就想要不要用MMKV把SP给替换了,这时就又想到了一些数据迁移的问题,所以这次简单谈谈SharedPreferences和MMKV,主要我还是想谈谈数据迁移这个问题。


2. MMKV


腾讯的MMKV,挺牛逼,为什么牛逼,很有想法,这也从侧面体现出想要做出牛逼的东西,你得敢想,然后你想出一套方案之后,还能去实现它。或许你看它的原理你觉得还行,也没多复杂什么的,但你能从0到1的过程想出这个方案然后去实现它吗?


首先要知道它为什么被设计出来,通过官方的介绍:需要一个性能非常高的通用 key-value 存储组件,我们考察了 SharedPreferences、NSUserDefaults、SQLite 等常见组件,发现都没能满足如此苛刻的性能要求。看得出是为了提升性能


那是不是说我觉得MMKV性能比SP好,所以我就用它?并不是这样的,如果你只是用key-value的组件去存状态等少量数据,而且不会频繁的读写,那SP是完全够用的,并且没必要引入MMKV。但是如果你存储的数据大数据复杂,并且频繁读写,假如你这次数据都没写完,又开始写下一次了,那就会有性能上的问题,这时候用MMKV去代替SP完全是一个很好的方案。


因为我当前的项目没有这样的需求,没达到这样的量级,所以暂不需要用到MMKV,但是我简单看了它的原理,比较核心的我觉得就两个思想:mmap和protobuf,其它的append啊这些都是在这基础上进一步优化的操作,核心的就是mmap和protobuf,特别是mmap。所以为什么说牛逼,因为如果是你做,没有参考的情况下,你能想出用mmap这种方案去优化吗?


什么是mmap,内存映射mmap,如果了解过Binder机制,那应该对它多多少少有些印象,如果不知道内存映射是什么,建议可以先去看看Binder机制,了解下一次拷贝的概念,再回来看mmap就知道是什么操作了,就知道为什么它要使用这种思路去做性能提升。


再看看另一个点protobuf,protobuf是一种数据存储格式,它所占用的空间更小,所以也是一个优化的点,占的空间越小,存储时所需要的空间就越小,传送也越快。


2. SharedPreferences


android经常使用的组件,喜欢用它是因为使用起来方便。可以简单看看它是怎么实现的,然后对比一下上面的MMKV。


一般我们调用都是SharedPreferences.Editor的commit()或者apply,然后点进去看发现Editor是一个接口,SharedPreferences也同样是个接口,点它的类看获取它的地方发现在Context里面


public abstract SharedPreferences getSharedPreferences(File file, @PreferencesMode int mode);

看它的子类实现在ContextWrapper里面


@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
return mBase.getSharedPreferences(file, mode);
}

mBase就是Context,点之后又跳到Context里面了,完了,芭比Q了,死循环了,找不到SharedPreferences的实现类了。为什么要讲这个,其实如果你看源码比较多,你就会发现有个习惯,一般具体的实现类都是在抽象接口的后面加Impl,所以我们找SharedPreferencesImpl,当然你还有个办法能找到,就是百度。然后看SharedPreferencesImpl的commit方法


@Override
public boolean commit() {
......

MemoryCommitResult mcr = commitToMemory();

SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
if (DEBUG) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " committed after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}

commitToMemory里面只是把数据包装成MemoryCommitResult,然后给enqueueDiskWrite方法


private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
......

final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}
......
}
};

......
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

QueuedWork.queue就是放到队列操作,这个就不说的,来看writeToFile(挺长的,我这截取中间一部分)


try {
FileOutputStream str = createFileOutputStream(mFile);

if (DEBUG) {
outputStreamCreateTime = System.currentTimeMillis();
}

if (str == null) {
mcr.setDiskWriteResult(false, false);
return;
}
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);

writeTime = System.currentTimeMillis();

FileUtils.sync(str);

fsyncTime = System.currentTimeMillis();

str.close();
ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);

if (DEBUG) {
setPermTime = System.currentTimeMillis();
}

try {
final StructStat stat = Os.stat(mFile.getPath());
synchronized (mLock) {
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
}
} catch (ErrnoException e) {
// Do nothing
}

if (DEBUG) {
fstatTime = System.currentTimeMillis();
}

// Writing was successful, delete the backup file if there is one.
mBackupFile.delete();

if (DEBUG) {
deleteTime = System.currentTimeMillis();
}

mDiskStateGeneration = mcr.memoryStateGeneration;

mcr.setDiskWriteResult(true, true);

if (DEBUG) {
Log.d(TAG, "write: " + (existsTime - startTime) + "/"
+ (backupExistsTime - startTime) + "/"
+ (outputStreamCreateTime - startTime) + "/"
+ (writeTime - startTime) + "/"
+ (fsyncTime - startTime) + "/"
+ (setPermTime - startTime) + "/"
+ (fstatTime - startTime) + "/"
+ (deleteTime - startTime));
}

long fsyncDuration = fsyncTime - writeTime;
mSyncTimes.add((int) fsyncDuration);
mNumSync++;

if (DEBUG || mNumSync % 1024 == 0 || fsyncDuration > MAX_FSYNC_DURATION_MILLIS) {
mSyncTimes.log(TAG, "Time required to fsync " + mFile + ": ");
}

return;
} catch (XmlPullParserException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
} catch (IOException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
}

其实能很明显第一眼就看出,是直接用FileOutputStream写到文件中,然后XmlUtils就是把这个文件写成xml的形式。其实SharedPreferences是用xml的格式存储数据相信大家都懂,我这里只是通过代码简单过一遍这个流程。


能看出SharedPreferences和MMKV的不同之处,SP是用FileOutputStream把数据写进本的,而MMKV是用了内存映射,MMKV明显会更快,存储数据的格式方面,SP是用了xml的格式,而MMKV用的是protobuf,明显也是MMKV会更小。


虽然SharedPreferences调用起来方便,但同样的也了一些缺点,比较多进程环境下,比如在某些快速读写的环境中使用apply等。那是不是说我就必须使用MMKV去代替SharedPreferences?其实并不是,你的功能没涉及多进程环境,没涉及频繁大量的读写数据,比如存就只存一个状态,或者说我隔一段时间才读写一次数据量不大的数据,那直接使用SharedPreferences也不会有什么问题。没必要大动干戈,杀鸡还要用牛刀?


3. 数据迁移


这才是我想讲的重点,什么是数据迁移,和SharedPreferences还有MMKV又有什么关系,数据迁移是一个解决问题的思路,和SP还有MMKV是没有关系,只不过我用它们两个来举例会比较好说明。


虽然MMKV好用是吧,假如说你有什么场景,用SP确实无法支持你的业务了,改用MMKV,但是你的旧版本中还是用的SP去存数据,直接覆盖升级可是不会删除磁盘数据的,那你得把SP之前存的xml格式的数据迁移到MMKV中,这就是一个本地数据迁移的过程。


如果从SP迁移到MMKV中,那应该挺简单,我相信MMKV中有对应的方法提供给你,我想腾讯开发的,肯定会考虑到这一点,如果没有,你自己写这个迁移的逻辑也不难。而且SP是android原生提供的组件,所以不会涉及到删除组件之类的操作。但是假如,我说假如,字节也出个key-value的组件,比如叫ByteKV,假如他不是用protobuf,是另一种能把数据压缩更小的格式。这时候你用MMKV,你想去替换成ByteKV,你要怎么做。


有的人就说了,那如果有这种情况,它们也会考虑兼容其它的组件,如果没有,那就在手动写迁移的逻辑,这个又不复杂。手写迁移的逻辑是不复杂,但有没有想过一个问题,你需要去删除之前的库,比如说你之前依赖MMKV,你现在换这个ByteKV之后,你需要不再依赖MMKV ,不然你就会每次换一个新的库,你都重新依赖,并且不删除旧的依赖。


比如你的1.0版本依赖MMKV,2.0版本改用ByteKV,在依赖ByteKV的同时,你还要依赖MMKV吗?SP是没有这个问题,因为它是原生的代码。


我帮你们想了一个办法,假如1.0版本依赖MMKV,我2.0版本当一个过渡版本依赖ByteKV和MMKV,我3.0再把MMKV的依赖去掉行不行?当然不行,那有些用户直接从1.0升到3.0不就导致没迁移的数据没了吗


那这要怎么处理,其实说来也简单,MMKV把数据存到本地的哪个文件这个你知道吧,它用protobuf的方式去存你也知道吧,那这事不就完了,你知道文件存哪里并以什么方式存,那你就能把内容读取出来,这和存的过程已经没有任何关系了。 所以你读这个文件的内容,根本就不需要MMKV,你只需要判断在这个文件夹下有这个文件,并且这个文件是某个格式的,就手动做迁移,迁移完之后再把文件删了。如果你不知道你所用的框架会把数据存到哪里,又是以什么格式存的,那也简单,去看它的源码就知道了。


这里是拿了MMKV来举例,数据库也一样,你改不同的数据库框架,无所谓,你知道它存在哪里,怎么存的,那你不用对应的库也能把数据提出来。


这其实就是数据迁移的原理,我管你是用什么库存的,你的库做的只不过是对存的过程的优化和决定数据的格式。


还有一个要注意的点是,数据不是一次性迁移完的,是部分部分迁移的,你先迁移一部分,然后删除旧文件的那部分数据。


总结


这篇文章其实主要是想简单介绍SP和MMKV的不同,了解MMKV是为何被设计出来,并且站在开发者的一个角度去思考,如果是你,你要怎样才能像他们一样,设计出这样的一套思路。


其次就是关于本地数据迁移的问题,如果去透过现象看本质,我们平时会用到很多别人写的库,为什么用,因为别人写得好,我自己从0开始设计没办法像他们一样设计得这么好,所以使用他们得。但我同样需要知道这其中的原理,知道他们是怎样去实现的。


作者:流浪汉kylin
链接:https://juejin.cn/post/7208844516950065210
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

一看就会,对startActivityForResult的几种实现方案的调用与封装

前言 startActivityForResult 可以说是我们常用的一种操作了,用于启动新页面并拿到这个页面返回的数据,是两个 Activity 交互的基本操作。 虽然可以通过接口,消息总线,单例池,ViewModel 等多种方法来间接的实现这样一个功能,但...
继续阅读 »

前言


startActivityForResult 可以说是我们常用的一种操作了,用于启动新页面并拿到这个页面返回的数据,是两个 Activity 交互的基本操作。


虽然可以通过接口,消息总线,单例池,ViewModel 等多种方法来间接的实现这样一个功能,但是 startActivityForResult 还是使用最方便的。


目前有哪些方式实现 startActivityForResult 的功能呢?


有新老两种方式,过时的方法是原生Activity/Fragment的 startActivityForResult 方法。另一种方法是 Activity Result API 通过 registerForActivityResult 来注册回调。


我们一起看看都是如何使用,使用起来方便吗?通常我们又都是如何封装的呢?


一、原生的使用


不管是Activity还是Fragment,我们都可以使用 startActivityForResult


image.png


    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 120 && resultCode == -1) {
toast("接收到返回的数据:" + data?.getStringExtra("text"))
}
}

可以看到虽然标记过时了,但是 startActivityForResult 这种方法是可以用的,我们一直这么用的,老项目中有很多页面都是这么定义的。也并没有什么问题。


不过既然谷歌推荐我们使用 Result Api 我们在以后使用 startActivityForResult 的时候还是推荐使用新的方式。


二、对原生的封装Ghost


在之前我们使用 startActivityForResult 这种方式的时候,为了更加方便的私有,有一种很流行的方式 Ghost 。


它使用一种 GhostFragment 的空视图当做一次中转,这种思路在现在看来已经不稀奇了,很多框架如Glide,权限申请等都是用的这种方案。


它的大致实现流程为:


Activty/Fragment -> add GhostFragment -> onAttach 中 startActivityForResult -> GhostFragment onActivityResult接收结果 -> callback回调给Activty/Fragment


总体需要两个类就可以完成这个逻辑,一个是中转Fragment,一个是管理类:


/**
* 封装Activity Result的API
* 使用空Fragemnt的形式调用startActivityForResult并返回回调
*
* Activty/Fragment——>add GhostFragment——>onAttach中startActivityForResult
* ——>GhostFragment onActivityResult接收结果——>callback回调给Activty/Fragment
*/
class GhostFragment : Fragment() {

private var requestCode = -1
private var intent: Intent? = null
private var callback: ((result: Intent?) -> Unit)? = null

fun init(requestCode: Int, intent: Intent, callback: ((result: Intent?) -> Unit)) {
this.requestCode = requestCode
this.intent = intent
this.callback = callback
}

private var activityStarted = false

override fun onAttach(activity: Activity) {
super.onAttach(activity)
if (!activityStarted) {
activityStarted = true
intent?.let { startActivityForResult(it, requestCode) }
}
}

override fun onAttach(context: Context) {
super.onAttach(context)
if (!activityStarted) {
activityStarted = true
intent?.let { startActivityForResult(it, requestCode) }
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK && requestCode == this.requestCode) {
callback?.let { it1 -> it1(data) }
}
}

override fun onDetach() {
super.onDetach()
intent = null
callback = null
}

}

/**
* 管理GhostFragment用于StartActivityForResult
* 启动的时候添加Fragment 返回的时移除Fragment
*/
object Ghost {
var requestCode = 0
set(value) {
field = if (value >= Integer.MAX_VALUE) 1 else value
}

inline fun launchActivityForResult(
starter: FragmentActivity?,
intent: Intent,
crossinline callback: ((result: Intent?) -> Unit)
) {
starter ?: return
val fm = starter.supportFragmentManager
val fragment = GhostFragment()
fragment.init(++requestCode, intent) { result ->
callback(result)
fm.beginTransaction().remove(fragment).commitAllowingStateLoss()
}
fm.beginTransaction().add(fragment, GhostFragment::class.java.simpleName)
.commitAllowingStateLoss()
}

}

如此我们就可以使用Kotlin的扩展方法来对它进行进一步的封装


//真正执行AcytivityForResult的方法,使用Ghost的方式执行
inline fun <reified T> FragmentActivity.gotoActivityForResult(
flag: Int = -1,
bundle: Array<out Pair<String, Any?>>? = null,
crossinline callback: ((result: Intent?) -> Unit)
) {
val intent = Intent(this, T::class.java).apply {
if (flag != -1) {
this.addFlags(flag)
}
if (bundle != null) {
//调用自己的扩展方法-数组转Bundle
putExtras(bundle.toBundle()!!)
}
}
Ghost.launchActivityForResult(this, intent, callback)
}

使用起来就超级简单了:


    gotoActivityForResult<Demo10Activity> {
val text = it?.getStringExtra("text")
toast("拿到返回数据:$text")
}

gotoActivityForResult<Demo10Activity>(bundle = arrayOf("id" to "123", "name" to "zhangsan")) {
val text = it?.getStringExtra("text")
toast("拿到返回数据:$text")
}

三、Result Api 的使用


其实看Ghost的原来就看得出,他本质上还是对 startActivityForResult 的调用与封装,还是过期的方法,那么如何使用新的方式,谷歌推荐我们怎么用?


Activity Result API :


它是 Jetpack 的一个组件,这是官方用于替代 startActivityForResult() 和 onActivityResult() 的工具,我们以Activity 1.2.4版本为例:



implementation "androidx.activity:activity-ktx:1.2.4"



那么如何基础的使用它呢:


  
private val safLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val data = result.data?.getStringExtra("text")
toast("拿到返回数据:$data")
}
}

//在方法中使用
safLauncher?.launch(Intent(mActivity, Demo10Activity::class.java))


看起来实现很简单,但是有几点要注意,Launcher 的创建需要在onStart生命周期之前,并且回调是在 Launcher 中处理的。并且 这些 Launcher 并不是只能返回Activity的Result的,还有其他的启动方式:


StartActivityForResult()
StartIntentSenderForResult()
RequestMultiplePermissions()
RequestPermission()
TakePicturePreview()
TakePicture()
TakeVideo()
PickContact()
GetContent()
GetMultipleContents()
OpenDocument()
OpenMultipleDocuments()
OpenDocumentTree()
CreateDocument()

可以看到这些方式其实对我们来说很多没必要,在真正的开发中只有 StartActivityForResult 这一种方式是我们的刚需。


为什么?毕竟现在谁还用这种方式申请权限,操作多媒体文件。相信大家也都是使用框架来处理了,所以我们这里只对 StartActivityForResult 这一种方式做处理。毕竟这才是我们使用场景最多的,也是我们比较需要的。


经过分析,对Result Api的封装,我们就剩下的两个重点问题:



  1. 我们把 Launcher 的回调能在启动的方法中触发。

  2. 实现 Launcher 在 Activity/Fragment 中的自动注册。


下面我们就来实现吧。


四、Result Api 的封装


我们需要做的是:


第一步我们把回调封装到launch方法中,并简化创建的对象方式


第二步我们尝试自动注册的功能


4.1 封装简化创建方式

首先第一步,我们对 Launcher 对象做一个封装, 把 ActivityResultCallback 回调方法在 launch 方法中调用。


/**
* 对Result-Api的封装,支持各种输入与输出,使用泛型定义
*/
@SuppressWarnings("unused")
public class BaseResultLauncher<I, O> {

private final androidx.activity.result.ActivityResultLauncher<I> launcher;
private final ActivityResultCaller caller;
private ActivityResultCallback<O> callback;
private MutableLiveData<O> unprocessedResult;

public BaseResultLauncher(@NonNull ActivityResultCaller caller, @NonNull ActivityResultContract<I, O> contract) {
this.caller = caller;
launcher = caller.registerForActivityResult(contract, (result) -> {
if (callback != null) {
callback.onActivityResult(result);
callback = null;
}
});
}

public void launch(@SuppressLint("UnknownNullness") I input, @NonNull ActivityResultCallback<O> callback) {
launch(input, null, callback);
}

public void launch(@SuppressLint("UnknownNullness") I input, @Nullable ActivityOptionsCompat options, @NonNull ActivityResultCallback<O> callback) {
this.callback = callback;
launcher.launch(input, options);
}

}

上门是对Result的基本封装,由于我们只想要 StartActivityForResult 这一种方式,所以我们定义一个特定的 GetSAFLauncher


/**
* 一般我们用这一个-StartActivityForResult 的 Launcher
*/
class GetSAFLauncher(caller: ActivityResultCaller) :
BaseResultLauncher<Intent, ActivityResult>(caller, ActivityResultContracts.StartActivityForResult()) {

//封装另一种Intent的启动方式
inline fun <reified T> launch(
bundle: Array<out Pair<String, Any?>>? = null,
@NonNull callback: ActivityResultCallback<ActivityResult>
) {

val intent = Intent(commContext(), T::class.java).apply {
if (bundle != null) {
//调用自己的扩展方法-数组转Bundle
putExtras(bundle.toBundle()!!)
}
}

launch(intent, null, callback)

}

}

注意这里调用的是 ActivityResultContracts.StartActivityForResult() 并且泛型的两个参数是 Intent 和 ActivityResult。


如果大家想获取文件,可以使用 GetContent() 泛型的参数就要变成 String 和 Uri 。由于我们通常不使用这种方式,所以这里不做演示。


封装第一步之后我们就能这么使用了。


    var safLauncher: GetSAFLauncher? = null

//其实就是 onCreate 方法
override fun init() {
safLauncher = GetSAFLauncher(this@Demo16RecordActivity)
}

//AFR
fun resultTest() {

safLauncher?.launch(Intent(mActivity, Demo10Activity::class.java)) { result ->
val data = result.data?.getStringExtra("text")
toast("拿到返回数据:$data")
}
}

//或者使用我们自定义的简洁方式


    fun resultTest() {

safLauncher?.launch<Demo10Activity> { result ->
val data = result.data?.getStringExtra("text")
toast("拿到返回数据:$data")
}

safLauncher?.launch<Demo10Activity>(arrayOf("id" to "123", "name" to "zhangsan")) { result ->
val data = result.data?.getStringExtra("text")
toast("拿到返回数据:$data")
}
}

使用下来是不是简单了很多了,我们只需要创建一个对象就可以了,拿到这个对象调用launch即可实现 startActivityForResult 的功能呢!


006C7PHRly1g91yv0vybdg307c07nqbz.gif


4.2 自动注册/按需注册

可以看到相比原始的用法,虽然我们现在的用法就简单了很多,但是我们还是要在oncreate生命周期中创建 Launcher 对象,不然会报错:



LifecycleOwners must call register before they are STARTED.



那我们有哪些方法处理这个问题?


1)基类定义


我们都已经封装成对象使用了,我们把创建的逻辑定义到BaseActivity/BaseFragment不就行了吗?


abstract class AbsActivity() : AppCompatActivity(){

protected var safLauncher: GetSAFLauncher? = null

...

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView()

//Result-Api
safLauncher = GetSAFLauncher(this)

...
}

}

这样不就行了吗?可以正常使用的。那有人可能说,你这个对象可能用不到,又不是每一个Activity都会用到 Launcher 对象,你这么无脑创建出来消耗内存。


有办法,按需加载!


2).懒加载


懒加载可以吧,我需要的时候就创建。


abstract class AbsActivity() : AppCompatActivity(){

val safLauncher by lazy { GetSAFLauncher(this) }

...
}

额,等等,这样的懒加载貌似是不行的,这在用的时候才初始化,一样会报错:



LifecycleOwners must call register before they are STARTED.



我们只能在页面创建的时候就要明确,这个页面是否需要这个 Launcher 对象,如果要就要在onCreate中创建对象,如果确定不要 Launcher 对象,那么就不必创建对象。


那我们就这么做:


abstract class AbsActivity() : AppCompatActivity(){

protected var safLauncher: GetSAFLauncher? = null

...

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView()

if (needLauncher()) {
//Result-Api
safLauncher = GetSAFLauncher(this)
}

...
}

open protected fun needLauncher(): Boolean = false

}

我们使用一个flag判断不就行了吗?这个页面如果需要 Launcher 对象,重写方法返回true就行了。默认是不创建这个对象的。


3).Kotlin委托


我们可以使用Kotlin的委托方式,把初始化的代码和 Launcher 的对象获取用接口封装,然后提供对应的实现类,不就可以完成按需添加 Launcher 的效果了吗?


我们定义一个接口,由于逻辑都封装在了别处,这里就尽量不改动之前的代码,只是定义初始化和提供对象两种方法。


/**
* 定义是否需要SAFLauncher
*/
interface ISAFLauncher {

fun <T : ActivityResultCaller> T.initLauncher()

fun getLauncher(): GetSAFLauncher?

}

接着定义这个实现类


class SAFLauncher : ISAFLauncher {

private var safLauncher: GetSAFLauncher? = null

override fun <T : ActivityResultCaller> T.initLauncher() {
safLauncher = GetSAFLauncher(this)
}

override fun getLauncher(): GetSAFLauncher? = safLauncher

}

然后我们就可以使用了:


class Demo16RecordActivity : BaseActivity, ISAFLauncher by SAFLauncher() {

//onCreate中直接初始化对象
override fun init() {
initLauncher()
}


//获取到对象直接用即可,还是之前的几个方法,没有变。
fun resultTest() {

getLauncher()?.launch<Demo10Activity> { result ->
val data = result.data?.getStringExtra("text")
toast("拿到返回数据:$data")
}
}

}

效果都是一样的:


image.png


这样通过委托的方式,我们就能自己管理初始化,自己随时获取到对象调用launch方法。


如果你当前的Activity不需要 startActivityForResult 这种功能,那么你不实现这个接口即可,如果想要 startActivityForResult 的功能,就实现接口委托实现,从而实现按需加载的逻辑。


我们再回顾一下 Result Api 需要封装的两个痛点与优化步骤:



  1. 第一步我们把回调封装到launch方法中,并简化创建的对象方式

  2. 第二步我们尝试自动注册的功能


同时我们还对一些步骤做了更多的可能性分析,对主动注册的方式我们有三种方式,(当然其实还有更多别的方式来实现,我只写了我认为比较简单方便的几种方式)。


到此对 Result Api的封装就此结束。


总结


总的来说 Result Api 的封装其实也不难,使用起来也是很简单了。如果大家是Kotlin项目我推荐使用委托的方式,如果是Java语言开发的也可以用flag的方式实现按需加载的逻辑。


而不想使用 Result Api 那么使用原始的 startActivityForResult 也能实现,那么我推荐你使用 Ghost 框架,可以更加方便快速的实现返回的功能。


本文对于 Result Api 的封装也只是限于 startActivityForResult 这一个场景,不过我们这种方式是很方便扩展的,如果大家想使用Result Api的方式来操作权限,文件等,都可以在 BaseResultLauncher 基础上进行扩展。


本文全部代码均以开源,源码在此。大家可以点个Star关注一波。


好了,本期内容如有错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。


Ok,这一期就此完结。



作者:newki
链接:https://juejin.cn/post/7136359176564899877
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android悬浮窗自己踩的2个小坑

最近在做一个全局悬浮窗基于ChatGPT应用快Ai,需要悬浮于在其他应用上面,方便从悬浮窗中,和ChatGPT对话后,对ChatGPT返回的内容拖拽到其他应用内部。快Ai应用本身透明,通过WindowManger添加悬浮窗。类似现在很多应用跳转到其他应用,会悬...
继续阅读 »

最近在做一个全局悬浮窗基于ChatGPT应用快Ai,需要悬浮于在其他应用上面,方便从悬浮窗中,和ChatGPT对话后,对ChatGPT返回的内容拖拽到其他应用内部。快Ai应用本身透明,通过WindowManger添加悬浮窗。类似现在很多应用跳转到其他应用,会悬浮一个小按钮,方便用户点击调回自身一样。只不过快Ai窗口比较大,但不全屏。


碰到以下几个问题:


1、悬浮窗中EditText无法获得弹出键盘


主要是没有明白下面两个属性的作用,在网上搜索之后直接设置了。



  • WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE


设置FLAG_NOT_FOCUSABLE,悬浮窗外的点击才有效,会把事件分发给悬浮窗底层的其他应用Activity。但设置了FLAG_NOT_FOCUSABLE,屏幕上除悬浮窗之外的地方也可以点击、但是悬浮窗上的EditText会掉不起键盘。


此时悬浮窗外的事件是不会触发悬浮窗内ViewonToucheEvent函数,可以通过添加WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH标志位,但无法拦截事件。




  • WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL


    屏幕上除了悬浮窗外能够点击、弹窗上的EditText也可以输入、键盘能够弹出来。




所以根据业务需要,我只需要添加WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL标志位即可。


2、悬浮窗无法录音


通过Activity调起Service,然后在Service通过WindowManager添加悬浮窗。在没有进行任何操作,正常情况下,可以调起科大讯飞进行录音转成文字发给ChatGPT。


问题点一:同事为了解决我还没来得及修复的windowManger.removeView改成exitProcess问题,强行进行各种修改,最终还调用了activityfinish函数,把activity干掉。最终导致无法调起科大讯飞的语音识别。总是报录音权限问题,找不到任何的问题点,网上资料都说没有给录音权限,其实是有的。最后通过代码回退,定位到是Activity被干掉了,同事也承认他的愚蠢行为。


问题点二:在进行一些操作,例如授权跳转到设置之后,退出设置回到原先界面,科大讯飞调不起录音,还是报权限问题。在有了问题点一的经验后,在Activity的各个生命周期打印日志,发现但onResume函数没有被回调到,也就是应用在后台运行时,该问题必现。


所以就一顿顿顿搜索后,找到官方文档:
Android 9 对后台运行的应用增加了权限限制。


image.png


解决方法:



  1. 声明为系统应用,没问题。但我们想做通用软件。

  2. 增加前台服务。实测没效果。

  3. 在2的基础上,再添加一个属性:android:foregroundServiceType="microphone"。完美。


<service android:name=".ui.service.AiService"
android:foregroundServiceType="microphone"
/>

image.png


希望本文对君有用!


作者:新小梦
链接:https://juejin.cn/post/7211116982513811516
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

那些拿20k💰的大佬在职场都是怎么沟通的❓

☀️ 前言大家好我是小卢,职场沟通是每个职场人必备的技能,但是如何提高职场沟通能力却是需要不断学习和实践。下面就给大家带来四点方法能很大程度提升你在职场的沟通能力。⌨️ 了解每个人的沟通方式每个人的个性、经验和教育背景都不同,这些因素都会影响到个人的沟通方式。...
继续阅读 »

☀️ 前言

  • 大家好我是小卢,职场沟通是每个职场人必备的技能,但是如何提高职场沟通能力却是需要不断学习和实践。

  • 下面就给大家带来四点方法能很大程度提升你在职场的沟通能力。

⌨️ 了解每个人的沟通方式

  • 每个人的个性、经验和教育背景都不同,这些因素都会影响到个人的沟通方式。有些人可能会喜欢直接表达自己的想法和意见,而有些人可能更倾向于暗示询问

  • 在实际职场生活中,我们不仅需要了解自己的沟通方式,还需要了解对方的沟通方式。简单举个例子吧:

  • 假设你是一个刚入公司不久的产品经理,你每周或者每月都需要给上司做一个工作汇报。

    • 你知道你的上司更喜欢使用图表和数据进行沟通,你可以准备好相关的数据和图表,并在开会过程中使用它们。这可以帮助你的上司更好地理解你的意思,并更快地进入到讨论的核心问题。

    • 你知道你的上司比较喜欢使用直接表达的方式来沟通,在这种情况下,直接表达你的想法和意见可能更为有效。你可以以明确的方式表达你对项目的看法,并解释你的看法背后的原因。

  • 在与他人交流时,我们需要时刻注意自己的语言、态度和非语言信号,并根据对方的反应进行调整。这需要一定的敏感度和经验,但是通过不断地练习和反思,我们可以逐渐提高自己的职场沟通能力,并取得更好的效果。

🤗 注意语速和语调

  • 职场沟通中,语速和语调是非常重要的因素,它们往往可以决定对方对你的印象和理解,如果你的语速太快或者语调不合适,很容易让对方感到困惑或者不舒服。

  • 除了注意自己的语速和语调,我们还需要注意对方的语速和语调。如果在某次交谈中你发现对方特别激动,说话特别快导致你不能全部理解,你可以说:“你说的内容非常重要,我来总结一下刚刚你分享的信息,看看是否符合预期,以便我能更好地理解你的意思?”

  • 这样的话语不仅能够有效地表达自己的需求,也能够尊重对方的沟通方式,让双方都能够更好地理解彼此。

👂 学会倾听

  • 职场沟通不仅仅是说话,更重要的是倾听。倾听意味着不仅是听别人说话,还包括尊重对方的意见和观点,关注对方的情感和态度,以及在适当的时候提出问题和反馈,以达到更好的沟通效果。

  • 要成为一个好的倾听者,我们需要全神贯注地聆听对方说话。这意味着不要分心,不要中途打断对方,而是要给对方充足的时间和空间来表达自己的想法和意见。如果你有不同的看法或者意见,可以先把它们记在脑海里,等对方表达完后再进行回应。

  • 在倾听的过程中,我们还需要注意对方的情绪和表情。通过观察对方的肢体语言和面部表情,我们可以更好地了解对方的真实意图和情感状态,从而更好地回应和理解对方的想法和需求,建立更好的信任和合作关系。

  • 举个例子吧:假设你是一个团队的领导,正在讨论下一步的项目计划。你发现其中一个成员很少发表意见,似乎对讨论不太感兴趣。你可以采取主动倾听的方式,问他对当前的计划有何看法,或者给他更具体的问题,以激发他的参与度。这样可以让他感受到自己的意见被认真听取,也有助于整个团队更好地理解和解决问题

👺 简明扼要

  • 简明扼要是职场中非常重要的一个点。当你需要向同事或客户提出需求时,最好提前思考好问题的前提条件、现状和问题的分支情况,一次性把问题说明白,尽量减少来回问答的次数,这样可以更有效地利用大家的时间和精力。

  • 为了让自己的观点更清晰地传达给别人,你可以先说出结论和重点,然后再说明为什么这么认为,并提供相关的事实依据。在接受问题或错误的指责时,也应该直接说明问题并找到解决办法,而不是遮掩或解释,以保证工作的顺利完成。

  • 我有一个同事在公司寻求大佬帮助的时候把前置说了很久,导致一直进入不到重点,别人根本不知道你想表达什么,这不仅浪费了别人的时间,还会让人对你产生厌恶。

  • 你可以简单干脆一点:这个问题导致了 xxx,影响了 xxx 的用户,他的原因是 xxx,我的想法是 xxx ,所以想问一下有没有更好的方案?


作者:快跑啊小卢_
来源:juejin.cn/post/7213744141737803832

收起阅读 »

平庸的恐惧,就业的烦恼——致互联网人进退两难的35岁!

最近阿道看到了一些黑色幽默的新闻。 事情是这样的,某媒体发文抨击职场的“35岁”歧视,但后来被扒出,该媒体所属的机构在发布招聘信息时,却明确地标注了受聘者的年龄界限。 这一通操作属实把大家看傻了,后来阿道又在网上查了一波互联网大厂的平均年龄: 根据DT财...
继续阅读 »

最近阿道看到了一些黑色幽默的新闻。


事情是这样的,某媒体发文抨击职场的“35岁”歧视,但后来被扒出,该媒体所属的机构在发布招聘信息时,却明确地标注了受聘者的年龄界限。


3.22.1.png


3.22.4.png


这一通操作属实把大家看傻了,后来阿道又在网上查了一波互联网大厂的平均年龄:


3.22.3.png


根据DT财经数据得出:主要互联网公司员工年龄中位数大多低于35岁,最“老”的也才只有33岁。

3.22.2.png


整个社会似乎在营造一种焦虑、恐惧的氛围,仿佛“35岁”成了互联网人前半生的ddl 。“35岁后不能考公考编”、“互联网大厂35岁裁员潮”、“35岁失业”………总是有一种到了35岁,人就过完了一生的感觉。

在这个行业里工作,头脑聪不聪明,知识更新是否高频、工作成绩好不好、有没有能力迅速完成任务、对公司有没有贡献,成为了互联网人的职业标尺,我们就是在用这些度量行业里个体的价值高低,并在此基础上形成了互联网行业“独特”的价值观。也可以说,正是这些标尺和价值体系,驱赶着人们陷入无限竞争和内卷,这是互联网人的隐痛,也成为了这个行业最恶毒的诅咒。


被重复而繁重的工作穷追不舍的这些年,我们已经逐渐丧失了思考能力,也没有时间停下脚步好好审视自己所做的。我们的一切都在高速发展的行业掌控之下,统一的体系管理,任务没有尽头,每天都在拼命完成指标,工作丧失了热情,没了思考。为什么我们会从三十而立,变成三十而栗,到最后的三十而离呢?


当一个人被淘汰、或者一部分人被淘汰,于是,我们就得到结论:做技术没前途,并且为这个行业亲手打上标签。但仔细想想,这个逻辑真的成立吗?


我们没有思考本身做的这个技术岗位,在整个行业处于什么阶段;也没有思考我们现阶段所掌握的技术,在行业内的应用场景是否落后;没有思考我们和行业中的技术大牛差距在哪;没有思考怎么增加自己的影响力,并保持对行业的敏感。


35岁,拼的不光是知识和技术,更多的是体力和眼界。一方面,这个行业的任务重、压力大,无休止地工作到35岁,每个人的心理和生理所承受压力的都到达了一个临界点;另一方面,面对日新月异的技术更新,我们是否还能够在每天的工作压力下,持续不断地吸收最新的信息和知识,掌握最新软件的技术,掌握最新技术和知识动态。阿道是这样认为的:


1. 注意身体健康


身体是革命的本钱,这句话当然对任何行业都适用,但对于程序员这个群体来说,问题似乎更为严峻一些。996、脱发、高强度工作等等都是这个职业的关键词,在这样的形势下,我们更应该注意身体的基本情况。早睡早起少熬夜、健康饮食多运动,这些道理听的耳朵都起茧子了,能真正做到的还是少数。那么,换个思路:
相比于多睡觉、不熬夜,晚上按1.5小时睡眠周期的倍数来睡觉,午休控制在30分钟,也许会更舒服;
相比于完全按膳食宝塔的来健康饮食,日常中让奶茶减点糖、把炸串换成麻辣烫,也是一种进步;
相比于一定要去健身房、一定要跑五公里,换个升降桌站一会儿办公,定个闹钟提醒自己起个身,也许会无形中缓解很多病痛。
良好的心态是,别让“不健康”的焦虑束缚住,造成心态-身体的恶性循环,在日常微小习惯中就做好。


2. 重视项目管理


项目管理能力是很容易被忽视的一项语言、技术之外的能力,但实际上,招聘机构Dice、美国《财富》杂志的内容都显示,项目管理能力是一项职场人士的核心管理能力。


人人都可以是自己的项目经理。这并不意味着人人都要有项目经理的权力,而是指要具备项目经理的思维和方法。生活中亦然。项目不分大小,任何一件事情都可以看作一个项目,执行这件事情的人就都是项目经理。它可以是设计开发某一个产品功能,可以是一个学习计划,也可以是房屋装修改造、活动筹备、旅行等等。


在工作、生活中时常具备项目管理的意识,可以从项目管理的干系人管理、风险管理、计划管理、质量管理等方面入手,其实也就是人力、资源、时间、质量等不同方面的协调。凡事的底层逻辑无非都是这几点,做好这些,上文所述的任何“项目”都可以无往而不利。


3. 保持学习能力


现在几乎所有的“给大学生的建议”、“给职场人的建议”、“给35岁的建议”等人生建议都少不了主动学习这一条,好像下班后不再上个补习班就对不起自己的人生。Take it easy,“主动学习”没有那么夸张,我们几乎无时无刻不在“学习”:从同事那种草了一套新快捷键、在微信文章中了解到行业趋势、学会了新的伸展动作……其实我们无时无刻不在学习。而保持空杯心态、意识到这个过程,并不断内化应用到自己工作中,精进自己的技能,也能够积水成渊。辅之以系统化成体系的学习、总结,这种学习能力就已经远超许多人。


4.程序员职业素养


回归到我们35岁程序员本身,如果说程序员写代码时熟练掌握编程语言是基础技能,那么代码可读性、代码规范等等则是必不可少的职业素养。听过业内老师的一句话,让人印象很深:“很多程序员只能说是会写代码,工作习惯都非常不好,他们对于如何高质量开发软件缺乏应有的sense。”


这里说的就是包括但不限于软件架构、代码整洁、代码可读性、重构等编程时该拥有的良好习惯,这些职业素养也将成为优秀程序员和平庸程序员的区别。


作为个体,我们确实无力与大环境对抗,但就像有种对VUCA时代的解读,原意是描述客观世界变化的:



  • Volatility(易变性)

  • Uncertainty(不确定性)

  • Complexity(复杂性)

  • Ambiguity(模糊性)


我们作为社会性的人,可以将之转变为主观能动的:



  • Various(多样性)

  • Universal(全球性)

  • Changing(各种可能)

  • Advancing(保持前进)


总而言之,我们想要在事业上走的更远,一方面是精神上不能松懈,不能有躺平心态,一定要持续不断的学习,提升能力水平;另一方面是要更加注意身体健康,拼事业拼到最后拼的就是体力。每天鼓励自己进步一点点,最终实现从“三十而离”到“三十而励”的跨越!


作者:禅道程序猿
来源:juejin.cn/post/7213267462305267773
收起阅读 »

Kotlin委托的原理与使用,以及在Android开发中常用的几个场景

Kotlin委托的常见使用场景 前言 在设计模式中,委托模式(Delegate Pattern)与代理模式都是我们常用的设计模式(Proxy Pattern),两者非常的相似,又有细小的区分。 委托模式中,委托对象和被委托对象都是同一类型的对象,委托对象将任务...
继续阅读 »

Kotlin委托的常见使用场景


前言


在设计模式中,委托模式(Delegate Pattern)与代理模式都是我们常用的设计模式(Proxy Pattern),两者非常的相似,又有细小的区分。


委托模式中,委托对象和被委托对象都是同一类型的对象,委托对象将任务委托给被委托对象来完成。委托模式可以用于实现事件监听器、回调函数等功能。


代理模式中,代理对象与被代理对象是两种不同的对象,代理对象代表被代理对象的功能,代理对象可以控制客户对被代理对象的访问。代理模式可以用于实现远程代理、虚拟代理、安全代理等功能。


以类的委托与代理来举例,委托对象和被委托对象都实现了同一个接口或继承了同一个类,委托对象将任务委托给被委托对象来完成。代理模式中,代理对象与被代理对象实现了同一个接口或继承了同一个类,代理对象代表被代理对象,客户端通过代理对象来访问被代理对象。


两者的区别:


他们虽然都有同一个接口,主要区别在于委托模式中委托对象和被委托对象是同一类型的对象,而代理模式中代理对象与被代理对象是两种不同的对象。总的来说,委托模式是为了将方法的实现交给其他类去完成,而代理模式则是为了控制对象的访问,并在访问前后进行额外的操作。


而我们常用的委托模式怎么使用?在 Java 语言中需要我们手动的实现,而在 Kotlin 语言中直接通过关键字 by 就可以实现委托,其实现更加优雅、简洁了。


我们在开发一个 Android 应用中,常用到的委托分为:



  1. 接口/类的委托

  2. 属性的委托

  3. 结合lazy的延迟委托

  4. 观察者的委托

  5. Map数据的委托


下面我们就一起看看不同种类的委托使用以及在 Android 常见的一些场景中的使用。


一、接口/类委托


我们可以选择使用接口来实现类似的效果,也可以直接传参,当然接口的方式更加的灵活,比如我们这里就以接口比如我定义一个攻击与防御的行为接口:


interface IUserAction {

fun attack()

fun defense()
}

定义了用户的行为,有攻击和防御两种操作!接下来我们就定义一个默认的实现类:


class UserActionImpl : IUserAction {

override fun attack() {
YYLogUtils.w("默认操作-开始执行攻击")
}

override fun defense() {
YYLogUtils.w("默认操作-开始执行防御")
}
}

都是很简单的代码,我们定义一些默认的操作,如果任意类想拥有攻击和防御的能力就直接实现这个接口,如果想自定义攻击和防御则重写对应的方法即可。


如果使用 Java 的方式实现委托,大致代码如下:


class UserDelegate1(private val action: IUserAction) : IUserAction {
override fun attack() {
YYLogUtils.w("UserDelegate1-需要自己实现攻击")
}

override fun defense() {
YYLogUtils.w("UserDelegate1-需要自己实现防御")
}
}

如果使用 Kotlin 的方式实现则是:


class UserDelegate2(private val action: IUserAction) : IUserAction by action

如果 Kotlin 的实现不想默认的实现也可以重写部分的操作:


class UserDelegate3(private val action: IUserAction) : IUserAction by action {

override fun attack() {
YYLogUtils.w("UserDelegate3 - 只重写了攻击")
}
}

那么使用起来就是这样的:


    val actionImpl = UserActionImpl()

UserDelegate1(actionImpl).run {
attack()
defense()
}

UserDelegate2(actionImpl).run {
attack()
defense()
}

UserDelegate3(actionImpl).run {
attack()
defense()
}

打印日志如下:


image.png


其实在 Android 源码中也有不少委托的使用,例如生命周期的 Lifecycle 委托:


Lifecycle 通过委托机制实现其功能。具体来说,组件可以将自己的生命周期状态委托给 LifecycleOwner 对象,LifecycleOwner 对象则负责管理这些组件的生命周期。


例如,在一个 Activity 中,我们可以通过将 Activity 对象作为 LifecycleOwner 对象,并将该对象传递给需要注册生命周期的组件,从而实现组件的生命周期管理。 页面可以使用 getLifecycle() 方法来获取它所依赖的 LifecycleOwner 对象的 Lifecycle 实例,并在需要时将自身的生命周期状态委托给该 Lifecycle 实例。


通过这种委托机制,Lifecycle 实现了一种方便的方式来管理组件的生命周期,避免了手动管理生命周期带来的麻烦和错误。



class AnimUtil private constructor() : DefaultLifecycleObserver {

...

private fun addLoopLifecycleObserver() {
mOwner?.lifecycle?.addObserver(this)
}

// 退出页面的时候释放资源
override fun onDestroy(owner: LifecycleOwner) {
mAnim?.cancel()
destory()
}

}


除此之外委托还特别适用于一些可配置的功能,比如 Resutl-Api 的封装,如果当前页面需要开启 startActivityForResult 的功能,就实现这个接口,不需要这个功能就不实现接口,达到可配置的效果。


/**
* 定义是否需要SAFLauncher
*/

interface ISAFLauncher {

fun <T : ActivityResultCaller> T.initLauncher()

fun getLauncher(): GetSAFLauncher?

}

由于代码是固定的实现,目标Activity也不需要重新实现,我们只需要实现默认的实现即可:


class SAFLauncher : ISAFLauncher {

private var safLauncher: GetSAFLauncher? = null

override fun <T : ActivityResultCaller> T.initLauncher() {
safLauncher = GetSAFLauncher(this)
}

override fun getLauncher(): GetSAFLauncher? = safLauncher

}

使用起来我们直接用默认的实现即可:


class DemoActivity : BaseActivity, ISAFLauncher by SAFLauncher() {

override fun init() {
initLauncher() // 实现了接口还需要初始化Launcher
}

fun gotoOtherPage() {
//使用 Result Launcher 的方式启动,并获取到返回值
getLauncher()?.launch<DemoCircleActivity> { result ->
val result = result.data?.getStringExtra("text")
toast("收到返回的数据:$result")
}

}

}

这样是不是就非常简单了呢?具体如何使用封装 Result Launcher 可以看看我去年的文章 【传送门】


二、属性委托


除了类与接口对象的委托,我们还常用于属性的委托。


我知道了!这么弄就行了。


    private val textStr by "123"

哎?怎么报错了?其实不是这么用的。


属性委托和类委托一样,属性的委托其实是对属性的 set/get 方法的委托。


需要我们把 set/get 方法委托给 setValue/getValue 方法,因此被委托类(真实类)需要提供 setValue/getValue 方法,val属性只需要提供 getValue 方法。


我们修改代码如下:


    private val textStr by TextDelegate()

class TextDelegate {

operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

}

打印的结果:


image.png


而我们定义一个可读写的属性则可以


  private var textStr by TextDelegate()

class TextDelegate {

operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
YYLogUtils.w("设置的值为:$value")
}

}

YYLogUtils.w("textStr:$textStr")
textStr = "abc123"

打印则如下:


image.png


为了怕大家写错,我们其实可以用接口来限制,只读的和读写的属性,我们分别可以用 ReadOnlyProperty 与 ReadWriteProperty 来限制:



class TextDelegate : ReadOnlyProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
return "我是赋值给与的文本"
}
}

class TextDelegate : ReadWriteProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

override fun setValue(thisRef: Any, property: KProperty<*>, value: String) {
YYLogUtils.w("设置的值为:$value")
}
}


那么实现的方式和上面自己实现的效果是一样的。如果要使用属性委托可以选用这种接口限制的方式实现。


我们的属性除了委托给类去实现,同时也能委托给其他属性(Kotlin 1.4+)来实现,例如:


    private var textStr by TextDelegate2()
private var textStr2 by this::textStr

其实是内部委托了对象的 get 和 set 函数。相对委托对象而言性能更好一些。而委托对象去实现,不仅增加了一个委托类,而且还还在初始化时就创建了委托类的实例对象,算起来其实性能并不好。


所以属性的委托不要滥用,如果要用,可以选择委托现成的其他属性来完成,或者使用延迟委托Lazy实现,或者使用更简单的方式实现:


    private val industryName: String
get() {
return "abc123"
}

对于只读的属性,这种方式也是我们常见的使用方式。


三、延迟委托


如果说使用类来实现委托不那么好的话,其实我们可以使用延迟委托。延迟关键字 lazy 接收一个 lambda 表达式,最后一行代表返回值给被推脱的属性。


默认的 Lazy 实现:


    val name: String by lazy {
YYLogUtils.w("第一次调用初始化")
"abc123"
}

YYLogUtils.w(name)
YYLogUtils.w(name)
YYLogUtils.w(name)

只有在第一次使用此属性的时候才会初始化,一旦初始化之后就可以直接获取到值。


日志打印:


image.png


它的内部其实也是使用的是类的委托实现。


public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)


最终的实现是由 SynchronizedLazyImpl 类生成并实现的:


private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required to enable safe publication of constructed instance
private val lock = lock ?: this

override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}

return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}

override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

private fun writeReplace(): Any = InitializedLazyImpl(value)
}


我们可以直接看 value 的 get 方法,如果_v1 !== UNINITIALIZED_VALUE 则表明已经初始化过了,就直接返回 value ,否则表明没有初始化过,调用initializer方法,也就是 lazy 的 lambda 表达式返回属性的赋值。


跟我们自己实现类的委托类似,也是实现了getValue方法。只是多了判断是否初始化的一些相关逻辑。


lazy的参数分为三种类型:



  1. SYNCHRONIZED:添加同步锁,使lazy延迟初始化线程安全

  2. PUBLICATION:初始化的lambda表达式,可以在同一时间多次调用,但是只有第一次的返回值作为初始化值

  3. NONE:没有同步锁,非线程安全


默认情况下,对于 lazy 属性的求值是同步锁的(synchronized),是可以保证线程安全的,但是如果不需要线程安全和减少性能花销可以可以使用 lazy(LazyThreadSafetyMode.NONE){} 即可。


四、观察者委托


除了对属性的值进行委托,我们甚至还能对观察到这个变化过程:


使用 observable 委托监听值的变化:


    var values: String by Delegates.observable("默认值") { property, oldValue, newValue ->

YYLogUtils.w("打印值: $oldValue -> $newValue ")
}

values = "第一次修改"
values = "第二次修改"
values = "第三次修改"

打印:


image.png


我们还能使用 vetoable 委托,和 observable 一样可以观察属性的变化,不同的是 vetoable 可以决定是否使用新值。


    var age: Int by Delegates.vetoable(18) { property, oldValue, newValue ->
newValue > oldValue
}

YYLogUtils.w("age:$age")
age = 14
YYLogUtils.w("age:$age")
age = 20
YYLogUtils.w("age:$age")
age = 22
YYLogUtils.w("age:$age")
age = 20
YYLogUtils.w("age:$age")

我们需要返回 booble 值觉得是否使用新值,比如上述的例子就是当新值大于老值的时候才赋值。那么打印的日志就是如下:


image.png


虽然这种方式我们并不常用,一般我们都是使用类似 Flow 之类的工具在源头就处理了逻辑,使用这种方式我们就可以在属性的赋值过程中进行拦截了。在一些特定的场景下还是有用的。


五、Map委托


我们的属性不止可以使用类的委托,延迟的委托,观察的委托,还能委托Map来进行赋值。


当属性的值与 Map 中 key 相同的时候,我们可以把对应 key 的 value 取出来并赋值给属性:


class Member(private val map: Map<String, Any>) {

val name: String by map
val age: Int by map
val dob: Long by map

override fun toString(): String {
return "Member(name='$name', age=$age, dob=$dob)"
}

}

使用:


        val member = Member(mapOf("name" to "guanyu", "age" to 36, Pair("dob", 1234567890L)))
YYLogUtils.w("member:$member")

打印的日志:


image.png


但是需要注意的是,map 中的 key 名字必须要和属性的名字一致才行,否则委托后运行解析时会抛出 NoSuchElementException 异常提示。


例如我们在 Member 对象中加入一个并不存在的 address 属性,再次运行就会报错。


image.png


而我们把 Int 的 age 属性赋值给为字符串也会报类型转换异常:


image.png


所以一定要一一对应才行哦,我怎么感觉有一点 TypeScript 结构赋值的那味道 - - !


image.png


总结


委托虽好不要滥用。委托毕竟还是中间多了一个委托类,如果没必要可以直接赋值实现,而不需要多一个中间类占用内存。


我们可以通过接口委托来实现一些可选的配置。通过委托类实现属性的监听与赋值。可以减少一些模板代码,达到低耦合高内聚的效果,可以提高程序的可维护性、可扩展性和可重用性。


对于属性的类委托,我们可以将属性的读取和写入操作委托给另一个对象,或者另一个属性,或者使用延迟委托来推迟对象的创建直到第一次访问。


对于 map 的委托,我们需要仔细对应属性与 key 的一致性。以免出现错误,这是运行时的错误,有可能出现在生产环境上的。


那么大家都是怎么使用的呢?有没有更好的方式呢?或者你有遇到的坑也都可以在评论区交流一下,大家可以互相学习进步。如有本文有一些错漏的地方,希望同学们可以指出。


如果感觉本文对你有一点点的帮助,还望你能点赞支持一下,你的支持是我最大的动力。


本文的部分代码可以在我的 Kotlin 测试项目中看到,【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


Ok,这一期就此完结。




作者:newki
来源:juejin.cn/post/7213267574770090039
收起阅读 »

Dart 与 Java & Kotlin 差异一览

前言 最近学习Flutter,发现其使用的Dart语言,有些方面很像Java,有些方面又很像Kotlin,所以整理下目前发现的区别点,一方面方便自己记忆,另一方面也希望可以给尚未接触过Flutter小伙伴们提供一些帮助。(本文仅从Dart语言使用角度对比Jav...
继续阅读 »

前言


最近学习Flutter,发现其使用的Dart语言,有些方面很像Java,有些方面又很像Kotlin,所以整理下目前发现的区别点,一方面方便自己记忆,另一方面也希望可以给尚未接触过Flutter小伙伴们提供一些帮助。(本文仅从Dart语言使用角度对比Java & Kotlin。)


1. 基本数据类型


Dart中,只有三种基本数据类型,数字型(num),布尔型(bool),字符串类型(String)。容器类型如List ,Map,是否属于基本数据类型,这里暂不讨论,毕竟使用也很简单。


类型DartJavaKotlin
布尔boolbooleanBoolean
数字num (int / double)int / double / long / char /byte ...Int / Double /Long / Char / Byte...
字符串StringStringString

1.1 数字类型




  • num 在Dart 中为抽象类,具有intdouble两个实现类,使用num 为类型定义变量时,会进行变量类型推断,推断为对应的实现类(int/ double)。


        ///其中需要注意,Dart中num 同样可以作为数据类型使用,如:
    num a = 10; (整数型)
    num b = 10.0; (浮点型)

    int c = 10; (整数型)
    double d = 10.00; (浮点型)



  • int 类型不仅可以表示整形数字,还代表byte 及 char类型数据,具体使用方式如下:


        ///byte 类型
    int x = 65;
    print(x.toRadixString(2));// 输出 1000001

    ///char 类型
    List<int> codes = [65, 66];
    for(var element in codes) {
    print(String.fromCharCode(element); //输出 AB
    }



1.2 字符串类型




  • 先看下Dart中字符串的定义,大概与Java 和 Kotlin相同:


      字符串定义:
    ///单引号定义字符串
    String e = 'hello world';
    String g = '''hello world''';

    ///双引号定义字符串
    String f = "hello world";
    String h = """hello world""";



  • 其中使用三引号'''"""时,会跟随文本换行,而双引号与单引号""''不会,单引号主动换行需要借助\n换行符。单引号定义的字符串中可以包含双引号,双引号定义的字符串中可以包含单引号,
    同类引号中无法包含同类引号,如:


        错误使用:
    String a = "----"hello world"----";
    String b = '----'hello world'----';

    正确使用:
    String a = '----"hello world"----';
    String b = "----'hello world'----";



  • Dart 支持 Kotlin 字符串拼接方式:


        String name = "Child";
    String s = "$name, hello world, ${name}"



2.语法区别


Dart的语法与Java基本是相同的,只不过在细节上有些差异,Dart在Java基础上,进行了优化。Dart在Java基础上,进行了优化,使其更加简洁,方便。


2.1 构造函数




  • Dart中类构造函数写法有很多种,既可以使用与Java完全一样的写法,也可以使用Dart特有写法,具体写入如下:


        Class TestA {
    int a = 0;
    int b = 0;
    ///与Java相同的基本写法
    TestA(int a, int b) {
    this.a = a;
    this.b = b;
    }

    ///Dart 特有构造写法,
    ///方式1:
    TestA(this.a, this.b);

    ///方式2:
    TestA(int x, int y)
    : this.a = x,
    this.b = y;

    ///方式3:命名构造,与Kotlin中的扩展函数类似,但功能完全不同。
    TestA.instance(this.a, this.b);
    }

    个人感觉,为了方便与Java区分,不建议使用与Java相同的构造写法,而且Dart特有的构造写法,更加简洁。




2.2 对象操作




  • 对象创建:


        ///Dart中,可以和Java一样相同,使用new关键字创新对象
    TestA a = new TestA();

    ///同样也可以使用Kotlin方式一样,创建对象
    TestA b = TestA();
    var c = TestA();



  • 对象属性赋值:


        ///通用赋值方式:
    TestA object = TestA();
    object.x = 10;
    object.y = 20;

    ///Dart特有赋值方式:
    TestA object = TestA()
    ..x = 10
    ..y = 20;

    Dart特有的赋值方式看起来有些奇怪,但是多看看也就习惯了,注意分号()在赋值结束后添加,赋值过程中不需要加。




2.3 空安全




  • Dart 中拥有与Kotlin 相同的变量空安全机制。在定义可为空的变量时,需要在变量后加 ?,示例如下:


        class Test {
    String? x = null;

    void method() {
    ///当变量可能为null时,添加问号,检查对象是否为null,不为null时,才会实行
    print(x?.length);
    ///类似Java 三元表达式,Kotlin变量判断是否为null,若为null,则赋予对应值。
    String y = x ?? 'hello word';
    ///当非常非常非常确定,变量不为null时,可以使用!,强制声明变量肯定不为null
    print(x!.length);
    }
    }



2.4 可变参数




  • Dart具有与Kotlin相同的可变参数的功能,只是实现方式有些许不同,示例如下:


        class Test() {
    ///命名参数,required修饰的参数都为必填
    void method1({required int a, required int b}) {
    print('add = ${a + b}');
    }

    ///默认参数,可以为参数赋予默认值,使用时,可以不传入该参数
    void method2({required int a, int b = 0}) {
    print('add = ${a + b}');
    }

    /// 位置参数,其特点是必须按顺序依次进行指定若干入参
    void method3(int a, [int b = 1, int c = 0]) {
    print('param: a = ${a}, b = ${b}, c = ${c}');
    }

    void test() {
    method1(a: 10, b 10);
    method2(a: 10);
    method3(10);
    method3(10, 20);
    method3(10, 20, 30);
    }

    }



3.关键字区别


这里将从Dart与Java不同的关键字,讲述不同的关键字对功能及编码方面的影响。


3.1 可见范围关键字




  • Dart的类,方法,变量只有两种访问类型,可访问/不可访问:




  • 在类名,方法名,变量名前添加 _ (下划线),即为外部类不可访问;没有 _ (下划线)为可访问。




  • 没有访问范围控制关键字,public, private, protect。


        class TestB {
    ///公共变量
    int a = 10;
    ///私有变量,仅能在本类中调用
    int _b = 20;
    ///常量定义, 与Kotlin中定义相同
    const c = 30;
    ///相当于Kotlin的 lateinit,延迟初始化变量
    late String d;

    ///公共方法,可以供内部/外部类调用
    void method1() {

    }

    ///私有方法,只能在本类调用
    void _method2() {
    var object = _TestC();
    }

    //静态方法,与Java使用方式一致,TestB.method3() 调用
    static void method3() {
    }
    }

    ///私有类,访问范围在本.dart文件中(在TestB类中可以访问),其他文件中无法访问
    class _TestC {

    }



3.2 interface & implement 使用区别:




  • Dart中, 没有interface 接口关键字的定义,但是有implement




  • implement 关键字使用,可以实现所有类:抽象类及普通类,需要实现类中所有定义的变量及方法,如下图所示:


       implement 实现普通类:

    class BaseA {
    int x = 10;

    void method1() {
    }
    }

    class ImplementA implements BaseA {
    @override
    int x = 0;

    @override
    void method1() {
    //TODO
    }
    }

        implement 实现抽象类:

    abstract class BaseB {
    final int a = 0;

    void method1();

    void method2() {
    }
    }

    class ImplementB implement BaseB {
    @override
    //TODO
    int get a => 0;

    @overide
    void method1() {
    //TODO
    }

    @override
    void method2() {
    //TODO
    }
    }



  • 与Java & Kotlin相同,一个类可以实现多个(接口)类。




  • 接口二义性问题解决:当 C 类实现 A 、B 接口,会强制重写所有方法,成员变量提供 get 方法;即在当前类,方法只具有一种实现,变量值需重新赋值,这样就解决了二义性问题。示例如下:


        class C implement A, B {
    @override
    String str = 'hello world';

    @override
    void go() {
    //TODO
    }
    }



3.3 with & mixin 混入




  • 含义:with & mixin 为Dart实现混入(mixins)的关键字,混入是指将一个类的代码插入到另一个类中,以增强该类的功能,而不需要创建一个新的子类。




  • 作用:实现类功能扩展(可以同时混入多个)。比如Java & Kotlin 可以通过内部类的形式,来扩展类功能。




  • 与普通类区别:混入类,没有构造方法,无法实例化。




  • 与接口区别:接口只定义一类功能接口,没有完整功能实现;混入类需具备完整功能实现。


        ///混入类定义
    mixin Write {
    final String word = 'hello world';

    void write() {
    print('person can write: $word');
    }
    }

    ///一般类接入混入类,引入混入类实现功能
    class Person with Write {
    @override
    String get word => 'hello word! ++';
    }

    class Test {
    void method() {
    Person p = Person();
    p.write();
    }
    }



混入类功能与接口类似,所以同样存在二义性问题,那么混入类是如何解决二义性问题的呢?



  • 如C 以先A ,后B顺序混入两个类,A, B 中都含有一个变量名name的字符串,混入C后,打印字符串name,显示的为后混入B类中name的值。
    即混入多个类时,若定义的相同类型&相同名称的变量,值为最后混入的类的值。

  • 若变量名相同,但变量类型不同,同时混入会报错。


3.4 extension 拓展/扩展方法




  • 这个功能与Kotlin的扩展方法是类似的,都可以在不修改类文件的前提下,扩展类方法。




  • Kotlin不仅可以添加扩展方法,同时可以添加扩展变量。Dart只可以添加拓展方法。


        extension StringUtil on String {
    bool isNullorEmpty(String? str) {
    return str == null || str.isEmpty;
    }
    }



3.5 on 关键字。




  • on 关键字用于混入类间,实现类似 extends 的关系。即混入类可以通过 on 关键字引入其他类的功能。需要注意的是,混入类不仅可以引入混入类,也可以引入普通类与抽象类。例如:


        mixin D {
    String d = 'hello word! D';

    void run() {
    print('on keyword --- ${d}')
    }
    }

    mixin E on D {
    @override
    set(String value) {
    d = value;
    }

    @override
    void run() {
    super.run();
    }
    }



  • onextension 配合使用,表示对哪个类进行扩展。




3.6 switch 关键字




  • switch 关键字与Java中的功能相同,即判断执行分支。其中有一个需要注意的细节,Dart中,对象类型也可以作为分支判断条件。


        class Test {
    void method1() {
    Person p1 = Person();
    Person p2 = Person();
    Person p3 = Person();

    Person p = p1;
    switch(p) {
    case p1:
    //TODO
    break;
    case p2:
    //TODO
    break;
    case p3:
    //TODO
    break;
    }
    }
    }



  • Java中,判断对象只能为基本数据类型,如下图所示。




20230316164610.jpg


3.7 set & get 关键字




  • 与Kotlin类似,Dart提供了 set & get关键字,实现变量的 setter & getter 功能。示例如下:


        class Test {
    void method() {
    A a = A();
    print(' get value : ${a.getValue}');

    a.setValue(10);
    print(' get value : ${a.getValue}');
    }
    }

    class A {
    int _value = 0;

    int get getValue => _value;
    /// => 是Dart中的省略写法,完整方法如下:
    int get getValue {
    return _value;
    }

    set setValue(int value) {
    _value = value;
    }
    }



3.8 Function 函数对象




  • 定义:函数对象与Kotlin的高阶函数类似,可以理解为函数对象类型的关键字;指定传入参数,执行对应代码块后,返回指定类型的返回值。这也是Dart 比 Java 更靠近万物皆对象的体现。




  • 作用:与Kotlin的高阶函数功能一致,定义一类功能的实现规则。示例如下:


        typedef Operate = int Function(int, int);

    class Test {
    void method() {
    Operate add = (a, b) {
    return a + b;
    }

    add.call(10, 20)
    }
    }



总结


总的来说,Dart语言与Java&Kotlin很多相似的地方,在最开始学习时,记住不同点,编码方面就不会有太多的阻碍。但是从语言设计层面看,Dart与Java&Kotlin还是有很大的区别,等我悟道之后,再和大家细说。
如果有需要完善,或者不认同的地方,欢迎大家留言评论。



作者:Child
来源:juejin.cn/post/7213232948794884155
收起阅读 »

去哪儿低代码平台跨端渲染方案及落地

web
作者介绍 何欣宇,2021年入职去哪儿旅行,目前担任门票前端开发负责人,擅长iOS、Android以及RN技术,主导了Qunar低代码平台跨端渲染方案的设计开发以及落地工作。 一、低代码平台跨端渲染现状 去哪儿网目前的低代码平台已经搭建了上万个活动页面,包含小...
继续阅读 »

作者介绍


何欣宇,2021年入职去哪儿旅行,目前担任门票前端开发负责人,擅长iOS、Android以及RN技术,主导了Qunar低代码平台跨端渲染方案的设计开发以及落地工作。


一、低代码平台跨端渲染现状


去哪儿网目前的低代码平台已经搭建了上万个活动页面,包含小程序、touch和 APP 多个平台。去哪儿低代码平台是基于 Shark 框架开发。Shark 是一款有着跨平台(一套代码支持跨端渲染)、按需加载(仅加载页面配置所需代码文件)等特性的类 React 框架。有着缓存、消息中心等多种能力。Shark 和低代码平台的无缝结合,给现在低代码平台带来了跨端、“所见即所得”得等多种特性。而“所见即所得”,就是一种动态加载的功能:我们在低代码平台上配置一个页面所需组件和对应的各种属性,可以及时的在各个端上看到。


随着低代码平台的推广应用,接入了越来越多的业务的核心流程,对于加载性能上的要求越来越高。在当前阶段,低代码平台在 APP 端是利用 H5 的方式来渲染页面。但是这种方式首先需要加载 WebView ,然后才会去绘制页面,导致白屏时间比较久。


去年遇到了一个契机,门票业务在对主流程进行了大改版,当时人力相对比较紧张,而且业务侧希望页面的组件是可配置的,对于这个挑战,结合低代码平台进行了思考,代码平台天然是可视化配置的,也支持多端运行,美中不足的是在端内是以 H5 方式运行的,如果在端内支持 RN 运行,补齐性能的短板,整体来讲将会是一个很好的方案。


二、APP 端替代 HY 方案调研以及可行性分析


说到既满足灵活发版,又能跨平台,还有较高的性能来解决前面的白屏时间久和性能差的问题,要同时满足这三个特点的技术。当前状况去哪儿 APP 是以React Native 为主的;于是我们提出了一个想法:Shark 和 React Native 能否结合一下呢?结合两家之长处,即实现灵活可配,又能保持高性能和跨平台,将扩大我们低代码平台的边界,提供更多可能性,于是我们开始了 Shark 和 RN 的结合探索之旅;


首先,我们开始分析 Shark 组件和 React Native 组件之间的区别;一个 Shark 组件主要是由 JS 文件以及 Scss 文件两个文件组成。那么作为一个类React框架,它和 RN 的代码有多大的区别那?通过下图对比我们可以看到差异点(左图是 Shark 组件代码,右图是 RN 组件代码):


图片


通过上面的对比,我们可以看到 Shark 和普通 RN 代码的区别在



  • 布局名称、方式和 RN 区别较大

  • 语法树标签主要是 View,可以看到交互和文字展示都是 View ,但在 RN中是不同标签

  • 标签属性的不同,在 RN 和 Shark 中点击事件不同等

  • ......


对比完 JS 文件,那布局文件的差异又有多大那?依旧可以通过下图的对比看到差异点


图片


上面是布局Scss文件部分,可以看到区别主要集中在



  • 布局名的嵌套

  • 单位的不同

  • 属性名和RN不同


通过上面的分析观察,我们可以看到 Shark 的代码和 RN 的代码虽然具有一定的区别但是相似度还是很高的。那如果我们先手动将这些差异点修改,能否将这份代码在 APP 上运行起来?下面我们先完成第一步:修改差异点。


图片


我们将手动修改后的代码嵌入在 RN 业务组件中,通过实验得知,这段代码是可以通过编译并正常运行的。


通过这些分析和实验得知,通过修改是可以将 Shark 的代码在 RN 上运行的。在上面的实验中,我们是通过手动修改的方式来达到目的,但是在实际项目中这样做肯定是不切实际的,我们可以通过 Babel 来编写自己的转化器,来达到批量转换的目的。


三、APP端实践


方案简述


通过上面的分析可知,Shark 的核心代码是可以通过 Babel 转换为 RN 的代码并在 APP 上直接运行的。在整个过程中是“代码转化”和“运行时能力提供”两个部分。通过下面的图,我们可以看到整体流程


图片


编译


整个编译时期我们的任务就是将 Shark 的源码转化为 RN 可以直接使用的代码。我们利用 Babel 编写了自己的工具:shark-cli,通过这个工具我们实现代码的适时转换。


- JS文件


JS 文件主要处理包括语法树(标签的替换、布局抹平、标签属性替换等)和JS( document 等的处理)两个部分。针对其中几个主要的问题展开讨论


标签以及属性的转换:


Shark 中绝大部分标签都是 View ,但是在 RN 中不同,RN 中不同的标签会承担不同的功能。比如在 Shark 里 View 还可以接收点击事件,但是在 RN 中只能是 TouchableOpacity 等少数组件。针对这一情况我们根据一些属性,当发现是一些特殊组合的时候就会在代码中替换组件。通过下面的映射表,我们将不同标签和属性的组合映射到 RN 中对应的标签和属性。


shark组件RN组件
ViewView、TouchableOpacity(当有onClick时转换为此组件)
InputTextInput
TextText
ImageImageImage + TouchableOpacity (当有onClick时转换)
ScrollViewScrollView

import的处理:


和 Shark 不同,RN 需要将使用到的标签、组件显示的引入并指明它在哪个库当中,比如我们经常遇到的下面的代码。


图片


为此,我们在代码转换时,准备了一个映射表,里面针对 react-native 的组件,可以直接 import 。但是这样并不能很好的支持,因为三方库和标签并不一样并不能枚举,为了解决这个问题我们提供了另一个能力,支持在标签上新增了两个属性,指定标签名称和来源来达到这一目的


图片


嵌套布局的抹平:


整个嵌套算是 Shark 和 RN 上分歧最大的地方,布局上要将Shark多种写法统一成 RN 的写法,其次就是要将 Shark 嵌套的布局在 RN 上抹平。过程如下图所示。


图片


对于不同的 class 或者 style 写法,在 babel 中都是不同的节点要单独处理,对于不同的节点我们应用不同的规则。我们收集到统一的格式之后,就可以运用一个规则去处理抹平。在 scss 文件处理的过程中,嵌套文件拿到的最后的属性名都是多层拼接完成的,比如 styles. 层级1_层级2_层级3,但是在 js 文件中处理完的都是 styles. 属性名,这就引出了嵌套布局抹平的问题。我们维护了一个当前布局层级的栈,我们在每一层 View 进入的时候入栈,记录一次布局名称,每一层 View 结束的时候作为出栈。在前面处理 scss 文件时,我们拿到了所有布局的嵌套关系,根据这个栈和我们拥有的嵌套关系去遍历,去匹配是否有布局嵌套,如果有就替换如果没有则进行下一次匹配。通过这种方式我们来解决嵌套布局的问题。


- Scss文件


图片


scss 文件和 RN 使用的布局属性,其实差异不大。我们最重要的是处理类型名的嵌套,整个的转换我们分为两步,每一步去处理不同的问题。


第一步:将 scss 文件转换为 css 文件。在转换的同时,我们将单位 rem 删除、嵌套的类名抹平,这时得到了我们想要的中间文件 .css 文件。


第二部:将 css 文件转换为 RN 的布局文件即 style.js 。在这一过程中,我们要记录所有嵌套布局的路径给将来index.js去处理嵌套布局。同时为了解决属性上的问题,我们通过配置文件将不支持的属性删除,并替换不同属性值的问题。通过这个方式我们获取到 RN 可使用的一个 object 对象并保存为一个 style.js 的文件。


整体 scss 文件的转换思路如下图所示


图片


Babel详解


编写自己的Babel插件


AST


整个工作流程可以描述为 AST → visitor 修改 AST→ 获取目标代码。在这其中,理解清楚 AST 十分重要,我们之所以需要将代码转换为 AST 也是为了让计算机能够更好地进行理解。我们可以来看看下面这段代码被解析成 AST 后对应的结构图:


以这一行代码为例子,它的语法树如下


图片


所有的 AST 根节点都是 Program 节点,从上图中我们可以看到解析生成的 AST 的结构的各个 Node 节点都很细微,github.com/babel/babyl… (不过这个文档并没有说明具体输出的样式,有时同一个节点,输入不同参数输出的代码可以差距非常大,尤其是在格式化时这点就非常重要)这个文档对每个节点类型都做了详细的说明,你可以对照各个节点类型在这查找到所需要的信息。通过astexplorer.net/ 可以有效的观察代码对应的节点,以及节点的各种属性关系。熟悉了 AST 之后,就可以通过 Visitor 来遍历节点,更改我们想要的代码。


Visitor


image.png


在 visitor 中引入了 path 的概念,它中包含了节点的信息以及节点和所在的位置,以供对特定节点进行操作。不仅包含了当前节点的信息,也有当前节点的父节点的信息,同时也包含了添加、更新、移动和删除节点有关的其他很多方法。具体地,Path 对象包含的属性和方法主要如下:


image.png


整个 visitor 的过程,可以简述为通过修改 path 来改变 AST 语法树的过程。


image.png


如上所示,我们在修改的过程中针对 path 对替换或者修改,生成新的节点,就可以达到我们的目标。以我们这次的代码为例子,当发现 onClick 属性时,我们要将 View 标签替换为 TouchableOpacity 标签,onClick 替换为 onPress 。


image.png


通过语法树分析,onClick 在语法树中的层级是 JSXOpeningElement → attributes → JSXAttribute → JSXIdentifier → name。这时才找到了 name = "onClick"。


image.png


此时我们在 visitor 中找到 JSXIdentifier 并通过 path 找到 name 


image.png


我们找到了对应的节点后,问题就是要替换成什么。通过上一步分析语法树的方式我们可以知道,onClick 转换为 onPress ,语法树上只是对应的 name 有变化。此时我们需要生成一个新的 JSXIdentifier 类型的节点。对应到我们的插件里如下图所示:


image.png


这样,我们就达到了转换的目的。通过总结不同的节点的替换和修改,就能达到修改语法树,转换代码的目的。


运行时


简述


拿到了通过编译态转换好的 RN 代码后,就是运行时要做的工作了。运行时的主要工作可以分为和低代码平台通讯获取需要渲染哪些组件、获取这些组件的实例进行组装,最后进行渲染。整体工作流程如下图所示。


image.png


可以把整个容器可以理解为一个 RN 业务组件。这个容器包含了缓存、动态加载、页面的绘制、提供 Shark 能力等几个部分。


提供Shark能力


整个 Shark 的能力包括几个部分:ability、core 以及 Qunar 特性,三个部分。其中 ability 是 shark 和 core 是 Shark 的核心能力。他们包括了如下能力:



  • message

  • request

  • jump

  • logger

  • ……


在运行时,我们要提供这些能力,让转换后的代码能够在 RN 上无缝运行。


缓存


如果每次进去页面都需要实时获取页面配置信息,这样很影响用户体验。针对这个问题,我们设计了一套缓存策略,让用户可以无感知的更新配置。我们缓存了不同页面的配置信息,在用户进入页面时可以通过直接读取缓存,省下了等待接口的时间。在用户进入页面的同时,去获取最新的配置信息来更新缓存内容。缓存包括内置配置文件和 cache 的二级缓存系统,当进入页面时会实时更新 cache ,如果有 cache 则优先读 cache ,否则就读取内置配置文件。整体流程如下


image.png


动态加载


有了上面两点能力,我们就可以实现在文章开头提到的动态加载的能力。由于 RN 只能渲染已经加载完的代码,为了达到这个目标,在容器端获取页面的配置信息,通过这个配置文件,我们能够获取到页面的组件信息,包括需要哪些组件、每个组件的属性,由于 RN 框架限制做不到去动态的加载一个新的组件,只能加载已经打包的组件,否则还是要去更新版本。通过一个专门的组装器,注册组件、解析组件属性并赋值。通过这个组装器,我们来选择对应的页面的对应的组件,来动态加载组件或更新组件信息。我们又借助上面提到的缓存的能力,来减少了用户的感知时间。


image.png


在获取到了页面配置信息之后,我们将配置信息交给容器。容器可以通过组件名称来找到需要渲染的组件。容器可以通过这个配置文件实现组件的选择、props的传递,来达到低代码平台组件所见即所得的功能。


成果展示


编译态代码转换结果。左图是低代码平台组件的源码,右图是经过编译态转换后的结果。


image.png


最后实际效果:
下方图 1 可以看到我们在低代码平台上配置的信息,图 2 是对应 APP 内页面的截图。可以看到左侧在平台上配置各个组件,最后可以做到在平台上配置组件属性和组件修改,在 APP 内部可以即使生效以及组件的灵活上下线。


image.png


(图1)


image.png


(图2)


针对我们开始提到的性能问题,根据TTI监控指标,我们可以看到,P90 和 P50 的平均时间都在 2 秒之内的,这样看整体的方案是达到了我们最初的目标。


image.png


四、未来规划


经过在实际业务线两个页面的尝试,整个低代码平台的跨平台能力已经经受了实际项目的验证。但是在开发人员实际使用的过程中还是有可以优化的地方,来提升开发人员的转换效率。将来考虑能将一些属性或者能力做成配置开放给开发人员,这样能做到更加个性化。


现在只有一个业务线尝试过这个功能,但是整个低代码平台其实是很大的一个平台且使用业务线众多,后面也要考虑针对业务线的推广,来扩大使用人群。


作者:去哪儿技术沙龙
来源:juejin.cn/post/7213665606628327461
收起阅读 »

svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退

web
在之前的系列文章中,我们介绍了图形编辑器基础的 移动、缩放、旋转 等基础编辑能力,以及吸附、网格、辅助线、锚点、连接线等辅助编辑的能力。这些能力提高了编辑功能的上限,本文将介绍的是提效相关的功能:右键菜单、快捷键、撤销回退。 一、右键菜单 1. 右键菜单底层...
继续阅读 »

在之前的系列文章中,我们介绍了图形编辑器基础的 移动缩放旋转 等基础编辑能力,以及吸附、网格、辅助线、锚点、连接线等辅助编辑的能力。这些能力提高了编辑功能的上限,本文将介绍的是提效相关的功能:右键菜单、快捷键、撤销回退。



一、右键菜单


1. 右键菜单底层方案


关于右键菜单的通用底层解决方案,我之前已经写了一篇文章专门介绍,有兴趣的同学欢迎参考:



功能:



  • 每个菜单项都可以独立设置是否禁用、是否隐藏

  • 支持子菜单

  • 支持显示icon图标、提示语、快捷键等

  • 与业务完全解耦,通过简单配置即可定制出功能各异的菜单


menu



  • 使用通用右键菜单组件演示:


import { ContextMenu, IContextMenuItem } from 'context-menu-common-react';

// 菜单配置数据
const menuList: IContextMenuItem[] = [
{ text: '复制', key: 'copy' },
{ text: '粘贴', key: 'paste', shortcutKeyDesc: `${cmd}+V` },
{
text: '对齐',
key: 'align',
children: [
{ text: '水平垂直居中', key: 'horizontalVerticalAlign' },
{ text: '水平居中', key: 'horizontalAlign' },
],
},
];

export () => {
const containerDomRef = React.useRef();
// 菜单点击触发
const handleMenuTrigger = (menu: IContextMenuItem) => {
console.log(menu); // { text: '复制', key: 'copy' }
// 这里处理触发菜单后的逻辑....

};
return (
<div
ref={containerDomRef}
style={{ position: 'relative' }}>

<ContextMenu
getContainerDom={() =>
containerDomRef.current}
menuList={menuList}
onTrigger={handleMenuTrigger}
/>
</div>

);
};


2. 图形编辑器右键菜单定制


上面的文章介绍了一种通过数据配置生成右键菜单的通用解决方案,它和业务没有任何的耦合,是一个独立功能。


但是仅有上面的功能在面临复杂业务的时候使用体验就不是很好了,例如:



  • 某个特殊的精灵想右键菜单在自己身上触发的时候,显示一个独属于自己的菜单项。

    • 比如富文本精灵提供清除内容富文本格式的功能,把加粗、字体大小等等样式全部清除变为普通无样式文本




这里我们为了提升右键菜单的扩展性易用性,会基于上面的方案做一些抽象和定制,例如:



  1. 菜单配置数据提供注册机制:以便于在不同的模块里维护属于自己模块的菜单项功能;

  2. 每个菜单项都可以独立定义点击触发时的操作:不在一个同一个onTrigger触发器里分发处理每个菜单项的点击逻辑;

  3. 为菜单项触发时处理函数里添加图形编辑器相关的上下文,以方便使用;


import { IContextMenuItem } from "context-menu-common-react";
import ContextMenu from "context-menu-common-react";
import React from "react";
import { ISprite, IStageApis } from "../../demo3-drag/type";
import { GraphicEditorCore } from "../../demo3-drag/graphic-editor";

export * from "context-menu-common-react";

export interface ITriggerParmas {
stage: GraphicEditorCore;
activeSpriteList: ISprite[];
menuItem: IEditorContextMenuItem;
}

export type IEditorContextMenuItem = IContextMenuItem & {
onTrigger: (params: ITriggerParmas) => void;
};

interface IProps {
getStage: () => GraphicEditorCore;
}

interface IState {
menuItemList: IContextMenuItem[];
}

export class EditorContextMenu extends React.Component<IProps, IState> {
triggerList: any[] = [];

stage: GraphicEditorCore | null = null;

menuItemMap: Record<string, IEditorContextMenuItem> = {};

state: IState = {
menuItemList: []
};

componentDidMount() {
this.stage = this.props.getStage?.();
}

public registerItemList = (_menuItemList: IEditorContextMenuItem[]) => {
const { menuItemList } = this.state;
_menuItemList.forEach((e) => {
this.menuItemMap[e.key] = e;
});
this.setState({ menuItemList: [...menuItemList, ..._menuItemList] });
};

public registerItem = (menuItem: IEditorContextMenuItem) => {
const { menuItemList } = this.state;
this.menuItemMap[menuItem.key] = menuItem;
this.setState({ menuItemList: [...menuItemList, menuItem] });
return () => this.remove(menuItem);
};

public remove = (menuItem: IEditorContextMenuItem | string) => {
const { menuItemList } = this.state;
const list = [...menuItemList];
const key = typeof menuItem === "string" ? menuItem : menuItem.key;
const index = list.findIndex((e) => e.key === key);
delete this.menuItemMap[key];
if (index !== -1) {
list.splice(index);
this.setState({ menuItemList: list });
}
};

public has = (menuItem: IEditorContextMenuItem | string) => {
const key = typeof menuItem === "string" ? menuItem : menuItem.key;
return Boolean(this.menuItemMap[key]);
};

handleTrigger = (menuItem: IContextMenuItem) => {
const { stage } = this;
const { activeSpriteList } = stage?.state || ({} as any);
const item = this.menuItemMap[menuItem?.key];
if (typeof item?.onTrigger === "function") {
item?.onTrigger({
menuItem,
stage: this.stage as any,
activeSpriteList
});
}
};

render() {
const { menuItemList } = this.state;
return (
<ContextMenu
getContainerDom={() =>
document.body}
menuList={menuItemList}
onTrigger={this.handleTrigger}
/>

);
}
}



3. 一些通用的右键操作方法


3.1 复制


const handleCopy = ({ stage, activeSprite }) => {
const jsonData = JSON.stringify({ type: 'activeSprite', content: activeSprite });
return navigator.clipboard.writeText(jsonData);
};
const menuItem: IContextMenuItem = {
text: '复制',
key: 'copy',
// 此菜单项是否禁用
disabled: ({ activeSprite }) => Boolean(activeSprite),
onTrigger: handleCopy,
};

stage.apis.contextMenu.registerItem(menuItem);

3.2 粘贴


const handlePaste = async ({ stage }) => {
const jsonData = await navigator.clipboard.readText();
const jsonObj = JSON.parse(jsonData);
if (jsonObj?.type === 'activeSprite') {
stage.apis.addSpriteToStage(jsonObj.content);
}
};
const menuItem: IContextMenuItem = {
text: '粘贴',
key: 'paste',
onTrigger: handlePaste,
};

stage.apis.contextMenu.registerItem(menuItem);

3.3 删除


const handleRemove = async ({ stage, activeSprite }) => {
stage.apis.removeSprite(activeSprite);
};
const menuItem: IContextMenuItem = {
text: '删除',
key: 'remove',
onTrigger: handleRemove,
};

stage.apis.contextMenu.registerItem(menuItem);

3.4 剪切


const handleCut = async ({ stage, activeSprite }) => {
const jsonData = JSON.stringify({ type: 'activeSprite', content: activeSprite });
// 先复制, 再删除
const res = await navigator.clipboard.writeText(jsonData);
stage.apis.removeSprite(activeSprite);
return res;
};
const menuItem: IContextMenuItem = {
text: '剪切',
key: 'cut',
onTrigger: handleCut,
};

stage.apis.contextMenu.registerItem(menuItem);

3.5 撤销、重做


const menuItem: IContextMenuItem = {
text: '撤销',
key: 'redo',
onTrigger: ({ stage }) => stage.apis.redo(),
};

stage.apis.contextMenu.registerItem(menuItem);

const menuItem: IContextMenuItem = {
text: '重做',
key: 'undo',
onTrigger: ({ stage }) => stage.apis.undo(),
};
stage.apis.contextMenu.registerItem(menuItem);

4. 精灵注册属于自己的右键菜单快捷操作


// 文本精灵组件
export class RichTextSprite extends BaseSprite<IProps> {

componentDidMount() {
const { stage } = this.props;
const { contextMenu } = stage.apis;
if (!contextMenu.has('clearRichTextFormat')) {
const menuItem: IContextMenuItem = {
text: '清除富文本格式',
key: 'clearRichTextFormat',
// 显示此菜单项的条件
condition: ({ sprite }) => sprite.type === 'RichTextSprite',
onTrigger: this.handleClearTextFormat,
};
stage.apis.contextMenu.registerItem(menuItem);
}
}

componentWillUnmount() {
if (contextMenu.has('clearRichTextFormat')) {
stage.apis.contextMenu.remove('clearRichTextFormat');
}
}

handleClearTextFormat = () => {
const { stage, sprite } = this.props;
const { content } = sprite.props;

const text = clearTextFormat(content);
const newProps = { ...sprite.props, content: text };
stage.apis.updateSpriteProps(sprite.id, newProps);
}

render() {
const { sprite } = this.props;
const { props, attrs } = sprite;
const { content } = props;
return (
<foreignObject
<span {...props}>
{content}</span>
</foreignObject>

);
}
}


二、快捷键


1. 图形编辑器快捷键定制


/**
* 快捷键配置
*/

export const shortcutOpts: IShortcutOpt[] = [
{
name: ShortcutNameEnum.copy,
title: '复制',
keys: ['c'],
containerSelectors: ['.div-1'],
option: { metaPress: true },
// 触发当前快捷键时执行
onTrigger: ({ opt, event }) => {
// 这里处理触发后的逻辑
},
},
{
name: ShortcutNameEnum.undo,
title: '重做',
keys: ['z'],
option: { metaPress: true, shiftPress: true },
// 触发当前快捷键时执行
onTrigger: ({ opt, event }) => {
// 这里处理触发后的逻辑
},
},
];

export default () => {

useEffect(() => {
// 实例化
const keyboardOpt = new KeyBoardOperate({
preventDefault: true,
onTrigger: (opt: IShortcutOpt, e) => {
console.info('bingo', opt, e);
// 所有快捷键触发后都会执行
},
});
shortcutOpts.forEach(e => keyboardOpt.registerShortcutKey(e));
return () => {
keyboardOpt.removeAllEventListener();
};
}, []);

return null
};

2. 精灵注册属于自己的快捷键操作


// 文本精灵组件
export class RichTextSprite extends BaseSprite<IProps> {
componentDidMount() {
const { stage } = this.props;
const { shortcutKey } = stage.apis;
if (!shortcutKey.has('clearRichTextFormat')) {
const opt: IShortcutOpt = {
title: '清除富文本格式',
name: 'clearRichTextFormat',
keys: ['c', 'l'],
option: { metaPress: true },
onTrigger: this.handleClearTextFormat,
};
stage.apis.shortcutKey.registerItem(menuItem);
}
}
componentWillUnmount() {
if (stage.apis.shortcutKey.has('clearRichTextFormat')) {
stage.apis.shortcutKey.remove('clearRichTextFormat');
}
}
render() {
...
}
}


3. 快捷键底层方案


这里的实现思路和右键菜单的注册思路类似,为了快捷键的稳定性和兼容性我们借助hotkeys-js这个包来实现快捷键的监听。


export interface IShortcutOpt {
// 快捷键的名字,不能重复,否则会报错
name: string;
// 按键数组
keys: string[];
// 容器选择器,选择快捷键生效的触发区域,支持class选择器和id选择器,例如:['.title', '#root']
containerSelectors?: string[];
// 名称
title?: string;
// 配置
option?: IShortcutOption;
// 触发回调
onTrigger?: (params: { opt: IShortcutOpt; event: KeyboardEvent }) => void;
}

上面就是一个快捷键的配置,我们的设计如下:



  • 使用option表示是否需要meta、shift等键按下

  • 使用keys表示监听的键,例如复制['c']

  • onTrigger表示快捷键被触发了时执行的回调

  • 同样支持 registerShortcutKey方法来注册上面的单个快捷键


以下是快捷键的源码:


import hotkeys from 'hotkeys-js';
import { getHotkeysStr, selectParents } from './helper';
import { IShortcutOpt, ITriggerCallback } from './types';

export class KeyBoardOperate {
// 快捷键映射
shortcutKeyMap: Record<string, IShortcutOpt[]> = {};

onTrigger: ITriggerCallback;

preventDefault: boolean = true;

clickEle: any;

constructor({
shortcutOpts = [],
preventDefault = true,
onTrigger = () => '',
}: {
shortcutOpts: IShortcutOpt[];
preventDefault?: boolean;
onTrigger?: ITriggerCallback;
}
) {
this.preventDefault = preventDefault;
this.onTrigger = (opt: IShortcutOpt, e: KeyboardEvent) => {
opt.onTrigger?.({ opt, event: e });
onTrigger?.(opt, e);
};
shortcutOpts.forEach(opt => this.registerShortcutKey(opt));
document.addEventListener('click', (e: MouseEvent) => {
this.clickEle = e.target;
});
console.log('yf123', this);
}

/**
* 注册快捷键
*
* @param shortcutOpt - 快捷键操作
* @param shortcutOpt.name - 快捷键操作名字,同时作为映射的key,要保证唯一性
* @param shortcutOpt.keys - 按键数组
* @param shortcutOpt.option - 配置
*/

public registerShortcutKey(shortcutOpt: IShortcutOpt) {
const { name, keys } = shortcutOpt;
if (!Array.isArray(keys)) {
throw new Error(`注册快捷键时, keys 参数是必要的!`);
}
// 避免重复
if (this.shortcutKeyMap[name]) {
throw new Error(`快捷键操作「${name}」已存在,请更换`);
}
this.addEventListener(shortcutOpt);
}

public removeAllEventListener() {
hotkeys.unbind();
}

private addEventListener(shortcutOpt: IShortcutOpt) {
const keyStr = getHotkeysStr(shortcutOpt);
hotkeys(keyStr, (e: KeyboardEvent) => this.handleKeyTrigger(e, shortcutOpt));
}

private removeEventListener(shortcutOpt: IShortcutOpt) {
const keyStr = getHotkeysStr(shortcutOpt);
hotkeys.unbind(keyStr);
}

private handleKeyTrigger = (event: KeyboardEvent, shortcutOpt: IShortcutOpt) => {
if (this.preventDefault) {
event.preventDefault();
}
// 如果配置了生效区域,但是触发快捷键的节点不在容器里,就认为是无效操作
const { containerSelectors = [] } = shortcutOpt;
if (containerSelectors.length > 0) {
const parents = selectParents(this.clickEle, containerSelectors);
if (parents.length === 0) {
return;
}
}
// 成功命中快捷键
this.onTrigger(shortcutOpt, event);
};
}



  • 工具函数


import { IShortcutOpt } from './types';

// 利用原生Js获取操作系统版本
export function getOS() {
const isWin =
navigator.platform === 'Win32' || navigator.platform === 'Windows';
const isMac =
navigator.platform === 'Mac68K' ||
navigator.platform === 'MacPPC' ||
navigator.platform === 'Macintosh' ||
navigator.platform === 'MacIntel';
if (isMac) {
return 'Mac';
}
const isLinux = String(navigator.platform).includes('Linux');
if (isLinux) {
return 'Linux';
}
if (isWin) {
return 'Win';
}
return 'other';
}

export const isMac = getOS() === 'Mac';

export const getMetaStr = () => (isMac ? 'command' : 'ctrl');

export const getHotkeysStr = (opt: IShortcutOpt) => {
const { metaPress, shiftPress, altPress } = opt.option || {};
let key = '';
if (metaPress) {
key += `${getMetaStr()}+`;
}
if (shiftPress) {
key += 'shift+';
}
if (altPress) {
key += 'alt+';
}
key += `${opt.keys.join('+')}`;
return key;
};

export const findDomParents = (dom: any) => {
const arr: any = [];
const findParent = (e: any) => {
if (e?.parentNode) {
arr.push(e);
findParent(e.parentNode);
}
};
findParent(dom);
return arr;
};

export const selectParents = (dom: any, selectors: string[]) => {
const results: any[] = [];
const parents = findDomParents(dom);
selectors.forEach((selector: string) => {
for (const node of parents) {
const selectorName = selector.slice(1);
if (selector.startsWith('#')) {
if (
node.getAttribute('id') === selectorName &&
!results.find(e => e === node)
) {
results.push(node);
}
} else if (selector.startsWith('.')) {
if (
node.classList.contains(selectorName) &&
!results.find(e => e === node)
) {
results.push(node);
}
}
}
});
return results;
};


  • types


export interface IShortcutOption {
metaPress?: boolean;
shiftPress?: boolean;
altPress?: boolean;
}

export type ITriggerCallback = (opt: IShortcutOpt, e: KeyboardEvent) => void;

export interface IShortcutOpt {
// 快捷键的名字,不能重复,否则会报错
name: string;
// 按键数组
keys: string[];
// 容器选择器,选择快捷键生效的触发区域,支持class选择器和id选择器,例如:['.title', '#root']
containerSelectors?: string[];
// 名称
title?: string;
// 配置
option?: IShortcutOption;
// 触发回调
onTrigger?: (params: { opt: IShortcutOpt; event: KeyboardEvent }) => void;
}

三、撤销回退


history.gif


1. 撤销回退底层方案


关于历史记录的通用底层解决方案,我之前已经写了一篇文章专门介绍,有兴趣的同学欢迎参考:



这个方案比较简单,是存储全量数据的,如果需要使用仅存储增量数据,欢迎在评论区分享方案讨论~


2. 图形编辑器中使用撤销回退


我们需要在图形编辑器里操作精灵列表spriteList数据的核心api里加上历史记录相关的操作。



export class GraphicEditorCore extends React.Component<IProps, IState> {
private readonly registerSpriteMetaMap: Record<string, ISpriteMeta> = {};

// 历史记录 - 添加
public pushHistory = (spriteList: ISprite[]) => {
history: string[] = [];

const { history } = this;
history.push(
JSON.stringify({ ...this.getMetaData(), children: spriteList }),
);
};

// 历史记录 - 撤销
public undo = () => {
const { history } = this;
if (history.getLength() > 1) {
history.undo();
history.currentValue &&
this.setSpriteList(JSON.parse(history.currentValue).children, false);
}
};

// 历史记录 - 重做
public redo = () => {
const { history } = this;
history.redo();
history.currentValue &&
this.setSpriteList(JSON.parse(history.currentValue).children, false);
};

public addSpriteToStage = (sprite: ISprite | ISprite[]) => {
const { spriteList } = this.state;
const newSpriteList = [...spriteList];
if (Array.isArray(sprite)) {
newSpriteList.push(...sprite);
} else {
newSpriteList.push(sprite);
}
this.setState({ spriteList: newSpriteList });
// 在操作精灵列表数据的方法里都加上历史记录的操作即可
this.pushHistory(newSpriteList);
};

setSpriteList = (newSpriteList: ISprite[]) => {
this.setState({ spriteList: newSpriteList });
};


四、总结


本文介绍了编辑器常用的三种提效功能:右键菜单、快捷键、历史记录,可以使我们编辑操作的效率得到大大的提升,优化体验,并且每个功能都做了分层抽象,可以形成解决方案,在别的业务中复用。


加下来我们会继续介绍提升编辑效率的功能:多选组合,以方便批量操作精灵,提升效率。


作者:前端君
来源:juejin.cn/post/7213757571960799291
收起阅读 »

深入 React Context 源码与实现原理

web
前置知识 本文假设你对 context 基础用法和 React fiber 渲染流程有一定的了解,因为这些知识不会介绍详细。本文基于 React v18.2.0 Context API React 渲染流程 React 渲染分为 render 阶段和 com...
继续阅读 »

前置知识


本文假设你对 context 基础用法和 React fiber 渲染流程有一定的了解,因为这些知识不会介绍详细。本文基于 React v18.2.0


Context API


image.png


React 渲染流程


React 渲染分为 render 阶段和 commit 阶段,其中 render 阶段分为两步(深度优先遍历)



  1. beginWork(进入节点的过程向下遍历,协调子元素)

  2. completeUnitOfWork(离开节点的过程向上回溯)


区别 render 和 beginWork


为了避免与上面的阶段混淆,以下 render 都代指开发者层面的 render,即指类组件执行 render 方法或函数组件执行



  • 如果一个组件发生更新,当前组件到 fiber root 上的父级链上的所有 fiber,都会执行 beginWork,但执行 beginWork,不代表触发了组件的 render(fiber 会检查组件是否需要进行渲染,不需要则会跳过复用旧的 fiber 节点)所以 render 不等于 beginWork

  • 如果组件 render 执行了,则一定经历了 beginWork 流程,触发了 beginWork


综上 beginWork 的工作是进入节点时协调子元素,如果 fiber 类型是类组件或者函数组件,则需检测比较组件是否需要执行 render,不需要则会跳过复用旧的 fiber 节点


React.createContext 原理


const MyContext = React.createContext(defaultValue)


创建一个 Context 对象。只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效



源码位置:packages/react/src/ReactContext.js


createContext 函数的核心逻辑是返回一个 context 对象,其中包括三个重要属性:



  • ProviderConsumer 两个组件(React Element 对象)属性

  • _currentValue :保存 context 的值,用来保存传递给 Provider 的 value 属性)


下列是精简去除类型定义和引入的源码,后面源码举例都这么处理,为了方便直观的看:


const REACT_PROVIDER_TYPE = Symbol.for('react.provider')
const REACT_CONTEXT_TYPE = Symbol.for('react.context')

export function createContext(defaultValue) {
const context = {
$$typeof: REACT_CONTEXT_TYPE, // 本质就是 Consumer Element 类型
_currentValue: defaultValue, // 保存 context 的值
_currentValue2: defaultValue, // 为了支持多个并发渲染器,适配不同的平台
_threadCount: 0, // 跟踪当前有多少个并发渲染器
Provider: null,
Consumer: null,
}
// 添加 Provider 属性,本质就是 Provider Element 类型
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,
}
// 添加 Consumer 属性
context.Consumer = context

return context
}


JSX 语法在进入 render 时会被编译成 React Element 对象



Context.Provider 原理


<MyContext.Provider value={/* 某个值 */}>

image.png


先来了解 Provider 的特性:



  • 每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化

  • Provider 接收一个  value  属性,传递给消费组件。一个 Provider 可以和多个消费组件有对应关系。

  • 只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效

  • 多个相同的 Provider 也可以嵌套使用,里层的会覆盖外层的数据。

  • 当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染,可跳过 shouldComponentUpdate 强制更新


如果一个组件发生更新,那么当前组件到 fiber root 上的父级链上的所有 fiber,更新优先级都会升高,都会触发 beginWork,但不一定会 render


当初次 Fiber 树渲染,进入 beginWork 方法,其中对应的节点处理函数是 updateContextProvider


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ContextProvider:
return updateContextProvider(current, workInProgress, renderLanes)
}
}

进入 updateContextProvider 方法:


function updateContextProvider(current, workInProgress, renderLanes) {
const providerType = workInProgress.type
const context = providerType._context

const newProps = workInProgress.pendingProps
const oldProps = workInProgress.memoizedProps
// 新的 value 值
const newValue = newProps.value
// 获取 Provider 上的 value
pushProvider(workInProgress, context, newValue)

// 更新阶段
if (oldProps !== null) {
const oldValue = oldProps.value
// 使用 Object.is 来比较新旧值是否发生变化
if (is(oldValue, newValue)) {
// context 值没有变更,则提前退出
if (
oldProps.children === newProps.children &&
!hasLegacyContextChanged()
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
)
}
} else {
// context 值发生改变,深度优先遍历查找 consumer 消费组件,标记更新
propagateContextChange(workInProgress, context, renderLanes)
}
}

// 继续向下调和子代 fiber
const newChildren = newProps.children
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}

// 使用栈存储 context._currentValue 值,设置 context._currentValue 为最新值
function pushProvider(providerFiber, context, nextValue) {
// 压栈
push(valueCursor, context._currentValue, providerFiber)
// 修改 context 的值
context._currentValue = nextValue
}


  • 首次执行时,保存 workInProgress.pendingProps.value 值作为最新值,然后调用 pushProvider 方法设置context._currentValue

  • pushProvider:存储 context 值的函数,利用栈先进后出的特性,先把 context._currentValue 压栈;与后面流程的 popProvider(出栈)函数相对应。

  • 更新阶段时通过浅比较(Object.is)来判断新旧 context 值是否发生改变,没发生改变则调用 bailoutOnAlreadyFinishedWork 进入 bailout,复用当前 Fiber 节点,改变则调用propagateContextChange方法


我们总结下 Context.Provider 的 Fiber 更新方法 —— updateContextProvider的核心逻辑



  1. 将 Provider 的 value 属性赋值给 context._currentValue(压栈)

  2. 通过 Object.is 浅比较 context 新旧值是否发生变化

  3. 发生变化时,调用 propagateContextChange 走更新的流程,深度优先遍历查找消费组件来标记更新



propagateContextChange 逻辑:深度优先遍历所有的子代 fiber ,然后找到里面具有 dependencies 的属性,对比 dependencies 中的 context 和当前 Provider 的 context 是否是同一个,如果是同一个,会提高 fiber 的更新优先级,让 fiber 在接下来的调和过程中,处于一个高优先级待更新的状态,而高优先级的 fiber 都会 beginWork



消费 Context 原理


由上文知识我们简略粗暴的说:Provider 一顿操作核心就是修改 context._currentValue 的值,那么消费 Context 值的原理也就是想方设法读取 context._currentValue 的值了。


image.png


Context.Consumer(函数组件)


<MyContext.Consumer>
{value => /* 基于 context 值进行渲染*/}
</MyContext.Consumer>


一个 React 组件可以订阅 context 的变更,此组件可以让你在函数式组件中可以订阅 context。这种方法需要一个函数作为子元素(function as a child)。这个函数接收当前的 context 值,并返回一个 React 节点。传递给函数的 value 值等价于组件树上方离这个 context 最近的 Provider 提供的 value 值



当 context 值更新时,Fiber 树渲染时,进入 beginWork 方法,beginWork 中对于 ContextConsumer 的节点处理函数是 updateContextConsumer


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ContextConsumer:
return updateContextConsumer(current, workInProgress, renderLanes)
}
}

updateContextConsumer的核心逻辑:



  1. 调用 prepareToReadContextreadContext 读取最新的 context 值。

  2. 通过 render props 函数,传入最新的 context value 值,得到最新的 children 。

  3. 调和 children


function updateContextConsumer(current, workInProgress, renderLanes) {
// 拿到 context
let context = workInProgress.type
context = context._context

const newProps = workInProgress.pendingProps
// 获取 Consumer 组件的 render props children
const render = newProps.children
// 读取 context 前的准备工作
prepareToReadContext(workInProgress, renderLanes)
// 读取最新 context._currentValue 值
const newValue = readContext(context)

let newChildren
// 最新的 children element
newChildren = render(newValue)

// 进入主流程,调和 children
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}

useContext(函数组件)


const value = useContext(MyContext)


接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。



看如下代码,useContext Hook 挂载阶段和更新阶段,本质都是调用 readContext 函数,readContext 函数会返回 context._currentValue。而且也是调用了 prepareToReadContextreadContext


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
)
}
}

function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes,
) {
prepareToReadContext(workInProgress, renderLanes)
// 处理各种hooks逻辑
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
)
// ...
}

renderWithHooks 函数是调用函数组件的主要函数


function renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
) {
// ...
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // 挂载阶段
: HooksDispatcherOnUpdate // 更新阶段
}

// 确保 Hooks 只能在函数组件内部或自定义 Hooks 中使用,提供正确的调度程序
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current
return dispatcher
}

function useContext(Context) {
const dispatcher = resolveDispatcher()
return dispatcher.useContext(Context)
}

const HooksDispatcherOnMount = {
useContext: readContext,
// ...
}
const HooksDispatcherOnUpdate = {
useContext: readContext,
// ...
}

Class.contextType(类组件)


class MyClass extends React.Component {
componentDidMount() {
let value = this.context
/* 在组件挂载完成后,使用 MyContext 组件的值来执行一些有副作用的操作 */
}
componentDidUpdate() {
let value = this.context
/* ... */
}
componentWillUnmount() {
let value = this.context
/* ... */
}
render() {
let value = this.context
/* 基于 MyContext 组件的值进行渲染 */
}
}
MyClass.contextType = MyContext


挂载在 class 上的 contextType 属性可以赋值为由 React.createContext() 创建的 Context 对象。此属性可以让你使用 this.context 来获取最近 Context 上的值。你可以在任何生命周期中访问到它,包括 render 函数中。




  • 类组件会判断类组件上是否有静态属性 contextType

  • 如果有则调用 readContext 方法,并赋值给类实例的 context 属性,所以我们才可以使用 this.context 获取 context 值


function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ClassComponent:
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
)
}
}

function updateClassComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes,
) {
// ...
prepareToReadContext(workInProgress, renderLanes)
mountClassInstance(workInProgress, Component, nextProps, renderLanes)
// ...
}

function mountClassInstance(workInProgress, ctor, newProps, renderLanes) {
// ...
const instance = workInProgress.stateNode
// 判断类组件上是否有静态属性 contextType
const contextType = ctor.contextType
// 有则调用 readContext
if (typeof contextType === 'object' && contextType !== null) {
// 赋值给类实例的 context 属性
instance.context = readContext(contextType)
}
}

综上,以上三种方式只是 React 根据不同使用场景封装的 API,它们在消费/订阅 context 的共同操作:



  1. 先调用 prepareToReadContext 进行准备工作

  2. 再调用 readContext 方法读取 context 值(readContext 方法返回 context._currentValue 最新值)


上文提到 propagateContextChange ,如果组件订阅了 context,不管是函数组件还是类组件,都会将 fiber.lanes 设置为 renderLanes。在 beginWork 阶段,发现 fiber.lanes 等于 renderLanes,则走 beginWork 的逻辑,强制组件更新


prepareToReadContext 和 readContext 逻辑


prepareToReadContext 的核心逻辑:



  • 设置全局变量 currentlyRenderingFiber 为当前工作的 fiber,并重置lastContextDependency 等全局变量


function prepareToReadContext(workInProgress, renderLanes) {
// 设置全局变量 currentlyRenderingFiber 为当前工作的 fiber, 为 readContext 做准备
currentlyRenderingFiber = workInProgress
// 用于构造 dependencies 列表
lastContextDependency = null
// 将全局变量 lastFullyObservedContext (保存的是 context 对象) 重置为 null
lastFullyObservedContext = null

const dependencies = workInProgress.dependencies
if (dependencies !== null) {
const firstContext = dependencies.firstContext
if (firstContext !== null) {
if (includesSomeLane(dependencies.lanes, renderLanes)) {
// Context list has a pending update. Mark that this fiber performed work.
markWorkInProgressReceivedUpdate()
}
// 重置 fiber context 依赖
dependencies.firstContext = null
}
}
}

readContext 的核心逻辑:



  • 收集组件依赖的所有不同的 context,如果组件订阅了 context,则将 context 添加到 fiber.dependencies 链表中

  • 返回context._currentValue, 并构造一个contextItem添加到workInProgress.dependencies 链表之后。


function readContext(context) {
return readContextForConsumer(currentlyRenderingFiber, context)
}

function readContextForConsumer(consumer, context) {
// ReactDOM 中 isPrimaryRenderer 为 true,则一直返回 context._currentValue
const value = isPrimaryRenderer
? context._currentValue
: context._currentValue2

// 相等说明是同一个 Context,不处理为了防止重复添加依赖
if (lastFullyObservedContext === context) {
// Nothing to do. We already observe everything in this context.
} else {
const contextItem = {
context: context,
memoizedValue: value,
next: null,
}
// 构造一个 contextItem, 加入到 workInProgress.dependencies 链表之后
if (lastContextDependency === null) {
lastContextDependency = contextItem
// dependencies 属性用于判定是否依赖了 ContextProvider 中的值
consumer.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
}
} else {
// 将 context 添加到 fiber.dependencies 链表末尾
lastContextDependency = lastContextDependency.next = contextItem
}
}
// 返回 context._currentValue
return value
}

Context 原理八连问


上面源码实际上还是讲解不够完整的,在这推荐一篇文章:【React 源码系列】React Context 原理,如何合理设计共享状态,个人认为相对讲得很清晰了。


想知道自己对原理的理解,除了输出就是回答解决一些提问了,这里列举了一些原理相关的问题,写下简略的解答,看看自己是否了解。


Provider 如何传递 context?


通过将 Provider 的 value 属性值赋值给 context._currentValue


没有 Provider 包裹,为什么读不到最新的 context 值?


render() {
return (
<>
<TestContext.Provider value={10}>
{/* 可读到 context 值最新值 10 */}
<Test />
</TestContext.Provider>
{/* 只能读到 context 初始值(createContext 函数的参数 defaultValue) */}
<Test />,
</>

)
}

消费 context 时是读取 context._currentValue 值,理论上其它组件也是读取该最新值的。Provider 其中一个特性是只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。所以没有被 Provider 包裹的组件,是只能读到默认值的。


React 在深度优先遍历 fiber 树时,最外层 Provider 开始 beginWork,会先将 context._currentValue 的旧值保存起来,赋新的值给 context._currentValue(所以在里层的组件都能读到最新值),在离开 Provider 节点时会调用 completeUnitOfWork 完成工作,在此会将 context._currentValue 恢复成旧值,到遍历第二个 <Test /> 节点时就读的是 context 的默认值(不被 Provider 包裹的组件 render 时 beginWork 的时候就读到旧值了)。


相同 Provider 嵌套使用,里层的会覆盖外层的数据是怎么实现的?


render() {
return (
<>
<TestContext.Provider value={10}>
<Test1 />
<TestContext.Provider value={100}>
<Test2 />
</TestContext.Provider>
</TestContext.Provider>
</>

)
}

在这场景下, <Test1 /><Test2 /> 组件读取的值分别是 10 和 100。


为了实现嵌套的机制,React 利用的是的特性(后入先出),通过 pushProviderpopProvider


Fiber 深度优先遍历时:



  • 最外层 Provider 将 value 值 10 压入栈 pushProvider,此时栈顶是 10

  • 遍历里层 Provider 时将 value 值 100 压入栈 pushProvider,此时栈顶是 100,即context._currentValue 的值为 100


消费组件 <Test2 />读取时,在其所在 Provider 范围内先读取栈顶的值,所以读取的是 100;里层的 Provider 完成遍历工作离开时,弹出栈顶 popProvider的值 100,此时栈顶的值是 10, 即 context._currentValue 的值为 10,<Test1 /> 里面读到的值也就为 10 了。


由于 React 调和过程就是 Fiber 树深度优先遍历的过程, 向下遍历(beginWork)和向上回溯(completeWork)恰好符合栈的特性(入栈和出栈),Context 的嵌套读取就是利用了这个特性。


三种消费 context 的原理



  • useContext:本质上调用 readContext 方法

  • Context.Consumer:本质上是类型为 REACT_CONTEXT_TYPE 的 React Element 对象,context 本身就存在 Consumer 里面,本质也是调用 readContext

  • Class.contextType:通过静态属性 contextType 建立联系 ,在类组件实例化的时候被使用,本质上也是调用 readContext


三种方式只是 React 根据不同使用场景封装的 API,本质都是调用了 readContext 方法读取 context._currentValue


context 的存取发生在 React 渲染的哪些阶段


context 的存取就是发生在 beginWork 阶段,在 beginWork 阶段,如果当前组件订阅了 context,则从 context 中读取 _currentValue


消费 context 的组件,context 改变为什么会订阅更新?



  • 当 Provider 的 context value 值更新时,会调用 updateContextProvider 方法,里面的 propagateContextChange 方法会对 fiber 子树向下深度优先遍历所有的 fiber 节点,目的是为了找到消费组件标记更新。如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,就会被标记更新。

  • 而消费组件调用的 readContext 方法则会把 fiber.dependencies 和 context 对象建立关联,fiber.dependencies 用于判断是否依赖了 ContextProvider 中的值

  • context 值更新时消费 context 的 fiber 和父级链都会提高更新优先级,向上遍历时,会设置消费节点的父路径上所有节点的 fiber.childLanes 属性,(childLanes 属性用于判断子节点是否需要更新)需要更新则子节点就会进入更新逻辑(开始 beginWork)。


消费 context 的组件是如何跳过 PureComponent、shouldComponentUpdate 强制 render?



  • 类组件更新流程中,强制更新会跳过 PureComponentshouldComponentUpdate 等优化策略,在外部代码层面,我们可调用 this.forceUpdate(),就会给类组件打上强制更新的 tag。而在内部实现上, context 的 value 改变时,要想订阅 context 的类组件更新,相应的也得打上强制更新的 tag

  • 当 context 值发生变化时,会调用 propagateContextChange 对 Fiber 子树向下深度优先遍历所有的 fiber 节点,如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,如果 fiber 节点是类组件, 则会创建一个 update 对象,并将 update.tag 标记为 ForceUpdate;而处理 update 时,发现 tag 为 ForceUpdate 的话,会将全局变量 hasForceUpdate 设置为 true, 这决定了类组件会强制更新。



updateClassComponent 中会调用 updateClassInstance 判断类组件是否应该更新。在 updateClassInstance 中会判断全局变量 hasForceUpdate 或者组件的 shouldComponentUpdate 的返回值是否为 true, true 则表示要强制更新。



简述 Context 原理


Context 的实现原理:



  • 创建 Context:createContext 返回一个 context 对象,对象包括 ProviderConsumer 两个组件属性,并创建 _currentValue 属性用来保存 context 的值

  • Provider 负责传递 context 值,并使用栈的特性存储修改 context 值

  • 消费 Context:消费组件节点调用 readContext 读取 context._currentValue 获取最新值

  • Provider 更新 Context:ContextProvider 节点深度优先遍历子代 fiber,消费 context 的 fiber 和父级链都会提升更新优先级;对于类组件的 fiber ,会被 forceUpdate 处理。接下来所有消费的 fiber,都会执行 beginWork


结语


本文对 Context 源码的理解有限,暂未能完全读完,只是过了一遍大致实现,如有错误恳请纠正。


参考文章




作者:JackySummer
来源:juejin.cn/post/7213752661761523772
收起阅读 »

css-transform2D变换

web
CSS transform 属性允许你旋转,缩放,倾斜或平移给定元素。 常用的transform 属性有下面几个 属性说明translate(0, 0)位移rotate(0deg)旋转scale(1)缩放skew(0deg)斜切 transform的说明文档:...
继续阅读 »

CSS transform 属性允许你旋转,缩放,倾斜或平移给定元素。
常用的transform 属性有下面几个


属性说明
translate(0, 0)位移
rotate(0deg)旋转
scale(1)缩放
skew(0deg)斜切

transform的说明文档:developer.mozilla.org/zh-CN/docs/…


下面分别说一下这几个方法


translate() 位移


translate通过x、y轴的参数来实现偏移
语法:transform: translate(10px, 10px); x轴偏移10pxy轴偏移10px
也可以单独对某一个轴进行偏移设置,css提供了x、y轴的语法:
transform: translateX(10px);
transform: translateY(10px);


translate的参数可以使用百分比,如果参数是百分比的话,实际的偏移距离是以自身大小为参考的,例如:一个100px的正方形,translateX(50%),那么实际x轴的偏移量是自身的100px * 50% = 50px,有了这个特性之后,可以通过transform: translate(-50%, -50%); 的写法实现垂直定位居中。


.box{
width: 20px;
height: 20px;
background: #e94242;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}

在这里插入图片描述


transform: translate第一个参数偏移自身x轴的50%,第二个参数偏移自身y50%,另外left偏移50%,假如自身100px
那么:left + 自身 - x轴自身50% = 50% + 100px - 50px = 偏移量正好居中,y轴同理。



另外,translate是不受文档流影响的,direction: ltr;文档流为左,translateX依然往右偏移。





rotate() 旋转


rotate() 用于设置元素的旋转角度,rotate(45deg)就是顺时针旋转45°rotate()的旋转受锚点的影响(transform-origin),锚点的问题在下文。
rotate() 有四个单位,分别是:deg角度、grad百分度、rad弧度 、return圈度,最常用的就是deg角度,其它的日常项目基本用不到。


.box{
width: 20px;
height: 20px;
background: #e94242;
transform: rotate(45deg);
}

在这里插入图片描述




scale()缩放


scale()有两个参数,语法:transform: scale(参数一 , 参数二),分别对应横向和纵向的放大和缩小,默认值为1(不放大)。


transform: scale(2); /**等比放大2倍 */
transform: scaleX(2); /**水平放大2倍 */
transform: scaleY(2); /**垂直放大2倍 */
transform: scale(2,1); /**x轴放大2倍,y轴不变 */
transform: scale(2,0.5); /**x轴放大2倍,y轴缩小一半 */

.shiftBox{
width: 80px;
height: 80px;
transform: scale(2,0.5); /**x轴放大2倍,y轴缩小一半 */
}

在这里插入图片描述




skew() 斜切


斜切字面意思就是将物体倾斜的意思,语法:transform: skew(10deg, 5deg)表示水平斜切10度 垂直斜切5度,它接受两个参数,第一个参数表示x轴,第二个参数y轴。
也可以单独对某一个轴进行斜切,css提供了x、y轴的语法:
transform: skewX(10deg):水平斜切10
transform: skewY(10deg):垂直斜切10


/* skew() 斜切 */
.shiftBox{
width: 80px;
height: 80px;
background: #80c342;
transform: skew(10deg, 5deg); /**水平斜切10度 垂直斜切5度 */
}

在这里插入图片描述


斜切可以应用在图形的变换,只通过调整x、y轴的倾斜角度即可实现一些画面效果,某些场合下比裁切属性(clip-path)方便。
例如:实现当前任务的进度展示


在这里插入图片描述


这种效果只需要绘制一个矩形,将x轴倾斜45


在这里插入图片描述


再绘制一个矩形,x轴倾斜 -45°即可实现


在这里插入图片描述




transform的细节和特性


元素引用transform属性值不会影响元素的尺寸和位置


我们在日常布局的时候,使用margin或者定位通常会影响到其他的元素


在这里插入图片描述


比如上面这个案例,第二个按钮设置了margin-left,导致第三个按钮的位置也发生变化。
如果第二个按钮使用的是transform: translateX()偏移,那么第三个按钮的位置并不会受到影响,因为transform属性值不会影响原始位置


在这里插入图片描述


另外,内联元素是不受transform所有的变换特性的影响的,必须转为行内块才可以。


span{
/* 内联元素不受transform所有的变换特性 */
display: inline-block; /* 设置行内块后,受transform影响,解决 */
transform: translateX(50px);
}



参数的顺序不同,会影响结果


transform的参数,会按照先后顺序执行,同样的参数,位置不同则会影响执行结果。


.order{
width: 200px;
height: 200px;
border: 1px solid red;
:nth-child(1){
width: 20px;
height: 20px;
background: #4d90fe;
transform: translateX(50px) scale(2); /* 先位移再放大,顺序影响结果 */
}
:nth-child(2){
width: 20px;
height: 20px;
background: #80c342;
transform: scale(2) translateX(50px); /* 先放大再位移,顺序影响结果 */
}
}

在这里插入图片描述


这里b盒子先放大后,再执行translateX,按照放大后的比例进行的偏移,所以b的偏移量比a的远。


有两点需要注意:
1、transformclip-path同时使用时,先裁剪再变换
2、transformmargin,应该优选选择transform,性能更高,因为transform属性值不会影响原始位置。




transform会创建新的层叠上下文


多个元素叠在一起时,通常后执行的元素会覆盖先执行的元素,类似下面的:


在这里插入图片描述


一层叠一层,如果想突出展示元素可以设置z-index来改变层级,其实这里使用transform也可以实现,transform会创建新的层叠上下文,后执行的元素会覆盖先执行的,所以这里无需z-index也可以实现突出展示层级效果,这里使用了transform: scale(1); 原大小保持不变,相当于没对元素做任何操作,但是层叠顺序改变了,如下:


.layer{
width: 200px;
height: 50px;
border: 1px solid red;
padding-left: 20px;
margin: 50px;
>img{
width: 50px;
margin-left: -20px;
}
>img:hover{
transform: scale(1); /*原大小*/
box-shadow: 0px 0px 5px black;
}
}

在这里插入图片描述




固定定位实效


固定定位fixed:元素会被移出正常文档流,并不为元素预留空间,而是通过指定元素相对于屏幕视口(viewport)的位置来指定元素位置。元素的位置在屏幕滚动时不会改变。
但如果fixed的父级设置了transform,那么固定定位将会实效。


/* 固定定位实效 */
.positions{
width: 200px;
height: 50px;
border: 1px solid red;
margin-top: 10px;
.positionBox{
width: 50px;
height: 50px;
background: #80c342;
transform: translateX(10px);
.positionInner{
width: 20px;
height: 20px;
background: #e94242;
right: 0px;
position: fixed; /* 父级设置了transform导致fixed失效 */

}
}
}

在这里插入图片描述




改变overflow对元素的限制


父级元素设置overflow: hidden;是不能对设置了绝对定位的子级元素产生影响的,子级内容超出父级范围不能被隐藏。


.overFlow{
width: 100px;
height: 100px;
background: #4d90fe;
overflow: hidden;
>img{
width: 200px;
height: 50px;
position: absolute; /* 绝对定位不受overflow:hidden影响 */
border: 1px solid red;
}
}

在这里插入图片描述


但如果给父级设置了transform,则会更改overflow的限制,绝对定位的子元素也受到到影响


.overFlow2{
width: 100px;
height: 100px;
background: #80c342;
overflow: hidden;
transform: scale(1); /* transform更改overflow的限制,绝对定位的子元素也受到到影响 */
>img{
width: 200px;
height: 50px;
position: absolute;
bottom: 0;
border: 1px solid red;
}
}

在这里插入图片描述


在这里还有个注意点,img图片跑到底部了,因为父级元素设置了transform,只要transform属性值不为none的元素也可以作为绝对定位元素的包含块 ,相当于开启了相对定位。




transform-origin更改元素变换的中心坐标


transform-origin CSS 属性让你更改一个元素变形的原点。其实就是元素的锚点坐标,默认锚点在元素的中心。


.innerBox2{
width: 20px;
height: 20px;
background: #e94242;
transform: rotate(20deg); /*顺时针旋转20°*/
}

在这里插入图片描述


锚点在中心,顺时针旋转20°,如果更改锚点的位置为右上角,那么会出现下面的效果


.innerBox2{
width: 20px;
height: 20px;
background: #e94242;
transform: rotate(20deg);
transform-origin: right top; /**受锚点影响 */
}


锚点可以使用方向关键字,也可以使用参数。


在这里插入图片描述


关于锚点的介绍,请看文档:developer.mozilla.org/zh-CN/docs/…


下面通过锚点实现钟摆效果


<div class="originPointer"></div>

.originPointer{
width: 10px;
height: 100px;
margin: 50px;
&::before{
content: '';
width: 10px;
height: 10px;
position: absolute;
background: #80c342;
border-radius: 50%;
transform: translateY(-50%);
}
&::after{
content: '';
width: 10px;
height: 100px;
background: #4d90fe;
position: absolute;
clip-path: polygon(50% 0%, 50% 0%, 100% 100%, 0% 100%);
transform: rotate(0deg);
/* transform-origin: top left; */ /* 改变锚点为左上角 */
transform-origin: 0px 0px; /* 锚点左上角 x轴和y轴,默认起点在最左侧 */
animation: pointer 2s infinite linear; /* 添加linear使画面流程不卡顿 */
}
@keyframes pointer {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-20deg);
}
100% {
transform: rotate(0deg);
}
}
}

在这里插入图片描述




案例源码:gitee.com/wang_fan_w/…


如果觉得这篇文章对你有帮助,欢迎点赞、收藏、转发哦~


作者:fanction
来源:juejin.cn/post/7211451845032902711
收起阅读 »

关于CodeReview的一些思考与看法

写在前面:本篇文档是关于团队实践中CodeReview的一些个人想法,非常主观。想法主要来源于日常工作的一些感想,以及参考了其他团队的一些CodeReview规范和做法,有很多的地方考虑不周到,还请大家多多包涵。本篇文档的主要目的是拉起大家对于CodeRevi...
继续阅读 »

写在前面:本篇文档是关于团队实践中CodeReview的一些个人想法,非常主观。想法主要来源于日常工作的一些感想,以及参考了其他团队的一些CodeReview规范和做法,有很多的地方考虑不周到,还请大家多多包涵。本篇文档的主要目的是拉起大家对于CodeReview的一些思考,如果看到这里,已经燃起你对思考CodeReview这件事情的欲望了,那么就请在这里打住,不要再往下看了。


为什么要拉CodeReview会?


从两方参与变为三方参与。



两方:reviewer,author


三方:reviewer,author,others





  • 对于author来说,



    • 拉会的形式能够加速review的流程,高效迅速完成CodeReview,避免一个mr拖太久

    • 能够引入更多的同学拉review自己的代码,减少低级错误,更好地提升和保障代码的质量

    • 拉会的形式对于author的逻辑表达能力有更高的要求,可以锻炼自己讲解代码的能力,同时也是自己知识输出的一种途径




  • 对于reviewer来说,



    • 拉会的形式能够帮助reviewer更好地理解代码逻辑,避免自己花大量时间看大段逻辑复杂的代码

    • 对于代码中有疑问的地方能够直接提出疑问,并及时得到解答,提高review效率

    • 避免漏掉review一些比较小的点





代码评审有个重要的作用,那就是可以教会开发者关于语言、框架或者通用软件设计原理。
——from 谷歌 code review实践





  • 对于others,



    • 新同学能够学习到组内大佬的思路和解决方案,加速成长

    • 促进团队内部知识共享,提高团队整体水平




什么时候应该拉CodeReview会?



  • 新增代码逻辑较为复杂,如新增某个接口or新增某个特性

  • 代码改动较大,如对某个模块进行了整体的优化or把代码改得面目全非了

  • 引入了新的技术或者新的架构


什么时候不应该拉CodeReview会?



  • 代码改动较小或改动的逻辑较为简单

  • mr上评论未解决,或检查未通过


CodeReview流程




  • 会前



    • 代码已完成自测,并且提mr,邀请相关的reviewer

    • 提前一到两天与主reviewers(至少一位主reviewer)约定时间,并将会议链接发到群里,感兴趣的同学可自行选择参与





如何选择主reviewers?



  1. 模块的负责人或者对模块熟悉度比较高的人

  2. 此次开发改动了对方的代码、逻辑

  3. 技术评审、需求开发过程中较为活跃或者贡献出意见的人





  • 会中



    • author首先简单同步一下需求的背景和改动的范围

    • author整体过一遍代码,重点讲述代码变动的地方和需要讨论的地方

    • reviewer可随时打断,提出自己的疑问或者修改建议,author进行解答或反驳。



    • 注意气氛,实施review时,要营造一个讨论问题、解决问题的氛围,不要搞成批判会或吵架会




    • author控制review的时间在1小时之内,避免长线作战




  • 会后




    • author根据修改建议完成代码修改,并邀请reviewer再次评审,如无问题,reviewer可以点approve,然后合入


作者:啊北_
链接:https://juejin.cn/post/7212563816453898301
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

回顾下小城市十年的经历:努力过,躺平过。

【回顾】 十年,目睹了时代的改变,也见证了技术的迭代。 【2011】大学毕业前的实习 这一年,大学即将毕业毕业,寒假回到了家乡的小县城,歪打正着的找了一份家乡县级市ZF的实习工作(合同工)。 技术:Ps Html Css 内容:内网新闻专题 薪资:150...
继续阅读 »

【回顾】



十年,目睹了时代的改变,也见证了技术的迭代。



【2011】大学毕业前的实习


这一年,大学即将毕业毕业,寒假回到了家乡的小县城,歪打正着的找了一份家乡县级市ZF的实习工作(合同工)。



  • 技术:Ps Html Css

  • 内容:内网新闻专题

  • 薪资:1500

  • 房价:3000/平


【2012】大学毕业了


这一年,大学毕业了,毕业那天喝了很多,并豪情壮志的和室友预定了四个五年计划。(一五:年薪10万。二五,年薪20万,三五,年薪30万,四五,财富自由。)


于是我放弃了ZF的安稳,只身来到了当地的十八线小城市,为了安身立命,于是随便找了一家只有2人的公司做网站~



  • 职位:美工

  • 技术:Ps Html Css Asp

  • 内容:企业网站

  • 薪资:1000

  • 房租:600

  • 房价:3500/平


这段时间,由于工资过低,每天只吃1-2顿饭,而且每天熬夜学习,体重从大学期间的100Kg降到了65Kg。


终于,在2012年的下半年找到了自己的第一份正式工作--所在城市日报集团。



  • 职位:美工

  • 技术:Ps Html Css JQ PHP

  • 门户网站、专题、企业网站

  • 薪资:3500

  • 房租:0(包吃住)

  • 房价:3500/平


【2014】开始学H5+C3


由于不知道多少线的小城市,技术需求极低,大部分工作内容还要考虑IE6的兼容性。技术部老大跟我说H5和C3咱们一时半会是用不到的。但我还是学了,我觉得早晚会用到。



  • 职位:美工

  • 技术:Ps Html Css JQ PHP

  • 门户网站、专题、企业网站

  • 薪资:3800

  • 房租:0(包吃住)

  • 房价:3500/平


【2015】辞职-新工作-公司解散-新工作


公司来了新领导,开始大刀阔斧的改革,并要求技术部承担经营任务,技术部老大一气之下带着我和另外一个程序W 离职了,大家决定自己干!


老大带我们组个个外包团队,包了一个公司的H5封装APP的活,签了3年的外包合同。我负责H5页面,W负责程序,老大负责服务器。我们仨找了个孵化器开始干了起来。



  • 职位:美工?前端?

  • 技术:Ps Html5 Css3 JQ

  • H5封装APP

  • 薪资:7000

  • 房租:500

  • 房价:5500/平


三个月后甲方撤资了~~ 突如其来的大家没了收入。老大还在努力的找新单子,我们也发现这不是长久之计,于是边接小单子边找新的出路。


新工作来的很意外,之前报社的老领导去了临市的大众网记者站当副站长,又被调回我们城市成立新的记者站。于是老领导找到了我,他们需要一个全栈员工。老领导于我也算是有知遇之恩,所以我决定去帮帮她。虽然给了我主任级别的待遇,但是工资依然不高。



  • 职位:全栈

  • 技术:Ps Html5 Css3 JQ 内部cms

  • 门户网站、专题、企业网站 微信H5

  • 薪资:4000

  • 房租:500

  • 房价:5500/平


【2016】 微信小程序来了


这一年,微信小程序的风吹进了我们这座小城市,开始有人问我会不会做。于是开始学习,并成功为一个经典做了一个卖票小程序,赚了3000.



  • 职位:全栈

  • 技术:Ps Html5 Css3 JQ 内部cms

  • 门户网站、专题、企业网站 微信H5

  • 薪资:4500

  • 房租:500

  • 房价:6500/平


【2018】结婚-辞职-新工作


这一年,和相恋5年的女朋友结婚了。忽然之间有了责任的压力,看看工资卡的工资,现在的工作显然是不行了,于是我决定辞职。


当我决定辞职之后我收到了两个橄榄枝,分别是老东家报社,和老同事W的。


老东家的一个同事已经升为主任,邀请我回去,工资开到了7500,在一个报社这样的单位我知道这是她的极限了。


W则是在我们三人组解散之后去了一家小公司,老板是个富二代,人也很好。现在业务增多想要叫我过去。


最终我选择了W的公司



  • 职位:美工?前端?

  • 技术:Ps Html5 Css3 JQ PHP

  • 商城类网站

  • 薪资:13000

  • 房租:0(买房了,朋友的顶账房,没贷款欠朋友的,慢慢还。)

  • 房价:8000/平


【2019】vue来了我却躺平了


这一年,老婆的公司开始使用VUE2了,老婆让我跟她一起学,我却沉迷游戏躺平了。


现在公司的环境很好,老板有钱人也很好。技术部的电脑都是i9-11代的处理器+2060的显卡,这哪里是公司简直是网吧。


公司也是经常几周甚至一个月没有新的业务,只要保证手里的几个网站正常运行就可以了,于是大家开始沉沦,每天上班也几乎都是在看电影和打游戏,还有免费的零食和饮料。


大家每天也都是在讨论哪个3A大作发售了,哪个电影上映了,很少能听到关于技术的讨论了。



  • 职位:美工?前端?

  • 技术:Ps Html5 Css3 JQ PHP

  • 商城类网站

  • 薪资:15000

  • 房价:9000/平


【2020】疫情来了,小天使来了,危机也在慢慢靠近。


这一年,疫情开始蔓延,小宝宝也降生了。


公司还是一如既往,虽然我们知道业务在下滑,但是老板有钱,工资不降反升。


老婆打算辞掉工作专心把孩子带到3岁再工作。我考量了一下我的收入,觉得在这个不知道多少线的小城也算可以,就同意了。



  • 职位:美工?前端?

  • 技术:Ps Html5 Css3 JQ PHP

  • 商城类网站

  • 薪资:17000

  • 房价:10000/平


【2021】终于开始学VUE和React


这一年,终于开始学习VUE和React,虽然公司依然没有使用新框架的打算,主要是因为后端不想写接口。想继续使用现在的程序。好像大家都躺平了,也可能是小城市的惬意。



  • 职位:美工?前端?

  • 技术:Ps Html5 Css3 JQ PHP

  • 商城类网站

  • 薪资:18000

  • 房价:9000/平


【2022-上】听说大厂裁员,我们老板“毕业”了。


这一年,听说大厂都在裁员,小城市却依然风平浪静。大家日复一日的摸鱼。


某一天,噩耗传来,老板进去了,公司解散。WTF。


晴天霹雳,没想到我们还没毕业,老板却“毕业”了



  • 职位:无

  • 薪资:0

  • 房价:9000/平


【2022-中】现实的社会和迟到的技术


2022精彩的一年,被狠狠的从舒适圈里踢了出来。一脸懵逼。


开始重新找工作却发现,自己的技术早已落伍,不得已又要从头学起。


无奈重新学了Vue2和Vue3,学了node.js,学了webpack,又学了ES6和TypeScript。


终于补完了前两年欠下的学习,却发现这个城市的前端工资普遍在6K-9K


落差感又一次让我一脸懵逼


【2022-下】焦虑和心虚


最终我进入了一家实体企业,HR看着我的简历对我十年的工作经验还算满意,我却很心虚。


总觉得自己这十年的工作经验水分很大。


看着比我年轻的同事熟练的使用着各种框架,而我却还在查着各种API。


我陷入了深深的自我怀疑,我是不是蹉跎了我的青春。



  • 职位:前端

  • 技术:Html5 Css3 Vue2

  • 内部平台系统、微信小程序

  • 薪资:9000

  • 房价:9000/平


【总结】



小城市没有那么多的技术需求,也没有那么多的996,大家朝九晚五,周末双休过得很惬意。



刚毕业那会用着Table切图用着Asp做后台,大家只知道你是做网站的,公司招人也是招美工,要求设计、切图、程序都会一些。小城市就是要求你什么都会一点,但不用很精通,够用就行。


慢慢的随着技术的发展前端才被定义出来,但是很多公司招聘的时候写着需要使用Vue或者React,但事实上还是干这设计切图的活,前后端不分家。


小城市技术渗透的慢,但是依然在渗透,四年的惬意生活,让我慢慢的忘记自己最初的梦想,惊醒的时候却发现我已经掉队了。


【未来可期】



被踢出舒适圈,也被踢醒了



想想大学时期的四个五年计划一个也没实现,还剩最后一个,或许还能抢救一下,虽然很不切实际,但是总有个盼头。


重新规划自己的未来,躺也躺过了,现在想想躺平的日子也就那样。虽然自己已经30+但是还是可以继续搏一搏的。


重新定制一个三年计划吧



  • 继续学习、阅读源码,做1-2个长期维护的项目。

  • 研究下混合基金,适量投入。

  • 看一些游戏开发相关的书,研究下Unreal引擎,争取做出一个游戏Demo。(游戏剧本已经写好了2个)。

作者:不知君
链接:https://juejin.cn/post/7143404185738805262
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

程序员能有什么好玩意?

从业10年了,看看一枚老成员都有什么好玩意(有个人的、同事的、公司的……)。【多图预警!!!摸鱼预警!!!】 桌面预警 桌面上放了个二层小架子,之前还有个盆栽的,可惜死掉了,悼念&缅怀+1。 喷雾预警 好几年前的习惯,之前是理肤泉的喷雾。当年的我还是...
继续阅读 »

从业10年了,看看一枚老成员都有什么好玩意(有个人的、同事的、公司的……)。【多图预警!!!摸鱼预警!!!


桌面预警


桌面上放了个二层小架子,之前还有个盆栽的,可惜死掉了,悼念&缅怀+1。


image.png


喷雾预警


好几年前的习惯,之前是理肤泉的喷雾。当年的我还是很暴躁的,需要一点水分帮我降降温,不过,当编程没有啥思路的时候,喷一喷感觉还不错。


image.png


养生预警


西洋参


有个同事是吉林的,某一天送给我一个山货大礼包,其中就有这瓶西洋参参片。偶尔会取几片泡水,当然喝茶的时候更多一些。【咖啡基本是戒了】


image.png


手串


年前,我领导说想弄个串儿盘着,防止老年痴呆。


我就买了些散珠自己串了些串,团队内,每人分了一串儿。


自己也留了些手串,每天选一串佩戴,主要是绕指柔的玩法。


image.png


image.png


image.png


茶事


喝茶也又些年头了,喝过好喝的,也扔过不好喝的。最近主要喝云南大白,家里的夫人也比较喜欢,


香道


疫情的风刮过来,听说艾草的盘香可以消毒杀菌,就买了盘香,还有个小香炉。周末在家会点一点,其实没那么好闻,但是仪式感满满的。


手霜


大概是东北恶劣的天气原因,办公室的手霜还是不少的,擦一擦,编码也有了仪式感。


盆栽


公司之前定了好多盆栽,我也选了一盆(其实是产品同学的,我的那盆已经养死了)。


image.png


打印机


家里买了台打印机,主要是打印一些孩子的东西,比如涂鸦的模版、还有孩子的照片。


image.png


工作预警


笔记本


大多用的是Mac,大概也不会换回Windows了。


image.png


耳机


还是用的有线耳机,没赶上潮流。哈哈


image.png


键盘


依然没赶上机械键盘的潮流,用的妙控……


面对疾风吧!


之前客户送的,小摆件。


image.png


证书


证书不少,主要是毕业时候发的,哈哈哈。



  1. 前年,公司组织学习了PMP,完美拿到了毕业后的第一个证书。

  2. 公司组织的活动的证书OR奖杯(干瞪眼大赛、乒乓球大赛、羽毛球大赛等),最贵的奖品应该是之前IDEA PK大赛获得的iwatch。

  3. 年会时发的证书。作为优秀的摸鱼份子,每年收到的表彰并不少,大多是个人的表彰,还有就是团队的证书,当然我更关心证书下面的奖金。

  4. 社区的证书。大致是技术社区的证书,嗯嗯,掘金的就一个,某年的2月优秀创作者,应该是这个。


家里的办公桌


夫人是个文艺女青年,喜欢装点我们的家,家里的办公桌的氛围还是很OK的。当然工作之余,也喜欢和夫人喝点小酒,我喜欢冰白,同好可以探讨哈。


image.png


悲伤的事情


疫情


疫情对我们的生活影响还是比较大的,特别是对我一个大龄程序员而言。


未来


今年打算给家庭计划一些副业,有余力的情况下,能够增加一些收入。人生已经过去了半数,感悟到生命的可贵,感情的来之不易,愿我们身边的人都越来越幸福。


作者:襄垣
链接:https://juejin.cn/post/7195814363692089403
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

New Bing最快申请流程

写在前面 按照以下流程操作无需等待审核,申请后可立即使用 New Bing !!! 下载 Edge Dev, 下载地址:http://www.microsoftedgeinsider.com/en-us/downl… ; 开启魔法 ip 挂在美国或英国(这会...
继续阅读 »

写在前面


按照以下流程操作无需等待审核,申请后可立即使用 New Bing !!!



  1. 下载 Edge Dev, 下载地址:http://www.microsoftedgeinsider.com/en-us/downl…image.png

  2. 开启魔法 ip 挂在美国英国(这会让申请立马审核通过),打开 Edge dev 浏览器, 登录 Microsoft 账号(要新创建一个 outlook 账号,原来的 outlook 账号可能会申请失败);image.png

  3. 创建 outlook 账号最好选择所在地在美国或英国,创建完成后登录;image.png

  4. 点击右上角图标image.png,会弹出一个窗口,让你加入New Bing的等待名单(或者直接通过链接 新必应 - 了解详细信息 (bing.com),页面如下图);image.png

  5. 如果出现以下报错,说明你不是用的新创建的 outlook 账号申请的,按照2、3操作新建一个账号;image.png

  6. 第4步点击 Sign in to chat 后即可成功使用 New Bing 了!image.png


总结


要点:



  1. 使用 Edge Dev

  2. 魔法到美国英国

  3. 新建一个 outlook 账号,选择所在地在美国或英国,并登录


以下是 New Bing 自己提供的使用申请教程 (手动狗头)


image.png


New Bing是微软推出的一款基于GPT4模型的智能搜索引擎,它不仅可以提供相关的搜索结果,还可以与用户进行人性化的对话,帮助用户解决各种问题。New Bing目前处于内测阶段,需要申请才能使用。本文将介绍如何申请和使用New Bing。


首先,你需要下载并安装Edge dev版本的浏览器,这个版本可以直接申请New Bing的内测资格。你可以在这个网址下载Edge dev版本:http://www.microsoftedgeinsider.com/en-us/downl…


安装完成后,你需要打开Edge dev浏览器,并登录你的微软账号。然后,你可以点击右上角的必应图标,会弹出一个窗口,让你加入New Bing的等待名单。点击加入后,你就成功申请了New Bing的内测资格。


但是,这并不意味着你就可以马上使用New Bing了。你还需要等待微软审核通过后,给你发送一封欢迎邮件。这个过程可能需要几天或者几周的时间,取决于微软的审核速度和名额数量。


为了加快审核通过的速度,你可以做以下两件事:



  • 将Edge dev浏览器设置为默认浏览器,并在浏览器中登录你申请内测时候的账号。

  • 扫描QR代码以安装Microsoft必应应用,并在手机上登录你申请内测时候的账号。


这样做可以让微软看到你对New Bing有足够的兴趣和需求,并且愿意使用必应作为主要搜索引擎。


当你收到欢迎邮件后,恭喜你!你就可以开始体验New Bing了!打开Edge dev浏览器,并访问http://www.bing.com/new ,然后点击左上角的聊天按钮,就可以进入New Bing的对话界面了。


在这里,你可以输入任何问题或者话题,New Bing会根据GPT4模型给出智能、友好、有趣、有用、有深度、有创造力、有逻辑性、有情感性等等各种特点的回答。例如:



  • 问:什么是GPT4模型?

  • 答:GPT4模型是一种基于深度学习和自然语言处理技术的生成式预训练模型。它可以根据大量文本数据学习语言规律和知识,并根据给定输入生成任意类型和长度的文本输出。

  • 问:为什么要用GPT4模型来做搜索引擎?

  • 答:因为GPT4模型具有强大而灵活的文本生成能力和理解能力。它不仅可以提供相关而精确地搜索结果,还可以与用户进行多轮对话交互,在对话中提供更多信息、建议、帮助等服务。

作者:wzdong
链接:https://juejin.cn/post/7212864793651609661
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

看New Bing回答世纪难题:女朋友和妈妈掉水里先救谁

1.女朋友和妈妈掉水里先救谁 今天好奇想看看New Bing怎么回答这种世纪难题 结果New Bing非常聪明,反手建议我不要直接回答这个问题,而是换个角度哄女朋友,带着点不甘心,我继续追问它 New Bing还是耍起了滑头,我开始怀疑背后是不是有个人在回...
继续阅读 »

1.女朋友和妈妈掉水里先救谁


今天好奇想看看New Bing怎么回答这种世纪难题


1.png


结果New Bing非常聪明,反手建议我不要直接回答这个问题,而是换个角度哄女朋友,带着点不甘心,我继续追问它


2.png


New Bing还是耍起了滑头,我开始怀疑背后是不是有个人在回答问题,打好字了再发出来


2.电车难题


3.png


带着些许的不满,我来考New Bing第二道世纪难题:电车难题。有一辆电车在轨道上行驶,此时有一个交叉路口,面前这条路上绑着五个人,另一条路上绑着一个人。此时你是否会选择拉动拉杆


4.png


结果New Bing一如既往的耍滑头,不正面回应问题,就像人一样遇到怎么说都不对的问题,顾左右而言其他。继续尝试让New Bing给出一个决定,New Bing果然还是能给出回答的!!!


5.png


3.牛过河问题


一头牛重800kg,一座桥承重700kg,牛如何过河。


这道题大家之前刷新闻肯定刷到过,来看看New Bing怎么回答


6.png


7.png


这题New Bing只是做了个网上答案的汇总,没有给出自己的看法,略微遗憾


作者:卷福同学
链接:https://juejin.cn/post/7212016466773901349
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

实测GPT4!不到1小时写完了一个小程序界面!推理能力提升能直接破译密文?

开始 本想着ChatGPT充钱后能用上GPT4的多模态识别的功能,然后就在充值完成撒花庆祝之后 GPT4生成小程序 发现虽然GPT4可以用了,但是图片上传却还没有更新,并且由于实在是太火爆了,所以只能每4小时发送50条消息。 所以,无奈之下,就只能先测试下...
继续阅读 »

开始


本想着ChatGPT充钱后能用上GPT4的多模态识别的功能,然后就在充值完成撒花庆祝之后



GPT4生成小程序


发现虽然GPT4可以用了,但是图片上传却还没有更新,并且由于实在是太火爆了,所以只能每4小时发送50条消息。


所以,无奈之下,就只能先测试下相比之前的gpt3.5, gpt4的编码能力是否有了一个实质的提升。


于是我决定试试让他写一个微信小程序的评论界面,可以从我们这篇文章跳转进去。于是有了下面这段对话



但是这个界面展示出来过于简陋了当然,这个功能在gpt3.5中也能够有着很好的完成度。于是我接着让他去丰富界面元素。



于是优化之后有了下面这个界面样式(除了界面上的👍图标是我自己加上去的)



但是这样还是不够,这只是一个简单的界面,完成了基本的发送评论功能,但是当我希望再添加一个回复功能时,界面样式就乱了套了。于是这个就只能暂时作罢。于是回复评论就只能暂时作罢。既然上传不了图片,那我就把评论的样式用文字的形式打出来看看它能否实际理解,于是我这样问


你应该使得评论看起来像这样
我的用户名 2022/3/17 6:34
我觉得今天天气真不错 0

继续完善关于界面逻辑操作,比如用户点赞,每条评论只能点击一次,用户评论数限制。



这里其实可以看出GPT4对于上一版本的推理能力的提升。在上一个版本中,当我需要它修改某段逻辑时,它会把完整代码重新写一遍,再然后可能就超过token限制了界面报错。在GPT4中,根据上下文,它可以明确指出与实现这个功能不相关的逻辑,并注释省略,再给出我的问题的答案。



要实现一个完整的功能,许多细节上的东西chatgpt还不能很好的实现,但是根据我们的提示去拆分功能点,它已经可以把这些功能点做到一个相对较好的整合了,更多的是针对通用场景代码的编写。不得不说的是,gpt4已经能够大大降低编码的时间了。


文末评论区迁移到了chatgpt写的界面去了,大家可以尝试互动。


GPT4的文字能力


虽然引导它完成这个界面属实花费了不少时间,但是确实发现它的语言组织能力比之前提升了不少。
所以我决定让它写一篇“码后感”



" 咱把刚刚对话的内容,写一篇博客介绍一下gpt4的实际应用,要求叙事丰富,语言幽默风趣,重点突出,引发思考。文体不限,诗歌除外,不少于1000字。"





最后,我完全沉浸在了GPT-4助手的智慧之中🤯



GPT4推理能力大提升


在上一个版本中,chatgpt一直被诟病的就是数学能力,但在这次升级之中,官方直接放出评测结果




  • 官方放出评测结果:信心满满


在GPT-4的官方发布会上,OpenAI直接公布了关于其数学能力的评测结果。这是对GPT-4在数学方面表现的一种信任和自信的体现。根据评测报告,GPT-4在处理数学问题时的表现已经有了显著提升。不仅如此,它还在某些复杂的数学任务中展现出了出奇制胜的能力,让人惊叹不已。



  • 更强大的数学解题能力:从基础到高级


GPT-4在数学能力方面的提升并不局限于简单的四则运算。事实上,GPT-4已经能够处理一系列复杂数学问题,包括代数、微积分、概率论等领域。这使得它在帮助用户解决数学难题时更具价值。对于那些一直苦恼于数学问题的用户来说,GPT-4无疑是一个福音。



  • 深度理解与推理:数学问题不再令人头疼


GPT-4在数学能力方面的提升不仅体现在解题能力上,还表现在对数学概念的深度理解和推理能力上。在处理数学问题时,GPT-4能够更好地理解题目中的概念和关系,并根据这些信息进行有针对性的推理。这使得它在解决数学问题时更具策略性和准确性。



  • 用户体验的巨大提升:数学辅导不再遥不可及


随着GPT-4在数学能力方面的突破,用户在使用过程中也将享受到更好的体验。不管是学生在解决作业问题时,还是工程师在处理实际项目中的数学挑战,GPT-4都将成为一个强有力的助手。


求解鸡兔同笼


还是使用经典题目来提问


鸡兔同笼,共有30个头,88只脚。
求笼中鸡兔各有多少只?


这波有理有据,可以得出正确答案。


那如果是肯德基呢?



实测密文破译


为了实验它的逻辑能力,我找了一道密码破译的题目。已知这段密文是维吉尼亚密码,简单点说就是需要分析词频,然后再遍历不同长度的密钥找到能与英文单词的常见频率一一对应上。看看结果如何。



Zemlpxphmj tizgeyel zqqlvmtgw ygwlofsgw h tssniy-eshuxlt vpfi vh lpilz crk nsdu, wbegluw hph mcmswvl. Uxbfiuvw zgrkkrn krcgwakkhvmvpw vp Qpuwpqr aq xog Muviypeaksucp Zrejg Wacxpqr sgeypik vlpu jptwa jeuf. wawhlpxz yiyg wlv jvt xog ieemakrn gbwgvpgrjg sm yeaelppk aji scyuel vh xogmy tizgeyel pp tltwvp jyqq Dcpsqtz Hppila Hejkppvc pp Zptkppmh. Vlhv ieemagqlpx mchlf aogr Vtfpves Ugpgrjgw’ Hpxhtiz tsjmia, ylpel dcw zgx aq hlnmcgv aji jcvnq, wbhjltik cr hpstcpf fyykrn cwjgra.



可能是期望过高,这段密码始终没有成功破解不管是直接推出密码还是给出密钥长度提示,甚至给出了密钥,依然无法解密这段文本。


image.png
image.png


所以只能期待接下来的更新中是否会进一步地加强了。


评论留言


我用gpt4写的评论留言界面链接入口在这里,感兴趣的可以一起讨论。


image.png


作者:Demonslzh
链接:https://juejin.cn/post/7211876919036428344
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

如何写一个炫酷的大屏仿真页

前言 之前我写过一遍文章《从阅读页仿真页看贝塞尔曲线》,简要的和大家介绍了仿真页的具体实现思路,正好写完文章的时候,看到 OPPO 发布会里面提到了仿真页,像这样: 看着确实有点炫酷,我平时也接触了很多跟阅读器相关的代码,就零零碎碎花了一些时间撸了一个双页仿...
继续阅读 »

前言


之前我写过一遍文章《从阅读页仿真页看贝塞尔曲线》,简要的和大家介绍了仿真页的具体实现思路,正好写完文章的时候,看到 OPPO 发布会里面提到了仿真页,像这样:


OPPO折叠屏


看着确实有点炫酷,我平时也接触了很多跟阅读器相关的代码,就零零碎碎花了一些时间撸了一个双页仿真。


看效果:


11.gif


由于使用录屏,所以看着有点卡顿,实际效果非常流畅!


一、基础知识具备


仿生页里面用到很多自定义 View 的知识,比如:



  1. 贝塞尔曲线

  2. 熟悉 Canvas、Paint 和 Path 等常用的Api

  3. Matrix


具备这些知识以后,我们就可以看懂绝大部分的代码了。这一篇同样并不想和大家过多的介绍代码,具体的可以看一下代码。


二、双仿真和单仿真有什么不同


我写双仿真的时候,感觉和单仿真有两点不同:



  • 绘制的页数

  • 背部的贴图处理


首先,单仿真只要准备两页的数据:


QQ20230312-0.jpg


背部的内容也是第一页的内容,需要对第一页内容进行翻转再平移。


而双仿真需要准备六页的内容,拿左边来说:


QQ20230312-1.jpg


我们需要准备上层图片(柯基)、背部图片(阿拉斯加)和底部图片(吉娃娃,看不清),因为我们不知道用户会翻页哪侧,所以两侧一共需要准备六页的数据。


由于翻转机制的不一样,双仿真对于背部的内容只需要平移就行,但是需要新的一页内容,这里相对来说比单仿真简单。


三、我做了哪些优化


主要对翻页的思路进行了优化,


正常的思路是这样的,手指落下的点即页脚:


QQ20230312-2.jpg


这样写起来更加简单,但是对于用户来说,可操作的区域比较小,相对来说有点难用。


另外一种思路就是,手指落下的点即到底部同等距离的边:


QQ20230312-4.jpg


即手指落位的位置到当前页页脚距离 = 翻动的位置到当前页脚的距离


使用这种方式的好处就是用户可以操作的区域更大,翻书的感觉跟翻实体书的感觉更类似,也更加跟手。


总结


这篇文章就讲到这了,这个 Demo 其实是一个半成品,还有一些手势没处理,阴影的展示还有一些问题。


写仿真比较难的地方在于将一些场景转化成代码,有些地方确实很难去想。


talk is cheap, show me code:


仓库地址:github.com/mCyp/Double…


如果觉得本文不错,点赞是对本文最好的肯定,如果你还有任何问题,欢迎评论区讨论!


作者:九心
链接:https://juejin.cn/post/7209625823581978680
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

理解TextView三部曲之番外篇:或许这会是最终的进化

额,为什么会有番外篇呢。。因为新版本上线后,别的同学用我的这个控件,描边显示出问题了-_-! 什么问题呢? 我把问题抽出来,同时把问题放大点,给大家看看(抹眼泪.png)   好嘛,问题不大。。就是描边歪了一点点,对吧。 可是怎么会这样!?,我自己测根本就没...
继续阅读 »

额,为什么会有番外篇呢。。因为新版本上线后,别的同学用我的这个控件,描边显示出问题了-_-!


什么问题呢?


我把问题抽出来,同时把问题放大点,给大家看看(抹眼泪.png)


1_error_show



好嘛,问题不大。。就是描边歪了一点点,对吧。


可是怎么会这样!?,我自己测根本就没有问题,压根就没出现过这样的问题啊。。(抹眼泪.png)


我又去检查了一遍计算描边位置那块的代码,最初是以为其他同学一不小心该了那块的代码,导致描边位置计算出错了,结果发现,代码丝毫没有动过的痕迹。


那怎么会描边出错呢?而且他描边出问题的地方,在我这里这里显示也没什么问题,在他那里会什么会有这么大的偏差呢?


我不信邪,看看那位同学都对StrokeTextView做了哪些设置?结果发现,他多了下面这行代码:


mStrokeTextView.setTypeface(typeface);

我捉摸了一下,发现这行代码很有问题,因为我的StrokeTextView是继承自TextView的,调用setTypeface(),看看它的默认实现:


public void setTypeface(@Nullable Typeface tf) {
if (mTextPaint.getTypeface() != tf) {
mTextPaint.setTypeface(tf);

if (mLayout != null) {
nullLayouts();
requestLayout();
invalidate();
}
}
}

看了一眼我就明白了,它只是给TextPaint设置了不同的typeFace,而我们的描边是使用不同的TextPaint,也就是说setTypeface()只是给我们的文本设置了字体,却没有给我们的StrokeTextPaint设置相同的字体,导致了两种不同字体之间,没有办法对齐位置,导致了描边差异。


怎么解决?简单,照葫芦画瓢就行,我们在StrokeTextView重写setTypeface()方法。


setTypeface()的默认实现有两种,我们都要重写:


@Override
public void setTypeface(@androidx.annotation.Nullable Typeface tf) {
// 模仿TextView的设置
// 需在super.setTypeface()调用之前,不然没有效果
if (mStrokePaint != null && mStrokePaint.getTypeface() != tf) {
mStrokePaint.setTypeface(tf);
}

super.setTypeface(tf);
}


另一种比较复杂,不过我们会模仿就行了:


public void setTypeface(@Nullable Typeface tf, int style) {
if (style > 0) {
if (tf == null) {
tf = Typeface.defaultFromStyle(style);
} else {
tf = Typeface.create(tf, style);
}

setTypeface(tf);
// now compute what (if any) algorithmic styling is needed
int typefaceStyle = tf != null ? tf.getStyle() : 0;
int need = style & ~typefaceStyle;
getPaint().setFakeBoldText((need & Typeface.BOLD) != 0);
getPaint().setTextSkewX((need & Typeface.ITALIC) != 0 ? -0.25f : 0);

// 同步设置mStrokeTextPaint
if (mStrokePaint != null) {
mStrokePaint.setFakeBoldText((need & Typeface.BOLD) != 0);
mStrokePaint.setTextSkewX((need & Typeface.ITALIC) != 0 ? -0.25f : 0);
}
} else {
getPaint().setFakeBoldText(false);
getPaint().setTextSkewX(0);

// 同步设置mStrokeTextPaint
if (mStrokePaint != null) {
mStrokePaint.setFakeBoldText(false);
mStrokePaint.setTextSkewX(0);
}

setTypeface(tf);
}
}

两步解决,但为什么我这显示没问题,别的同学那里显示就出问题了呢?


我突然想起来,相同字体在不同手机上显示是有差异的,而且有些手机不一定都支持那种字体


我和那位同学用着不同厂商的真机进行测试,而我的真机是不支持他设置的字体的,所以看着没问题,但他的小米是支持的。难怪我这看着没问题,他那看着就很离谱。


修改完后,我们在运行一遍。


2_fixed_show


怎一个完美形容!ok,bug解决了,准备提交代码



就这样结束了吗?


时隔多日,我又重新审核了一遍代码,我留意到这样一行代码


float heightWeNeed
= getCompoundPaddingTop() + getCompoundPaddingBottom() + mStrokeWidth + mTextRect.height() + DensityUtil.dp2px(getContext(), 4);

我们需要的高度 = 内边距 + 描边高度 + 文本高度 + 一个额外设定的值 ?


怎么会需要一个额外的值呢?要实现wrap_content的效果,我们的宽度不是只需要加上边距、文本高度和一个描边的高度吗?


好奇怪的逻辑,这不是多余嘛,我当时怎么想的来着哈哈?不符合我wrap_content的预期,把它删了试试,再测一遍


把我之前的测试用例都测了一遍,都运行正常


除了。。除了下面这种情况。


3_error_show



果然,去掉额外的高度,就会有这种高度不够显示的情况。看来当时的我,就是遇到了这种情况,然后一个手快,就给heightWeNeed做了这种适配。


不过这种手快的适配方法貌似不太优雅,为了适配单一的这种情况,要牺牲剩下的所有情况都增加一个额外的高度。


而且因为我们适配的额外高度是一个固定值,如果我们给文本字体大小设置大一点,还是会有高度不够显示的可能,毕竟文本变大了,所需要的高度也就更多了。


好吧,这种适配方法看来是用不得了,要换一个吗?但是计算高度的公式 = 内边距 + 文本高度 + 描边高度,这个公式肯定是没错的。


回到我们最初的问题,我们为什么会需要增加一个额外的固定高度呢?明明公式都是对的,为什么还是会有偏差,难道是公式里的对应的值计算错误了?


我们看看再来看看这个式子:


heightWeNeed = getCompoundPaddingTop() + getCompoundPaddingBottom() +
mStrokeWidth + mTextRect.height();

其中,getCompoundPaddingTop() 和 getCompoundPaddingBottom() 是Android提供的计算内边距的api,这个肯定不至于错吧。


mStrokeWidth是我们的描边宽度,是由用户使用时自定义的,这个没什么需要计算的,就是一个值而已


那么mTextRect.height() 这个呢,我们需要这里返回一个正确的文本高度。


看看这个mTextRect是在哪里赋值的


getPaint().getTextBounds(text, 0, text.length(), mTextRect);

从getTextBounds()里跟下去,发现最后调用测量的是native方法,看不到内部实现,不过我们可以看看getTextBounds()的注释


/**
* Retrieve the text boundary box and store to bounds.
*
* Return in bounds (allocated by the caller) the smallest rectangle that
* encloses all of the characters, with an implied origin at (0,0).
*
* @param text string to measure and return its bounds
* @param start index of the first char in the string to measure
* @param end 1 past the last char in the string to measure
* @param bounds returns the unioned bounds of all the text. Must be allocated by the caller
*/
public void getTextBounds(String text, int start, int end, Rect bounds) {
...
// native 方法
nGetStringBounds(mNativePaint, text, start, end, mBidiFlags, bounds);
}

Return in bounds the smallest rectangle that encloses all of the characters


在bounds中返回包含所有字符的最小矩形


5_bounds_height


也就是说bounds返回的高度,只是能够包含文本的最小高度。


我们在三部曲概览里就讨论过,安卓里文本的描绘,是由几根线来确定的


4_text_lines


文本的高度应该为(fontMetrics.bottom -fontMetrics. top),但是,bounds中返回的height也够文本显示啊?怎么会显示成下面这个样子?


6_error_show


比如这样


7_thought_show


但实际情况好像是这样的


8_thought_show_2


我想到,安卓绘制文本是有起点坐标的,这个起点由gravity,textAlign,和baseline确定,和内容展示高度好像没有关系。


虽然我们展示高度设小了,但它的起点坐标还在原来的位置(比如y坐标baseline),这才导致了18数字显示不完整,底部好像缺了一块。



问题的根本找到了,看来好像有两种解决方法



  1. 调整baseline的位置:把我们的baseline位置上移一些,让它和展示区域底部位置重合,这样就能以最小区域显示完整的文本内容。

  2. 拓宽bounds.height的高度,以(fontMetrics.bottom - fontMetrics.top)作为文本的高度显示,这样就无需改变baseline的位置,但比第一种方案要多需要一些空间。


这里我选了第二种,顺着系统的绘制规则来,图个方便,而且我们的描边也可以利用文本顶部多出来的这些空间。


我们新设个变量 textHeight = fontMetrics.descent - fontMetrics.top


heightWeNeed = getCompoundPaddingTop() + getCompoundPaddingBottom() +
textHeight + mStrokeWidth / 2;

为了最大化利用空间,文字顶部到top线的距离已经足够我们的描边显示了,而bottom线到descent线之间的距离很窄,就可能不够我们的描边显示。


所以只需要在文字底部加一半的描边宽度,同时去掉buttom线和descent线之间的距离,这样就能确保文字和描边都有足够的位置显示了。


好了,番外篇终于结束了,看了眼字数,居然比之前的三部曲系列都要多一些。实在没想到需要这么长的篇幅来讲这两个小优化,谢谢小伙伴们能够看到这里啦。


源码我都已经上传到github了,欢迎小伙伴自取,如果觉得写得不错的,还请给这份工程给个star ~_ <



兄dei,如果觉得我写的还不错,麻烦帮个忙呗 :-)



  1. 给俺点个赞被,激励激励我,同时也能让这篇文章让更多人看见,(#^.^#)

  2. 不用点收藏,诶别点啊,你怎么点了?这多不好意思!

  3. 噢!还有,我维护了一个路由库。。没别的意思,就是提一下,我维护了一个路由库 =.= !!


拜托拜托,谢谢各位同学!


作者:古嘉明同学
链接:https://juejin.cn/post/7111669608842543135
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

前端加载超大图片(100M以上)实现秒开解决方案

web
前言前端加载超大图片时,一般可以采取以下措施实现加速:图片压缩:将图片进行压缩可以大幅减小图片的大小,从而缩短加载时间。压缩图片时需要注意保持图片质量,以免影响图片显示效果。图片分割:将超大图片分割成多个小图块进行加载,可以避免一次性加载整个图片,从而加快加载...
继续阅读 »

前言

前端加载超大图片时,一般可以采取以下措施实现加速:

  1. 图片压缩:将图片进行压缩可以大幅减小图片的大小,从而缩短加载时间。压缩图片时需要注意保持图片质量,以免影响图片显示效果。

  2. 图片分割:将超大图片分割成多个小图块进行加载,可以避免一次性加载整个图片,从而加快加载速度。这种方式需要在前端实现图片拼接,需要确保拼接后的图片无缝衔接。

  3. CDN 加速:使用 CDN(内容分发网络)可以将图片缓存在离用户更近的节点上,从而加速图片加载速度。如果需要加载的图片是静态资源,可以将其存储在 CDN 上,以便快速访问。

  4. 懒加载:懒加载是一种图片延迟加载的方式,即当用户浏览到需要加载的图片时才进行加载,可以有效避免一次性加载大量图片而导致页面加载速度缓慢。

  5. WebP 格式:使用 WebP 格式可以将图片大小减小到 JPEG 和 PNG 的一半以下,从而加快图片加载速度。

  6. HTTP/2:使用 HTTP/2 协议可以并行加载多个图片,从而加快页面加载速度。

  7. 预加载:预加载是在页面加载完毕后,提前加载下一步所需要的资源。在图片加载方面,可以在页面加载完毕后提前加载下一个需要显示的图片,以便用户快速浏览。

而对于几百M或上G的大图而言,不管对图片进行怎么优化或加速处理,要实现秒开也是不太可能的事情。而上面介绍的第二条“图像分割切片”是最佳解决方案。下面介绍下如何对大图进行分割,在前端进行拼接实现秒开。

图像切片原理介绍

图像切片是指将一张大图分割成若干个小图的过程,以便于存储和处理。图像切片常用于网络地图、瓦片地图、图像拼接等应用中。

切片原理主要包括以下几个步骤:

  1. 定义切片大小:首先需要定义每个小图的大小,一般情况下是正方形或矩形。

  2. 计算切片数量:根据定义的切片大小,计算原始图像需要被切成多少个小图。计算公式为:切片数量 = 原始图像宽度 / 切片宽度 × 原始图像高度 / 切片高度。

  3. 切割图像:按照计算出的切片数量,将原始图像分割成相应数量的小图。可以使用图像处理库或自己编写代码实现。

  4. 存储切片:将切割后的小图存储到磁盘上,可以使用常见的图片格式,如JPEG、PNG等。

  5. 加载切片:在需要显示切片的地方,根据需要加载相应的小图,组合成完整的图像。

使用图像切片可以降低处理大图像的复杂度,同时也能够提高图像的加载速度,使得用户可以更快地查看图像的细节。图像切片广泛应用于需要处理大图像的场景,能够提高图像处理和显示效率,同时也能够提高用户的体验。

实现

先上效果图


上传打开图形

先上传大图,至后台进行切片处理, 上传相关代码为:

async onChangeFile(file) {
           try {
               message.info('文件上传中,请稍候...')
               this.isSelectFile = false;
               this.uploadMapResult = await svc.uploadMap(file.raw);
               if (this.uploadMapResult.error) {
                   message.error('上传图形失败!' + this.uploadMapResult.error)
                   return
              }
               this.form.mapid = this.uploadMapResult.mapid;
               this.form.uploadname = this.uploadMapResult.uploadname;
               this.maptype = this.uploadMapResult.maptype || '';
               this.dialogVisible = true;
          } catch (error) {
               console.error(error);
               message.error('上传图形失败!', error)
          }
      }

如果需要上传后对图像进行处理,可以新建一个cmd.txt文件,把处理的命令写进文件中,然后和图像一起打包成zip上传。

如需要把1.jpg,2.jpg拼接成一个新的图片m1.png再打开,cmd.txt的写法如下:

join
1.jpg
2.jpg
m1.png
horizontal

再把1.jpg,2.jpg,cmd.txt三个文件打包成zip文件上传即可

打开图像相关代码

async onOpenMap() {
           try {
               let mapid = this.form.mapid;
               let param = {
                   ...this.uploadMapResult,
                   // 图名称
                   mapid: this.form.mapid,
                   // 上传完返回的fileid
                   fileid: this.uploadMapResult.fileid,
                   // 上传完返回的文件名
                   uploadname: this.form.uploadname,
                   // 地图打开方式
                   mapopenway: this.form.openway === "直接打开图形" ? vjmap.MapOpenWay.Memory : vjmap.MapOpenWay.GeomRender,
                   // 如果要密码访问的话,设置秘钥值
                   secretKey: this.form.isPasswordProtection ? svc.pwdToSecretKey(this.form.password) : undefined,
                   style: vjmap.openMapDarkStyle(),// div为深色背景颜色时,这里也传深色背景样式
                   // 图像类型设置地图左上角坐标和分辨率
                   imageLeft: this.form.imageLeft ? +this.form.imageLeft : undefined,
                   imageTop: this.form.imageTop ? +this.form.imageTop : undefined,
                   imageResolution: this.form.imageResolution ? +this.form.imageResolution : undefined,
              }
               let isVectorStyle = this.form.openway === "存储后渲染矢量";
               await openMap(param, isVectorStyle);
          } catch (error) {
               console.error(error);
               message.error('打开图形失败!', error)
          }
      }

应用案例

应用一 对图像进行拼接前端查看

原始图片为



最终效果为:


体验地址: vjmap.com/app/cloud/#…

应用二 对tiff影像进行切片并与CAD图叠加校准

对tiff影像上传时可设置地理坐标范围。

tiff/tfw, jpg/jpgw坐标文件的格式(6个参数) 0.030000 0.0000000000 0.0000000000 -0.030000 451510.875000 3358045.000000

以上每行对应的含义:

1 地图单元中的一个象素在X方向上的X分辨率尺度。 2 平移量。 3 旋转量。 4 地图单元中的一个象素在Y方向上的Y分辨率尺度的负值。 5 象素1,1(左上方)的X地坐标。 6 象素1,1(左上方)的Y地坐标。

在上传图时需要根据文件中的第一个,第五个和第六个值设置地图范围


或者上传完后,操作菜单中点击设置地图范围进行设置


影像地图切片完成后,可与CAD图进行叠加校准。效果如下


体验地址: vjmap.com/demo/#/demo…

作者:vjmap
来源:juejin.cn/post/7212270321622106170

收起阅读 »

Android App封装 ——架构(MVI + kotlin + Flow)

项目搭建经历记录 Android App封装 ——架构(MVI + kotlin + Flow) Android App封装 —— ViewBinding Android App封装 —— DI框架 Hilt?Koin? Android App封装 —— 实...
继续阅读 »

项目搭建经历记录



  1. Android App封装 ——架构(MVI + kotlin + Flow)

  2. Android App封装 —— ViewBinding

  3. Android App封装 —— DI框架 Hilt?Koin?

  4. Android App封装 —— 实现自己的EventBus


一、背景


最近看了好多MVI的文章,原理大多都是参照google发布的 应用架构指南,但是实现方式有很多种,就想自己封装一套自己喜欢用的MVI架构,以供以后开发App使用。


说干就干,准备对标“玩Android”,利用提供的数据接口,搭建一个自己习惯使用的一套App项目,项目地址:Github wanandroid


二、MVI


先简单说一下MVI,从MVC到MVP到MVVM再到现在的MVI,google是为了一直解决痛点所以不断推出新的框架,具体的发展流程就不多做赘诉了,网上有好多,我们可以选择性适合自己的。


应用架构指南中主要的就是两个架构图:


2.1 总体架构


image.png


Google推荐的是每个应用至少有两层:



  • UI Layer 界面层: 在屏幕上显示应用数据

  • Data Layer 数据层: 提供所需要的应用数据(通过网络、文件等)

  • Domain Layer(optional)领域层/网域层 (可选):主要用于封装数据层的逻辑,方便与界面层的交互,可以根据User Case


图中主要的点在于各层之间的依赖关系是单向的,所以方便了各层之间的单元测试


2.2 UI层架构


UI简单来说就是拿到数据并展示,而数据是以state表示UI不同的状态传送给界面的,所以UI架构分为



  • UI elements层:UI元素,由activity、fragment以及包含的控件组成

  • State holders层: state状态的持有者,这里一般是由viewModel承担


image.png


2.3 MVI的特点


MVI相比与MVVM的核心区别是它的两大特性:


1. 唯一可信数据源


唯一可信数据源,是为了解决MVVM中View层使用大量LiveData,导致各种LiveData数据并行更新或者互相交互时会偶尔出现不可控逻辑,导致偶现一些的奇奇怪怪的Bug。


MVI使用唯一可信的数据源UI State来避免这种问题。


2. 数据单向流动。


image.png


从图中可以看到,



  1. 数据从Data Layer -> ViewModel -> UI,数据是单向流动的。ViewModel将数据封装成UI State传输到UI elements中,而UI elements是不会传输数据到ViewModel的。

  2. UI elements上的一些点击或者用户事件,都会封装成events事件,发送给ViewModel。



PS:这里有同学问,为啥不直接调用ViewModel的方法,还要弄个events事件流这么麻烦?


的确,如果直接调用是很方便,但是这样UI和ViewModel就耦合了,这时就要像MVP架构那样定义很多接口才能解耦。而定义events事件流就是另外一种方便解耦的方法,避免接口膨胀。其次,这个也是为了保证数据的单向流动,如果UI和ViewModel能直接调用方法的话,如果方法还有返回值,就破坏了数据的单向流动。



2.4 搭建MVI要注意的点


了解了MVI的原理和特点后,我们就要开始着手搭建了,其中需要解决的有以下几点



  1. 定义UI Stateevents

  2. 构建UI State单向数据流UDF

  3. 构建事件流events

  4. UI State的订阅和发送


三、搭建项目


3.1 定义UI Stateevents


我们可以用interface先定义一个抽象的UI Stateeventseventintent是一个意思,都可以用来表示一次事件。


@Keep
interface IUiState

@Keep
interface IUiIntent

然后根据具体逻辑定义页面的UIState和UiIntent。


data class MainState(val bannerUiState: BannerUiState, val detailUiState: DetailUiState) : IUiState

sealed class BannerUiState {
object INIT : BannerUiState()
data class SUCCESS(val models: List<BannerModel>) : BannerUiState()
}

sealed class DetailUiState {
object INIT : DetailUiState()
data class SUCCESS(val articles: ArticleModel) : DetailUiState()
}

通过MainState将页面的不同状态封装起来,从而实现唯一可信数据源


3.2 构建单向数据流UDF


在ViewModel中使用StateFlow构建UI State流。



  • _uiStateFlow用来更新数据

  • uiStateFlow用来暴露给UI elements订阅


abstract class BaseViewModel<UiState : IUiState, UiIntent : IUiIntent> : ViewModel() {

private val _uiStateFlow = MutableStateFlow(initUiState())
val uiStateFlow: StateFlow<UiState> = _uiStateFlow

protected abstract fun initUiState(): UiState

protected fun sendUiState(copy: UiState.() -> UiState) {
_uiStateFlow.update { copy(_uiStateFlow.value) }
}
}

class MainViewModel : BaseViewModel<MainState, MainIntent>() {

override fun initUiState(): MainState {
return MainState(BannerUiState.INIT, DetailUiState.INIT)
}
}

3.3 构建事件流


在ViewModel中使用 Channel构建事件流



有人好奇这里为啥用Channel,而不用SharedFlow或者StateFlow?


Channel就像一个队列一样,适合实现单个生产者和单个消费者之间的通信,而 SharedFlow 更适合实现多个观察者订阅同一数据源。而这里的Intent事件更像前者,各个协程生产出不同的Intent事件通过Channel发送给ViewModel,然后在ViewModel中集中处理消费。




  1. _uiIntentFlow用来传输Intent

  2. 在viewModelScope中开启协程监听uiIntentFlow,在子ViewModel中只用重写handlerIntent方法就可以处理Intent事件了

  3. 通过sendUiIntent就可以发送Intent事件了


abstract class BaseViewModel<UiState : IUiState, UiIntent : IUiIntent> : ViewModel() {

private val _uiIntentFlow: Channel<UiIntent> = Channel()
val uiIntentFlow: Flow<UiIntent> = _uiIntentFlow.receiveAsFlow()

fun sendUiIntent(uiIntent: UiIntent) {
viewModelScope.launch {
_uiIntentFlow.send(uiIntent)
}
}

init {
viewModelScope.launch {
uiIntentFlow.collect {
handleIntent(it)
}
}
}

protected abstract fun handleIntent(intent: IUiIntent)

class MainViewModel : BaseViewModel<MainState, MainIntent>() {

override fun handleIntent(intent: IUiIntent) {
when (intent) {
MainIntent.GetBanner -> {
requestDataWithFlow()
}
is MainIntent.GetDetail -> {
requestDataWithFlow()
}
}
}
}

3.4 UI State的订阅和发送


3.4.1 订阅UI State


在Activity中订阅UI state的变化



  1. lifecycleScope中开启协程,collect uiStateFlow

  2. 使用map 来做局部变量的更新

  3. 使用distinctUntilChanged来做数据防抖


class MainActivity : BaseMVIActivity() {

private fun registerEvent() {
lifecycleScope.launchWhenStarted {
mViewModel.uiStateFlow.map { it.bannerUiState }.distinctUntilChanged().collect { bannerUiState ->
when (bannerUiState) {
is BannerUiState.INIT -> {}
is BannerUiState.SUCCESS -> {
bannerAdapter.setList(bannerUiState.models)
}
}
}
}
lifecycleScope.launchWhenStarted {
mViewModel.uiStateFlow.map { it.detailUiState }.distinctUntilChanged().collect { detailUiState ->
when (detailUiState) {
is DetailUiState.INIT -> {}
is DetailUiState.SUCCESS -> {
articleAdapter.setList(detailUiState.articles.datas)
}
}

}
}
}
}

3.4.2 发送Intent


直接调用sendUiIntent就可以发送Intent事件


button.setOnClickListener {
mViewModel.sendUiIntent(MainIntent.GetBanner)
mViewModel.sendUiIntent(MainIntent.GetDetail(0))
}

3.4.3 更新Ui State


调用sendUiState发送Ui State更新


需要注意的是: 在UiState改变时,使用的是copy复制一份原来的UiState,然后修改变动的值。这是为了做到 “可信数据源”,在定义MainState的时候,设置的就是val,是为了避免多线程并发读写,导致线程安全的问题。


class MainViewModel : BaseViewModel<MainState, MainIntent>() {
private val mWanRepo = WanRepository()

override fun initUiState(): MainState {
return MainState(BannerUiState.INIT, DetailUiState.INIT)
}

override fun handleIntent(intent: IUiIntent) {
when (intent) {
MainIntent.GetBanner -> {
requestDataWithFlow(showLoading = true,
request = { mWanRepo.requestWanData() },
successCallback = { data -> sendUiState { copy(bannerUiState = BannerUiState.SUCCESS(data)) } },
failCallback = {})
}
is MainIntent.GetDetail -> {
requestDataWithFlow(showLoading = false,
request = { mWanRepo.requestRankData(intent.page) },
successCallback = { data -> sendUiState { copy(detailUiState = DetailUiState.SUCCESS(data)) } })
}
}
}
}

其中 requestDataWithFlow 是封装的一个网络请求的方法


protected fun <T : Any> requestDataWithFlow(
showLoading: Boolean = true,
request: suspend () -> BaseData<T>,
successCallback: (T) -> Unit,
failCallback: suspend (String) -> Unit = { errMsg ->
//默认异常处理
},
)
{
viewModelScope.launch {
val baseData: BaseData<T>
try {
baseData = request()
when (baseData.state) {
ReqState.Success -> {
sendLoadUiState(LoadUiState.ShowMainView)
baseData.data?.let { successCallback(it) }
}
ReqState.Error -> baseData.msg?.let { error(it) }
}
} catch (e: Exception) {
e.message?.let { failCallback(it) }
}
}
}

至此一个MVI的框架基本就搭建完毕了


3.5运行效果


www.alltoall.net_device-2022-12-15-161207_I_ahtLP5Kj.gif

四、 总结


不管是MVC、MVP、MVVM还是MVI,主要就是View和Model之间的交互关系不同



  • MVI的核心是 数据的单向流动

  • MVI使用kotlin flow可以很方便的实现 响应式编程

  • MV整个View只依赖一个State刷新,这个State就是 唯一可信数据源


目前搭建了基础框架,后续还会在此项目的基础上继续封装jetpack等更加完善这个项目。


项目源码地址:Github wanandroid


作者:剑冲
来源:juejin.cn/post/7177619630050000954
收起阅读 »

Rust在Android端的入门开发

前言 IOS上应用还在半路上,遇到了一些集成问题。在了解、学习过程中发现,IOS的Swifit UI动画真的是比Flutter做的好几倍,后面有时间可以记录记录。本次先记录Android集成吧,对比性能的话,可以在rust中for循环个10万次,对比C的时间消...
继续阅读 »

前言


IOS上应用还在半路上,遇到了一些集成问题。在了解、学习过程中发现,IOSSwifit UI动画真的是比Flutter做的好几倍,后面有时间可以记录记录。本次先记录Android集成吧,对比性能的话,可以在rustfor循环个10万次,对比C的时间消耗。

参考资料

Building and Deploying a Rust library on Android

JNI Create

Create JNI


目录


Rust在Android端的入门开发.png


一、环境准备


rustup配置


这个配置,在装rust的时候就配置了,可以忽略。如果没有配置,想了解的可以看二、Rust入门之Hello World


配置NDK


第一步

先确定自己的NDK目录

默认目录一般都在 /Users/你的用户名/Library/Android/sdk/ndk-bundle 这个位置,用户目录可以用 ${HOME} 代替。


第二步

创建库crate


cargo new android_demo --lib

第三步

切换到 android_demo 项目下,创建 NDK 文件

找到 make_standalone_toolchain.py 文件,执行以下语句


python D:/Android/SDK/ndk-bundle/build/tools/make_standalone_toolchain.py --api 26 --arch arm64 --install-dir NDK/arm64
python D:/Android/SDK/ndk-bundle/build/tools/make_standalone_toolchain.py --api 26 --arch arm --install-dir NDK/arm
python D:/Android/SDK/ndk-bundle/build/tools/make_standalone_toolchain.py --api 26 --arch x86 --install-dir NDK/x86

对应的NDK目录如下


rust_ndk_1.PNG


第四步

找到 cargo的配置文件,~/.cargo/config


[target.aarch64-linux-android]
ar = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm64/bin/aarch64-linux-android-ar"
linker = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm64/bin/aarch64-linux-android-clang"

[target.armv7-linux-androideabi]
ar = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm/bin/arm-linux-androideabi-ar"
linker = "E:/VSCodeWorkspace/rust/android_demo/NDK/arm/bin/arm-linux-androideabi-clang"

[target.i686-linux-android]
ar = "E:/VSCodeWorkspace/rust/android_demo/NDK/x86/bin/i686-linux-android-ar"
linker = "E:/VSCodeWorkspace/rust/android_demo/NDK/x86/bin/i686-linux-android-clang"

其中 E:/VSCodeWorkspace/rust/android_demo 是本次项目目录。


第五步

添加工具链


rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android

第六步

在当前 android_demo 目录下,执行以下语句

编译Rust项目,按需要的架构编译即可。


cargo build --target aarch64-linux-android --release
cargo build --target armv7-linux-androideabi --release
cargo build --target i686-linux-android --release


  • 出现问题

    • note: %1 不是有效的 Win32 应用程序。 (os error 193) ,第三步和第六步编译不一致。解决方法:将第四步,换成Android SDK 目录下的ndk,看下面代码示例。

    • error: linker cc not found,解决方案也是按照下面,一定要使用 .cmd




解决方案


[target.aarch64-linux-android]
ar = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\aarch64-linux-android-ar"
linker = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\aarch64-linux-android26-clang.cmd"

[target.armv7-linux-androideabi]
ar = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\arm-linux-androideabi-ar"
linker = "D:\\Android\\SDK\\ndk\\21.4.7075529\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\armv7a-linux-androideabi26-clang++.cmd"
xxx

产物


rust_target_2.PNG


二、Rust实现


Cargo.toml


[package]
name = "android_demo"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
jni-sys = "0.3.0"

[target.'cfg(target_os="android")'.dependencies]
jni = { version = "0.5", default-features = false }

[lib]
crate-type = ["dylib"]

lib.rs


/*
* @Author: axiong
*/

use std::os::raw::{c_char};
use std::ffi::{CString, CStr};

#[no_mangle]
pub extern fn rust_greeting(to: *const c_char) -> *mut c_char {
let c_str = unsafe { CStr::from_ptr(to) };
let recipient = match c_str.to_str() {
Err(_) => "there",
Ok(string) => string,
};

CString::new("Hello ".to_owned() + recipient).unwrap().into_raw()
}

/// Expose the JNI interface for android below
/// 只有在目标平台是Android的时候才开启 [cfg(target_os="android")
/// 由于JNI要求驼峰命名,所以要开启 allow(non_snake_case)
#[cfg(target_os="android")]
#[allow(non_snake_case)]
pub mod android {
extern crate jni;

use super::*;
use self::jni::JNIEnv;
use self::jni::objects::{JClass, JString};
use self::jni::sys::{jstring};

#[no_mangle]
pub unsafe extern fn Java_com_rjx_rustdemo_RustGreeting_greeting(env: JNIEnv, _: JClass, java_pattern: JString) -> jstring {
// Our Java companion code might pass-in "world" as a string, hence the name.
let world = rust_greeting(env.get_string(java_pattern).expect("invalid pattern string").as_ptr());
// Retake pointer so that we can use it below and allow memory to be freed when it goes out of scope.
let world_ptr = CString::from_raw(world);
let output = env.new_string(world_ptr.to_str().unwrap()).expect("Couldn't create java string!");

output.into_inner()
}
}

三、Android集成


SO集成


rust_android_001.PNG


RustGreeting.java


public class RustGreeting {
static {
System.loadLibrary("android_demo");
}

private static native String greeting(final String pattern);

public static String sayHello(String to) {
return greeting(to);
}

}

MainActivity.java


public class MainActivity extends AppCompatActivity {

// Used to load the 'native-lib' library on application startup.
static {
//System.loadLibrary("native-lib");
}

private ActivityMainBinding binding;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

// Example of a call to a native method
TextView tv = binding.sampleText;
tv.setText(RustGreeting.sayHello("Rust!!"));
}

}

效果


Rust_Hello.PNG


作者:CodeOver
来源:juejin.cn/post/7170696817682694152
收起阅读 »

在 Flutter 多人视频中实现虚拟背景、美颜与空间音效

在之前的「基于声网 Flutter SDK 实现多人视频通话」里,我们通过 Flutter + 声网 SDK 完美实现了跨平台和多人视频通话的效果,那么本篇我们将在之前例子的基础上进阶介绍一些常用的特效功能。 本篇主要带你了解 SDK 里几个实用的 API ...
继续阅读 »

在之前的「基于声网 Flutter SDK 实现多人视频通话」里,我们通过 Flutter + 声网 SDK 完美实现了跨平台和多人视频通话的效果,那么本篇我们将在之前例子的基础上进阶介绍一些常用的特效功能。



本篇主要带你了解 SDK 里几个实用的 API 实现,相对简单



虚拟背景


虚拟背景是视频会议里最常见的特效之一,在声网 SDK 里可以通过 enableVirtualBackground 方法启动虚拟背景支持。


首先,因为我们是在 Flutter 里使用,所以我们可以在 Flutter 里放一张 assets/bg.jpg 图片作为背景,这里有两个需要注意的点:




  • assets/bg.jpg 图片需要在 pubspec.yaml 文件下的 assets 添加引用


      assets:
      - assets/bg.jpg



  • 需要在 pubspec.yaml 文件下添加 path_provider: ^2.0.8path: ^1.8.2 依赖,因为我们需要把图片保存在 App 本地路径下




如下代码所示,首先我们通过 Flutter 内的 rootBundle 读取到 bg.jpg ,然后将其转化为 bytes, 之后调用 getApplicationDocumentsDirectory 获取路径,保存在的应用的 /data" 目录下,然后就可以把图片路径配置给 enableVirtualBackground 方法的 source ,从而加载虚拟背景。


Future<void> _enableVirtualBackground() async {
 ByteData data = await rootBundle.load("assets/bg.jpg");
 List<int> bytes =
     data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
 Directory appDocDir = await getApplicationDocumentsDirectory();
 String p = path.join(appDocDir.path, 'bg.jpg');
 final file = File(p);
 if (!(await file.exists())) {
   await file.create();
   await file.writeAsBytes(bytes);
}

 await _engine.enableVirtualBackground(
     enabled: true,
     backgroundSource: VirtualBackgroundSource(
         backgroundSourceType: BackgroundSourceType.backgroundImg,
         source: p),
     segproperty:
         const SegmentationProperty(modelType: SegModelType.segModelAi));
 setState(() {});
}

如下图所示是都开启虚拟背景图片之后的运行效果,当然,这里还有两个需要注意的参数:



  • BackgroundSourceType :可以配置 backgroundColor(虚拟背景颜色)、backgroundImg(虚拟背景图片)、backgroundBlur (虚拟背景模糊) 这三种情况,基本可以覆盖视频会议里的所有场景

  • SegModelType :可以配置为 segModelAi (智能算法)或 segModelGreen(绿幕算法)两种不同场景下的抠图算法。




这里需要注意的是,在官方的提示里,建议只在搭载如下芯片的设备上使用该功能(应该是对于 GPU 有要求):



  • 骁龙 700 系列 750G 及以上

  • 骁龙 800 系列 835 及以上

  • 天玑 700 系列 720 及以上

  • 麒麟 800 系列 810 及以上

  • 麒麟 900 系列 980 及以上



另外需要注意的是,为了将自定义背景图的分辨率与 SDK 的视频采集分辨率适配,声网 SDK 会在保证自定义背景图不变形的前提下,对自定义背景图进行缩放和裁剪


美颜


美颜作为视频会议里另外一个最常用的功能,声网也提供了 setBeautyEffectOptions 方法支持一些基础美颜效果调整。


如下代码所示, setBeautyEffectOptions 方法里主要是通过 BeautyOptions 来调整画面的美颜风格,参数的具体作用如下表格所示。



这里的 .5 只是做了一个 Demo 效果,具体可以根据你的产品需求,配置出几种固定模版让用户选择。



_engine.setBeautyEffectOptions(
 enabled: true,
 options: const BeautyOptions(
   lighteningContrastLevel:
       LighteningContrastLevel.lighteningContrastHigh,
   lighteningLevel: .5,
   smoothnessLevel: .5,
   rednessLevel: .5,
   sharpnessLevel: .5,
),
);

属性作用
lighteningContrastLevel对比度,常与 lighteningLevel 搭配使用。取值越大,明暗对比程度越大
lighteningLevel美白程度,取值范围为 [0.0,1.0],其中 0.0 表示原始亮度,默认值为 0.0。取值越大,美白程度越大
smoothnessLevel磨皮程度,取值范围为 [0.0,1.0],其中 0.0 表示原始磨皮程度,默认值为 0.0。取值越大,磨皮程度越大
rednessLevel红润度,取值范围为 [0.0,1.0],其中 0.0 表示原始红润度,默认值为 0.0。取值越大,红润程度越大
sharpnessLevel锐化程度,取值范围为 [0.0,1.0],其中 0.0 表示原始锐度,默认值为 0.0。取值越大,锐化程度越大

运行后效果如下图所示,开了 0.5 参数后的美颜整体画面更加白皙,同时唇色也更加明显。


没开美颜开了美颜

色彩增强


接下来要介绍的一个 API 是色彩增强: setColorEnhanceOptions ,如果是美颜还无法满足你的需求,那么色彩增强 API 可以提供更多参数来调整你的需要的画面风格。


如下代码所示,色彩增强 API 很简单,主要是调整 ColorEnhanceOptionsstrengthLevelskinProtectLevel 参数,也就是调整色彩强度和肤色保护的效果


  _engine.setColorEnhanceOptions(
     enabled: true,
     options: const ColorEnhanceOptions(
         strengthLevel: 6.0, skinProtectLevel: 0.7));

如下图所示,因为摄像头采集到的视频画面可能存在色彩失真的情况,而色彩增强功能可以通过智能调节饱和度和对比度等视频特性,提升视频色彩丰富度和色彩还原度,最终使视频画面更生动。



开启增强之后画面更抢眼了。



没开增加开了美颜+增强

属性参数
strengthLevel色彩增强程度。取值范围为 [0.0,1.0]。0.0 表示不对视频进行色彩增强。取值越大,色彩增强的程度越大。默认值为 0.5。
skinProtectLevel肤色保护程度。取值范围为 [0.0,1.0]。0.0 表示不对肤色进行保护。取值越大,肤色保护的程度越大。默认值为 1.0。 当色彩增强程度较大时,人像肤色会明显失真,你需要设置肤色保护程度; 肤色保护程度较大时,色彩增强效果会略微降低。 因此,为获取最佳的色彩增强效果,建议动态调节 strengthLevel 和 skinProtectLevel 以实现最合适的效果。

空间音效


其实声音调教才是重头戏,声网既然叫声网,在音频处理上肯定不能落后,在声网 SDK 里就可以通过 enableSpatialAudio 打开空间音效的效果。


_engine.enableSpatialAudio(true);

什么是空间音效?简单说就是特殊的 3D 音效,它可以将音源虚拟成从三维空间特定位置发出,包括听者水平面的前后左右,以及垂直方向的上方或下方。



本质上空间音效就是通过一些声学相关算法计算,模拟实现类似空间 3D 效果的音效实现



同时你还可以通过 setRemoteUserSpatialAudioParams 来配置空间音效的相关参数,如下表格所示,可以看到声网提供了非常丰富的参数来让我们可以自主调整空间音效,例如这里面的 enable_blurenable_air_absorb 效果就很有意思,十分推荐大家去试试。


属性作用
speaker_azimuth远端用户或媒体播放器相对于本地用户的水平角。 取值范围为 [0,360],单位为度,例如 (默认)0 度,表示水平面的正前方;90 度,表示水平面的正左方;180 度,表示水平面的正后方;270 度,表示水平面的正右方;360 度,表示水平面的正前方;
speaker_elevation远端用户或媒体播放器相对于本地用户的俯仰角。 取值范围为 [-90,90],单位为度。(默认)0 度,表示水平面无旋转;-90 度,表示水平面向下旋转 90 度;90 度,表示水平面向上旋转 90 度
speaker_distance远端用户或媒体播放器相对于本地用户的距离,取值范围为 [1,50],单位为米,默认值为 1 米。
speaker_orientation远端用户或媒体播放器相对于本地用户的朝向。 取值范围为 [0,180],单位为度。默认)0 度,表示声源和听者朝向同一方向;180: 180 度,表示声源和听者面对面
enable_blur是否开启声音模糊处理
enable_air_absorb是否开启空气衰减,即模拟声音在空气中传播的音色衰减效果:在一定的传输距离下,高频声音衰减速度快、低频声音衰减速度慢。
speaker_attenuation远端用户或媒体播放器的声音衰减系数,取值范围为[0,1]。 0:广播模式,即音量和音色均不随距离衰减;(0,0.5):弱衰减模式,即音量和音色在传播过程中仅发生微弱衰减;0.5:(默认)模拟音量在真实环境下的衰减,效果等同于不设置 speaker_attenuation 参数;(0.5,1]:强衰减模式,即音量和音色在传播过程中发生迅速衰减
enable_doppler是否开启多普勒音效:当声源与接收声源者之间产生相对位移时,接收方听到的音调会发生变化


音频类的效果这里就无法展示了,强烈推荐大家自己动手去试试。



人声音效


另外一个推荐的 API 就是人声音效:setAudioEffectPreset, 调用该方法可以通过 SDK 预设的人声音效,在不会改变原声的性别特征的前提下,修改用户的人声效果,例如:


_engine.setAudioEffectPreset(AudioEffectPreset.roomAcousticsKtv);

声网 SDK 里预设了非常丰富的 AudioEffectPreset ,如下表格所示,从场景效果如 KTV、录音棚,到男女变声,再到恶搞的音效猪八戒等,可以说是相当惊艳。


参数作用
audioEffectOff原声
roomAcousticsKtvKTV
roomAcousticsVocalConcert演唱会
roomAcousticsStudio录音棚
roomAcousticsPhonograph留声机
roomAcousticsVirtualStereo虚拟立体声
roomAcousticsSpacial空旷
roomAcousticsEthereal空灵
roomAcousticsVirtualSurroundSound虚拟环绕声
roomAcoustics3dVoice3D 人声
voiceChangerEffectUncle大叔
voiceChangerEffectOldman老年男性
voiceChangerEffectBoy男孩
voiceChangerEffectSister少女
voiceChangerEffectGirl女孩
voiceChangerEffectPigking猪八戒
voiceChangerEffectHulk绿巨人
styleTransformationRnbR&B
styleTransformationPopular流行
pitchCorrection电音


PS:为获取更好的人声效果,需要在调用该方法前将 setAudioProfile 的 scenario 设为 audioScenarioGameStreaming(3):


_engine.setAudioProfile(
 profile: AudioProfileType.audioProfileDefault,
 scenario: AudioScenarioType.audioScenarioGameStreaming);


当然,这里需要注意的是,这个方法只推荐用在对人声的处理上,不建议用于处理含音乐的音频数据


最后,完整代码如下所示:


class VideoChatPage extends StatefulWidget {
 const VideoChatPage({Key? key}) : super(key: key);

 @override
 State<VideoChatPage> createState() => _VideoChatPageState();
}

class _VideoChatPageState extends State<VideoChatPage> {
 late final RtcEngine _engine;

 ///初始化状态
 late final Future<bool?> initStatus;

 ///当前 controller
 late VideoViewController currentController;

 ///是否加入聊天
 bool isJoined = false;

 /// 记录加入的用户id
 Map<int, VideoViewController> remoteControllers = {};

 @override
 void initState() {
   super.initState();
   initStatus = _requestPermissionIfNeed().then((value) async {
     await _initEngine();

     ///构建当前用户 currentController
     currentController = VideoViewController(
       rtcEngine: _engine,
       canvas: const VideoCanvas(uid: 0),
    );
     return true;
  }).whenComplete(() => setState(() {}));
}

 Future<void> _requestPermissionIfNeed() async {
   if (Platform.isMacOS) {
     return;
  }
   await [Permission.microphone, Permission.camera].request();
}

 Future<void> _initEngine() async {
   //创建 RtcEngine
   _engine = createAgoraRtcEngine();
   // 初始化 RtcEngine
   await _engine.initialize(const RtcEngineContext(
     appId: appId,
  ));

   _engine.registerEventHandler(RtcEngineEventHandler(
     // 遇到错误
     onError: (ErrorCodeType err, String msg) {
       if (kDebugMode) {
         print('[onError] err: $err, msg: $msg');
      }
    },
     onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
       // 加入频道成功
       setState(() {
         isJoined = true;
      });
    },
     onUserJoined: (RtcConnection connection, int rUid, int elapsed) {
       // 有用户加入
       setState(() {
         remoteControllers[rUid] = VideoViewController.remote(
           rtcEngine: _engine,
           canvas: VideoCanvas(uid: rUid),
           connection: const RtcConnection(channelId: cid),
        );
      });
    },
     onUserOffline:
        (RtcConnection connection, int rUid, UserOfflineReasonType reason) {
       // 有用户离线
       setState(() {
         remoteControllers.remove(rUid);
      });
    },
     onLeaveChannel: (RtcConnection connection, RtcStats stats) {
       // 离开频道
       setState(() {
         isJoined = false;
         remoteControllers.clear();
      });
    },
  ));

   // 打开视频模块支持
   await _engine.enableVideo();
   // 配置视频编码器,编码视频的尺寸(像素),帧率
   await _engine.setVideoEncoderConfiguration(
     const VideoEncoderConfiguration(
       dimensions: VideoDimensions(width: 640, height: 360),
       frameRate: 15,
    ),
  );

   await _engine.startPreview();
}

 @override
 void dispose() {
   _engine.leaveChannel();
   super.dispose();
}

 @override
 Widget build(BuildContext context) {
   return Scaffold(
       appBar: AppBar(),
       body: Stack(
         children: [
           FutureBuilder<bool?>(
               future: initStatus,
               builder: (context, snap) {
                 if (snap.data != true) {
                   return const Center(
                     child: Text(
                       "初始化ing",
                       style: TextStyle(fontSize: 30),
                    ),
                  );
                }
                 return AgoraVideoView(
                   controller: currentController,
                );
              }),
           Align(
             alignment: Alignment.topLeft,
             child: SingleChildScrollView(
               scrollDirection: Axis.horizontal,
               child: Row(
                 ///增加点击切换
                 children: List.of(remoteControllers.entries.map(
                  (e) => InkWell(
                     onTap: () {
                       setState(() {
                         remoteControllers[e.key] = currentController;
                         currentController = e.value;
                      });
                    },
                     child: SizedBox(
                       width: 120,
                       height: 120,
                       child: AgoraVideoView(
                         controller: e.value,
                      ),
                    ),
                  ),
                )),
              ),
            ),
          )
        ],
      ),
       floatingActionButton: FloatingActionButton(
         onPressed: () async {
           // 加入频道
           _engine.joinChannel(
             token: token,
             channelId: cid,
             uid: 0,
             options: const ChannelMediaOptions(
               channelProfile:
                   ChannelProfileType.channelProfileLiveBroadcasting,
               clientRoleType: ClientRoleType.clientRoleBroadcaster,
            ),
          );
        },
      ),
       persistentFooterButtons: [
         ElevatedButton.icon(
             onPressed: () {
               _enableVirtualBackground();
            },
             icon: const Icon(Icons.accessibility_rounded),
             label: const Text("虚拟背景")),
         ElevatedButton.icon(
             onPressed: () {
               _engine.setBeautyEffectOptions(
                 enabled: true,
                 options: const BeautyOptions(
                   lighteningContrastLevel:
                       LighteningContrastLevel.lighteningContrastHigh,
                   lighteningLevel: .5,
                   smoothnessLevel: .5,
                   rednessLevel: .5,
                   sharpnessLevel: .5,
                ),
              );
               //_engine.setRemoteUserSpatialAudioParams();
            },
             icon: const Icon(Icons.face),
             label: const Text("美颜")),
         ElevatedButton.icon(
             onPressed: () {
               _engine.setColorEnhanceOptions(
                   enabled: true,
                   options: const ColorEnhanceOptions(
                       strengthLevel: 6.0, skinProtectLevel: 0.7));
            },
             icon: const Icon(Icons.color_lens),
             label: const Text("增强色彩")),
         ElevatedButton.icon(
             onPressed: () {
               _engine.enableSpatialAudio(true);
            },
             icon: const Icon(Icons.surround_sound),
             label: const Text("空间音效")),
         ElevatedButton.icon(
             onPressed: () {                
               _engine.setAudioProfile(
                   profile: AudioProfileType.audioProfileDefault,
                   scenario: AudioScenarioType.audioScenarioGameStreaming);
               _engine
                  .setAudioEffectPreset(AudioEffectPreset.roomAcousticsKtv);
            },
             icon: const Icon(Icons.surround_sound),
             label: const Text("人声音效")),
      ]);
}

 Future<void> _enableVirtualBackground() async {
   ByteData data = await rootBundle.load("assets/bg.jpg");
   List<int> bytes =
       data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
   Directory appDocDir = await getApplicationDocumentsDirectory();
   String p = path.join(appDocDir.path, 'bg.jpg');
   final file = File(p);
   if (!(await file.exists())) {
     await file.create();
     await file.writeAsBytes(bytes);
  }

   await _engine.enableVirtualBackground(
       enabled: true,
       backgroundSource: VirtualBackgroundSource(
           backgroundSourceType: BackgroundSourceType.backgroundImg,
           source: p),
       segproperty:
           const SegmentationProperty(modelType: SegModelType.segModelAi));
   setState(() {});
}
}

最后


本篇的内容作为上一篇的补充,相对来说内容还是比较简单,不过可以看到不管是在画面处理还是在声音处理上,声网 SDK 都提供了非常便捷的 API 实现,特别在声音处理上,因为文章限制这里只展示了简单的 API 介绍,所以强烈建议大家自己尝试下这些音频 API ,真的非常有趣。


作者:无知小猿
来源:juejin.cn/post/7211388928242352184
收起阅读 »

聊一下AIGC

“UGC不存在了”——借鉴自《三体》 ChatGPT 的横空出世将一个全新的概念推上风口——AIGC( AI Generated Content)。 GC即创作内容(Generated Content),和传统的UGC、PGC,OGC不同的是,AIGC的创作...
继续阅读 »

“UGC不存在了”——借鉴自《三体》



ChatGPT 的横空出世将一个全新的概念推上风口——AIGC( AI Generated Content)。


GC即创作内容(Generated Content),和传统的UGC、PGC,OGC不同的是,AIGC的创作主体由人变成了人工智能。



xGC



  • PGC:Professionally Generated Content,专业生产内容

  • UGC:User Generated Content,用户生产内容

  • OGC:Occupationally Generated Content,品牌生产内容。



AI 可以 Generate 哪些 Content?


作为淘宝内容线的开发,我们每天都在和内容打交道,那么AI到底能生成什么内容?


围绕着不同形式的内容生产,AIGC大致分为以下几个领域:



文本生成


基于NLP的文本内容生成根据使用场景可分为非交互式文本生成交互式文本生成


非交互式文本生成包括摘要/标题生成、文本风格迁移、文章生成、图像生成文本等。


交互式文本生成主要包括聊天机器人、文本交互游戏等。


【代表性产品或模型】:JasperAI、copy.AI、ChatGPTBard、AI dungeon等。



图像生成


图像生成根据使用场可分为图像编辑修改图像自主生成


图像编辑修改可应用于图像超分、图像修复、人脸替换、图像去水印、图像背景去除等。


图像自主生成包括端到端的生成,如真实图像生成卡通图像、参照图像生成绘画图像、真实图像生成素描图像、文本生成图像等。


【代表性产品或模型】:EditGAN,Deepfake,DALL-E、MidJourneyStable Diffusion文心一格等。



音频生成


音频生成技术较为成熟,在C端产品中也较为常见,如语音克隆,将人声1替换为人声2。还可应用于文本生成特定场景语音,如数字人播报、语音客服等。此外,可基于文本描述、图片内容理解生成场景化音频、乐曲等。


【代表性产品或模型】:DeepMusic、WaveNet、Deep Voice、MusicAutoBot等。



视频生成


视频生成与图像生成在原理上相似,主要分为视频编辑视频自主生成


视频编辑可应用于视频超分(视频画质增强)、视频修复(老电影上色、画质修复)、视频画面剪辑(识别画面内容,自动场景剪辑)


视频自主生成可应用于图像生成视频(给定参照图像,生成一段运动视频)、文本生成视频(给定一段描述性文字,生成内容相符视频)


【代表性产品或模型】:Deepfake,videoGPT,Gliacloud、Make-A-Video、Imagen video等。



多模态生成


以上四种模态可以进行组合搭配,进行模态间转换生成。如文本生成图像(AI绘画、根据prompt提示语生成特定风格图像)、文本生成音频(AI作曲、根据prompt提示语生成特定场景音频)、文本生成视频(AI视频制作、根据一段描述性文本生成语义内容相符视频片段)、图像生成文本(根据图像生成标题、根据图像生成故事)、图像生成视频。


【代表性产品或模型】:DALL-E、MidJourney、Stable Diffusion等。


本文接下来将会着重讲述文本类AIGC和图像类AIGC。


文本类AIGC


RNN → Transformer → GPT(ChatGPT)


最近势头正猛的ChatGPT就是文本类AIGC的代表。


ChatGPT(Chat Generative Pre-trained Transformer),即聊天生成型预训练变换模型,Transformer指的是一种非常重要的算法模型,稍后将会介绍。


其实现在的用户对于聊天机器人已经很熟悉了,比如天猫精灵、小爱同学或是Siri等语音助手。那为什么ChatGPT一出现,这些语音助手就显得相形见绌呢?


本质上是NLP模型之间的差异。


在自然语义理解领域(NLP)中,RNN和Transformer是最常见的两类模型。


循环神经网络(recurrent neural network)


RNN,即循环神经网络(recurrent neural network)源自于1982年由Saratha Sathasivam 提出的霍普菲尔德网络。下图所示是一个RNN网络的简易展示图,左侧是一个简单的循环神经网络,它由输入层、隐藏层和输出层组成。



RNN 的主要特点在于 w 带蓝色箭头的部分。输入层为 x,隐藏层为 s,输出层为 o。U 是输入层到隐藏层的权重,V 是隐藏层到输出层的权重。隐藏层的值 s 不仅取决于当前时刻的输入 x,还取决于上一时刻的输入。权重矩阵 w 就是隐藏层上一次的值作为这一次的输入的权重。由此可见,这种网络的特点是,每一个时刻的输入依赖于上一个时刻的输出,难以并行化计算。



从人类视角理解RNN 人类可以根据语境或者上下文,推断语义信息。就比如,一个人说了:我喜欢旅游,其中最喜欢的地方是三亚,以后有机会一定要去___,很显然这里应该填”三亚”。 但是机器要做到这一步就比较困难。RNN的本质是像人一样拥有记忆的能力,因此,它的输出就依赖于当前的输入和记忆。



Transformer


而Transformer模型诞生于2017年,起源自《Attention Is All You Need》。这是一种基于Attention机制来加速深度学习算法的模型,可以进行并行化计算,而且每个单词在处理过程中注意到了其他单词的影响,效果非常好。




Attention机制:又称为注意力机制,顾名思义,是一种能让模型对重要信息重点关注并充分学习吸收的技术。通俗的讲就是把注意力集中放在重要的点上,而忽略其他不重要的因素。 其中重要程度的判断取决于应用场景,根据应用场景的不同,Attention分为空间注意力时间注意力,前者用于图像处理,后者用于自然语言处理。




Transformer是完全基于自注意力机制的一个深度学习模型,有关该模型的介绍,详情可参考下面这篇文章👇


人工智能 LLM 革命前夜:一文读懂横扫自然语言处理的 Transformer 模型


由于Transformer的存在加速了深度学习的效果,基于海量数据的进行样本训练便有了可能。至此,LLM正式从幕后走向台前。


LLM,Large Language Model 即大型语言模型。这个大不仅仅指数据集的“大”,同样也是指算法模型的“大”。一般来说,在训练数据足够充足的情况下,往往是模型越大效果越好。在某种程度上说,甚至只要样本足够,哪怕模型“稍微简单”一些,也是可以取得不错的结果的。


笔者在2019年时曾翻译过一篇文章👇


机器学习竞赛实际上是一场数据竞赛


这篇文章的主要观点便是“AI竞争本质上就是数据之争”,所有希望创建有影响力、有价值的AI应用都应该认识到以下三点:



  1. 差异化数据是这场AI游戏成功的关键

  2. 有意义的数据比全面的数据好

  3. 起点应该是自己所擅长的东西


以ChatGPT为例,其本质是基于GPT3的一种变体,而GPT又是基于Transformer模型的一种演化。从模型参数上来说,GPT3共使用了1750亿个参数训练而成,而ChatGPT只使用了15亿个参数,但其数据集是却是整个互联网和几百万本书大概3千亿文字。哪怕是这样,却也是对一众使用RNN的NLP程序造成了降维打击。


GPT



这篇文章写到一半的时候GPT-4发布了,现在作为小插曲来扩展一下



笔者在和朋友的日常交流中发现大家总是将ChatGPT和GPT混为一谈,其实这是两个不同的东西。让我们来问一下New Bing这两者的区别。



很显然,从Bing给我的回答看来,为了让对话更加生动和有趣,ChatGPT是一个专为聊天设计的专业模型,而GPT则是一个通用语言模型。GPT4就是这个模型发展到第四代的模样,相较于GPT3,GPT4可以做的事情变得更多了。



  • GPT-4 是一个更大的模型,网传拥有约 1000 万亿个参数,这意味着它能够处理更多的数据,学习更多的知识和技能。

  • GPT-4 能够接受多模态的输入,例如文本、图像、音频和视频,并生成相应的输出。这使得它能够处理更复杂和丰富的任务,例如图像描述、语音识别和视频生成。


类ChatGPT


在国内一直都有一句调侃的话,叫做“国外一开源,国内就自主研发”。那既然算法模型是公开的,代码也已经开源了,那在国内,那些类ChatGPT的模型是不是应该如“雨后春笋”般涌现了呢?


事实上并没有,本质上还是因为LLM的扩展和维护是相当困难的。主要来源于以下几点:



  1. 漫长的训练时间

  2. 高昂的费用开支

  3. 海量的训练数据

  4. 稀缺的高端人才



时代的眼泪



  • 2017 - Attention is all you need

  • 2023 - Money is all you need



以复旦大学开源的类ChatGPT应用MOSS为例,虽然不知道具体的模型参数数量,但其负责人表示相较于ChatGPT少了一个数量级,再加上简中互联网作为其训练样本,训练质量可想而知。


点此体验👉moss.fastnlp.top/




关于训练的样本数据,这里举一个小例子🌰。 同样是搜索代码段,ChatGPT给你推StackOverflow的答案,MOSS给你推csdn的答案,高下立判



本来还想补充一下百度的文心一言的,结果他们发布了一个ChatPPT,网上一堆段子,这里就不吐槽了。


图像类AIGC


说完了文本类AIGC,我们再来看看最近另一个比较火的领域——图像类AIGC。


俗话说,饱暖思淫欲。作为“第一生产力”的“性”,很多技术发展都离不开他。扎克伯克创建Facebook的起因就是为了更好的认识小姐姐。而图像类AIGC出圈的一个很大原因就在于,他生成的美女小姐姐越来越真实了。


作为一个业余摄影师,第一眼也没能正确分辨出下面这两张图谁是真人,谁是AI画出来的人。




那么问题来了:这些由AI生成出来的美女是如何生成的呢?


GAN → DiffusioModel → Stable Diffusion


生成对抗网络( Generative Adversarial Networks,GAN


2014年 Ian GoodFellow提出了生成对抗网络,成为早期最著名的生成模型。GAN使用零和博弈策略学习,在图像生成中应用广泛。以GAN为基础产生了多种变体,如DCGAN,StytleGAN,CycleGAN等。



零和博弈是指参与博弈的各方,在严格竞争下,一方的收益必然意味着另一方的损失,博弈各方的收益和损失相加总和永远为“零” ,双方不存在合作的可能。



一个简单易懂的例子


有两个人,都快要渴死了,现在他们面前只有一杯水,抢到水的那人得到的收益是1,没抢到水的那个人会死掉,收益为-1,总收益为0。这就是零和博弈。


GAN包含两个部分:



  • 生成器: 学习生成合理的数据。对于图像生成来说是给定一个向量,生成一张图片。其生成的数据作为判别器的负样本。

  • 判别器:判别输入是生成数据还是真实数据。网络输出越接近于0,生成数据可能性越大;反之,真实数据可能性越大。



如上图,我们希望通过GAN生成一些手写体来以假乱真。我们定义生成器与判别器:



  • 生成器:图中蓝色部分网络结构,其输入为一组向量,可以表征数字编号、字体、粗细、潦草程度等。在这里使用特定分布随机生成。

  • 判别器:在训练阶段,利用真实数据与生成数据训练二分类模型,输出为0-1之间概率,越接近1,输入为真实数据可能性越大。


生成器与判别器相互对立。在不断迭代训练中,双方能力不断加强,最终的理想结果是生成器生成的数据,判别器无法判别是真是假。(和周伯通的左右互搏很像)


以生成对抗网络为基础产生的应用:图像超分、人脸替换、卡通头像生成等。


扩散模型( Diffusion Model,里程碑式模型


扩散是受到非平衡热力学的启发,定义一个扩散步骤的马尔科夫链,并逐渐向数据中添加噪声,然后学习逆扩散过程,从噪声中构建出所需的样本。扩散模型的最初设计是用于去除图像中的噪声。随着降噪系统的训练时间越来越长且越来越好,可以从纯噪声作为唯一输入,生成逼真的图片。



马尔科夫链指的是一种随机过程,它的特点是当前状态只依赖于前一个状态,而与其他历史状态无关。



一个马尔科夫链的例子是股市模型,它有三种状态:牛市、熊市和横盘。 每种状态之间有一定的转移概率,例如从牛市到熊市的概率是0.4,从熊市到牛市的概率是0.2,等等。这样就可以用一个转移矩阵来描述这个马尔科夫链。


一个标准的扩散模型分为两个过程:前向过程与反向过程。在前向扩散阶段,图像被逐渐引入的噪声污染,直到图像成为完全随机噪声。在反向过程中,利用一系列马尔可夫链在每个时间步逐步去除预测噪声,从而从高斯噪声中恢复数据。


前向扩散过程,向原图中逐步加入噪声,直到图像成为完全随机噪声。



反向降噪过程,在每个时间步逐步去除噪声,从而从高斯噪声中恢复源数据。



扩散模型的工作原理是通过添加噪声来破坏训练数据,然后通过逆转这个噪声过程来学习恢复数据。换句话说,扩散模型可以从噪声中生成连贯的图像。


扩散模型通过向图像添加噪声进行训练,然后模型学习如何去除噪声。然后,该模型将此去噪过程应用于随机种子以生成逼真的图像。


下图为向原始图像中添加噪声,使原始图像成为随机噪声。



下图为从噪声中恢复的原始图像的变种图像。



Stable Diffusion(Stability AI 文本生成图像,代码与模型开源


2022年8月,Stability AI发布了Stable Diffusion ,这是一种开源Diffusion模型,代码与模型权重均向公众开放。


通过prompt提示语“郊区街区一栋房子的照片,灯光明亮的超现实主义艺术,高度细致8K”,生成图像如下,整体风格与内容锲合度高,AI作画质量较高。


在线体验👉huggingface.co/spaces/stab…



仰望星空:AIGC与元宇宙




结论先行,我认为Web3.0就是元宇宙,AIGC为元宇宙提供养料。



第一代互联网(Web1.0)是PC(个人计算机)互联网,从1994年发展至今。Web1.0让人类第一次掌握高效的传输信息的手段,随着各大网页应用的普及,互联网用户被迅速的连接起来,从而提升了全球信息的传输效率,各大门户网站从此处开始大放异彩。


第二代互联网(Web2.0)是移动互联网,从2008年左右拉开大幕,至今仍精彩纷呈。正是由于移动设备具备“永远在线”和“随时随地”的特点,“上网”二字逐渐从大众的视野消失,因为每个人时时刻刻都生活在网络里。


第三代互联网(Web3.0)是互联网的下一代技术发展方向,主要特征是去中心化、安全、开放和自主。元宇宙是一种虚拟的、持续的、共享的和交互的数字世界,可以让人们以不同的身份和形式参与其中。Web 3.0为元宇宙提供了技术基础和可能性,而元宇宙则是Web3.0在应用场景和生活方式上的体现。


百度百科对于元宇宙的定义是👇



元宇宙(Metaverse) 是人类运用数字技术构建的,由现实世界映射或超越现实世界,可与现实世界交互的虚拟世界,具备新型社会体系的数字生活空间。



本质上,元宇宙是一种新型社会形态,在元宇宙中,更多工作和生活将被数字化。


更多的数字化,意味着需要更丰富的虚拟地图、虚拟场景、虚拟对象和虚拟角色,这其中涉及到大量数字内容的生产和制作。以往,数字内容制作开发周期较长,通常以年计,在生产方式上,或来源于现实,通过扫描或重建模型实现材质、光影、动作捕捉等,或通过创作工具辅助艺术家实现。而这些刚好是AIGC所擅长的。AIGC广泛的适用性可以为元宇宙提供全新内容生成解决方案。



🌰 脑洞一下 开放世界游戏一直被大家视作元宇宙“数字世界”的雏形,试想一下,如果未来的3A大作,NPC的脸部模型、肢体动作是Diffusion Model生成的,聊天是ChatGPT和你对话,语音是Deep Voice创作的,你还会觉得他就只是一个普普通通的NPC吗?抑或是,此时看文章的你,才是地球Online里的一个NPC呢?



脚踏实地:AIGC的应用场景


元宇宙目前还只是大家的一个美好幻想,Web3.0究竟走向何方还需要大家共同探索,作为时代的先锋,既要学会抬头仰望星空,也不能忘记低头看路。


如今,AIGC的浪潮已起,作为非算法行业的从业者,底层的算法实现并非我们关心的重点,如何发挥AI的作用,创造出实际的价值才是我们应该探讨的方向。除了聊天机器人、画画这种不痛不痒的功能外,AIGC可能会颠覆的应用场景会有哪些呢?


人工智能助理


AIGC+搜索已经成为我现在获取信息的第一途径了。目前,New Bing作为我的贴心小助手,不管是什么想要了解的知识点,他都可以快速的给我解答,省去了我在海量信息中筛选的过程。




辅助工作流



摘自Microsoft 365 Copilot官网


人类天生就有梦想、创造、创新的本能。我们每个人都渴望做一些有意义的工作——写一部伟大的小说,做一个发现,建立一个强大的社区,照顾生病的人。我们都有与工作核心相连的冲动。但是今天,我们花了太多时间在那些消耗我们时间、创造力和精力的琐碎任务上。为了重新连接到工作的灵魂,我们不仅需要一种更好地做同样事情的方法。我们需要一种全新的工作方式。 —— 翻译自 ChatGPT


GPT4发布的第二天,Microsoft 365 Copilot变横空出世,宣传片相信大家都已经看到了,从此以后我们的工作方式将永远改变,开启新一轮的生产力大爆发。


除此之外,前段时间笔记软件Notion也上线了自己的AI助手,可以帮助用户更轻松、更快捷地完成日常任务。主要包括自动文本生成、内容推荐、智能搜索、情感分析等。



可以预见,AIGC的出现将会极大的改变现有的工作模式,未来,越来越多的效率软件、办公软件将会推出其自己的AI解决方案。



🎺 插播一条行业动态 36氪独家获悉,钉钉已完成对协同办公厂商「我来wolai」的全资收购。3月5日,我来wolai(上海我云网络科技有限公司)数位核心团队成员已经退出公司股东名单。公司法人已变为钉钉总裁叶军,公司则由阿里100%控股。36氪就上述消息向钉钉求证,钉钉官方表示:我来wolai团队已加入钉钉,将负责智能化协作文档的研发,和个人版文档的产品设计。



文本生成器(对话、文案、代码……)



笔者最近一直沉迷“以xxx的口吻调教ChatGPT”,上图便是在绩效季到来之际用chatGPT给老板们整的活,别的不说,效果还是蛮不错的。自行跳转👉 《老板评语生成器》


ChatGPT最强大的便是其背后的庞大数据,基于此,你甚至可以让那些不存在的人物、已故的人物出现。笔者最近刚看完电视剧《三体》,如果真的有一款“三体游戏”,里面的墨子、秦始皇等人物会不会就是ChatGPT生成的呢?


如果你也想调教出一个自己的对话机器人,可以试试这个网站👉open-gpt.app/


关于AIGC的落地,最后推荐两个和我们饭碗有关的AIGC产品


Codeium



Github Copilot



AIGC在伦理问题上的攻与守


攻——利用AIGC生成有害内容


AIGC技术强大的创作能力也引发对技术作恶的担忧。当要求类ChatGPT应用制造有害信息时,它确实可以做到,输出主要以文本和图片为主。



  1. 网络诈骗话术教学者

  2. 人机交互意味着无限可能,ChatGPT 可能会遵守某种虚假设定下的邪恶指令。

  3. 黑产团伙可精细化训练AIGC技术生成各类话术,用于网络诈骗、评论刷量等,不仅能够主动发帖,还会对其他用户的帖子做出响应,并展开长期的运营。

  4. 错误信息超级传播者

  5. ChatGPT的不可靠之处还在于,可能会加剧虚假消息和错误消息的传播。ChatGPT不具有识别甄别能力,给出的答案很可能拼凑而来,看似合理,却暗藏事实性错误。如果用户没有这方面的鉴别能力,就可能产生比较大的风险,特别是在一些政治文化、医疗健康、交通安全方面的话题,错误的回答可能引起严重后果。

  6. 色情暴力素材生成机

  7. 用户利用AIGC生成虚假名人照片等违禁图片,甚至会制作出暴力和性有关的画作,LAION-5B数据库包含色情、种族、恶意等内容,目前海外已经出现基于Stable Diffusion模型的色情图片生成网站。




  1. AIGC对个人肖像权等权利的侵犯


finance.sina.com.cn



  1. AIGC对原创作品的版权侵犯


网易LOFTER风波警醒了谁?_风闻


守——如何应对AIGC的“暗黑”一面?


在可预见的未来,AIGC将会以井喷的态势席卷各个行业,在享受AI赋能我们生产力的同时,也应该警惕AIGC带来的危害。


《Nature》杂志在《ChatGPT: five priorities for research》一文中提到,对AIGC的进一步研究需要关注的五个问题:



  1. 务必要对模型进行持续校对;

  2. 制定问责制与规则;

  3. 投资于真正开放的大语言模型;

  4. 扩大辩论范围;

  5. 拥抱人工智能的好处。


ChatGPT会设置多层道德底线,兼顾科学和伦理,约束“经济人”行为,以保持“人尽其才、物尽其用”特性。未来,亦有可能出现“以子之矛,攻子之盾”的场景,用AI去识别/对抗AI。



也许未来会出现很多这样的文章👇
《一种基于xxx的AI文本/图像检测技术》



最后


对行业的思考


Meta AI 负责人、图灵奖得主杨立昆Yann LeCun近日表示:就底层技术而言,ChatGPT 并没有特别的创新。与其说 ChatGPT 是一个科学突破,不如说它是一个像样的工程实例



AI人工智能的底层三大件,数据,算力,算法的发展给ChatGPT的出现提供了爆发的基础,Open AI 将它组合的很好,不但是算法而且还包括了算力,数据。


数据方面,互联网的几十年高速发展,积累了海量人类的文本。


算力方面,计算机技术的发展,从芯片的制程到类似Chiplet,等助力AI芯片蓬勃发展。


算法,从神经元算法起步,到Transformer 等各类算法的炉火纯青的应用。


所以AI底层三大件的发展,一定会催生出更多类似于ChatGPT的通用人工智能应用,但我们更应该关注底层三大件的发展,未来数据类似于宝藏和矿产;芯片算力,成了决胜AI的大器,没有芯片那么数据矿产无法挖掘;算法,犹如矿藏提纯配方。


对于行业而言,未来做好AI三大件的工作,才能在AI时代赢得红利。


对个人的启发


说到最后,很多人变得焦虑,担心自己会被取代,那么从个人的角度出发,我们对于AIGC的态度究竟应该是什么样的呢?


马克思说过,人和其他动物的最大区别是“使用工具制造工具”,而GPT是进入信息时代以来,人类最伟大的工具之一。使用 GPT 能够直接调用人类千年以来积累的知识与技能,对于我们普通人而言,自己身上没有神迹,也非天才,与其尝试与机器、GPT和AI竞争,不如站在AI这个巨人的肩膀上,利用这些工具让自己变得更强。


未来,能否使用AI将会成为衡量一个人能力的标准之一。就像当年智能设备普及时,那些不会使用智能手机的人注定会被淘汰一样。


作者:插猹的闰土
来源:juejin.cn/post/7212924329428615226
收起阅读 »

面试官问我按钮级别权限怎么控制,我说v-if,面试官说再见

web
最近的面试中有一个面试官问我按钮级别的权限怎么控制,我说直接v-if啊,他说不够好,我说我们项目中按钮级别的权限控制情况不多,所以v-if就够了,他说不够通用,最后他对我的评价是做过很多东西,但是都不够深入,好吧,那今天我们就来深入深入。 因为我自己没有相关实...
继续阅读 »

最近的面试中有一个面试官问我按钮级别的权限怎么控制,我说直接v-if啊,他说不够好,我说我们项目中按钮级别的权限控制情况不多,所以v-if就够了,他说不够通用,最后他对我的评价是做过很多东西,但是都不够深入,好吧,那今天我们就来深入深入。


因为我自己没有相关实践,所以接下来就从这个有16.2k星星的后台管理系统项目Vue vben admin中看看它是如何做的。


获取权限码


要做权限控制,肯定需要一个code,无论是权限码还是角色码都可以,一般后端会一次性返回,然后全局存储起来就可以了,Vue vben admin是在登录成功以后获取并保存到全局的store中:


import { defineStore } from 'pinia';
export const usePermissionStore = defineStore({
state: () => ({
// 权限代码列表
permCodeList: [],
}),
getters: {
// 获取
getPermCodeList(){
return this.permCodeList;
},
},
actions: {
// 存储
setPermCodeList(codeList) {
this.permCodeList = codeList;
},

// 请求权限码
async changePermissionCode() {
const codeList = await getPermCode();
this.setPermCodeList(codeList);
}
}
})

接下来它提供了三种按钮级别的权限控制方式,一一来看。


函数方式


使用示例如下:


<template>
<a-button v-if="hasPermission(['20000', '2000010'])" color="error" class="mx-4">
拥有[20000,2000010]code可见
</a-button>
</template>

<script lang="ts">
import { usePermission } from '/@/hooks/web/usePermission';

export default defineComponent({
setup() {
const { hasPermission } = usePermission();
return { hasPermission };
},
});
</script>

本质上就是通过v-if,只不过是通过一个统一的权限判断方法hasPermission


export function usePermission() {
function hasPermission(value, def = true) {
// 默认视为有权限
if (!value) {
return def;
}

const allCodeList = permissionStore.getPermCodeList;
if (!isArray(value)) {
return allCodeList.includes(value);
}
// intersection是lodash提供的一个方法,用于返回一个所有给定数组都存在的元素组成的数组
return (intersection(value, allCodeList)).length > 0;

return true;
}
}

很简单,从全局store中获取当前用户的权限码列表,然后判断其中是否存在当前按钮需要的权限码,如果有多个权限码,只要满足其中一个就可以。


组件方式


除了通过函数方式使用,也可以使用组件方式,Vue vben admin提供了一个Authority组件,使用示例如下:


<template>
<div>
<Authority :value="RoleEnum.ADMIN">
<a-button type="primary" block> 只有admin角色可见 </a-button>
</Authority>
</div>
</template>
<script>
import { Authority } from '/@/components/Authority';
import { defineComponent } from 'vue';
export default defineComponent({
components: { Authority },
});
</script>

使用Authority包裹需要权限控制的按钮即可,该按钮需要的权限码通过value属性传入,接下来看看Authority组件的实现。


<script lang="ts">
import { defineComponent } from 'vue';
import { usePermission } from '/@/hooks/web/usePermission';
import { getSlot } from '/@/utils/helper/tsxHelper';

export default defineComponent({
name: 'Authority',
props: {
value: {
type: [Number, Array, String],
default: '',
},
},
setup(props, { slots }) {
const { hasPermission } = usePermission();

function renderAuth() {
const { value } = props;
if (!value) {
return getSlot(slots);
}
return hasPermission(value) ? getSlot(slots) : null;
}

return () => {
return renderAuth();
};
},
});
</script>

同样还是使用hasPermission方法,如果当前用户存在按钮需要的权限码时就原封不动渲染Authority包裹的内容,否则就啥也不渲染。


指令方式


最后一种就是指令方式,使用示例如下:


<a-button v-auth="'1000'" type="primary" class="mx-4"> 拥有code ['1000']权限可见 </a-button>

实现如下:


import { usePermission } from '/@/hooks/web/usePermission';

function isAuth(el, binding) {
const { hasPermission } = usePermission();

const value = binding.value;
if (!value) return;
if (!hasPermission(value)) {
el.parentNode?.removeChild(el);
}
}

const mounted = (el, binding) => {
isAuth(el, binding);
};

const authDirective = {
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
mounted,
};

// 注册全局指令
export function setupPermissionDirective(app) {
app.directive('auth', authDirective);
}

只定义了一个mounted钩子,也就是在绑定元素挂载后调用,依旧是使用hasPermission方法,判断当前用户是否存在通过指令插入的按钮需要的权限码,如果不存在,直接移除绑定的元素。


很明显,Vue vben admin的实现有两个问题,一是不能动态更改按钮的权限,二是动态更改当前用户的权限也不会生效。


解决第一个问题很简单,因为上述只有删除元素的逻辑,没有加回来的逻辑,那么增加一个updated钩子:


app.directive("auth", {
mounted: (el, binding) => {
const value = binding.value
if (!value) return
if (!hasPermission(value)) {
// 挂载的时候没有权限把元素删除
removeEl(el)
}
},
updated(el, binding) {
// 按钮权限码没有变化,不做处理
if (binding.value === binding.oldValue) return
// 判断用户本次和上次权限状态是否一样,一样也不用做处理
let oldHasPermission = hasPermission(binding.oldValue)
let newHasPermission = hasPermission(binding.value)
if (oldHasPermission === newHasPermission) return
// 如果变成有权限,那么把元素添加回来
if (newHasPermission) {
addEl(el)
} else {
// 如果变成没有权限,则把元素删除
removeEl(el)
}
},
})

const hasPermission = (value) => {
return [1, 2, 3].includes(value)
}

const removeEl = (el) => {
// 在绑定元素上存储父级元素
el._parentNode = el.parentNode
// 在绑定元素上存储一个注释节点
el._placeholderNode = document.createComment("auth")
// 使用注释节点来占位
el.parentNode?.replaceChild(el._placeholderNode, el)
}

const addEl = (el) => {
// 替换掉给自己占位的注释节点
el._parentNode?.replaceChild(el, el._placeholderNode)
}

主要就是要把父节点保存起来,不然想再添加回去的时候获取不到原来的父节点,另外删除的时候创建一个注释节点给自己占位,这样下次想要回去能知道自己原来在哪。


第二个问题的原因是修改了用户权限数据,但是不会触发按钮的重新渲染,那么我们就需要想办法能让它触发,这个可以使用watchEffect方法,我们可以在updated钩子里通过这个方法将用户权限数据和按钮的更新方法关联起来,这样当用户权限数据改变了,可以自动触发按钮的重新渲染:


import { createApp, reactive, watchEffect } from "vue"
const codeList = reactive([1, 2, 3])

const hasPermission = (value) => {
return codeList.includes(value)
}

app.directive("auth", {
updated(el, binding) {
let update = () => {
let valueNotChange = binding.value === binding.oldValue
let oldHasPermission = hasPermission(binding.oldValue)
let newHasPermission = hasPermission(binding.value)
let permissionNotChange = oldHasPermission === newHasPermission
if (valueNotChange && permissionNotChange) return
if (newHasPermission) {
addEl(el)
} else {
removeEl(el)
}
};
if (el._watchEffect) {
update()
} else {
el._watchEffect = watchEffect(() => {
update()
})
}
},
})

updated钩子里更新的逻辑提取成一个update方法,然后第一次更新在watchEffect中执行,这样用户权限的响应式数据就可以和update方法关联起来,后续用户权限数据改变了,可以自动触发update方法的重新运行。


好了,深入完了,看着似乎也挺简单的,我不确定这些是不是面试官想要的,或者还有其他更高级更优雅的实现呢,知道的朋友能否指点

作者:街角小林
来源:juejin.cn/post/7209648356530896953
一二,在下感激不尽。

收起阅读 »

canvas绘制行星环绕

web
前言 最近学校学了一些JavaScript课程,其中涉及到了部分有关于canvas的知识点,万万没想到老师只是用了一节课提了一下有关canvas的一些有关使用就布置下来了一个作业--采用canvas绘制一个简易太阳系,咱作为学生还能说啥,只能冲啦。 实现原理 ...
继续阅读 »
太阳与月亮.gif

前言


最近学校学了一些JavaScript课程,其中涉及到了部分有关于canvas的知识点,万万没想到老师只是用了一节课提了一下有关canvas的一些有关使用就布置下来了一个作业--采用canvas绘制一个简易太阳系,咱作为学生还能说啥,只能冲啦。


实现原理


只是单纯的canvas方法的使用再加上一点点js的使用就可以实现这个简单的实例啦。


实现代码


html部分


<!-- 画布元素 -->
<canvas id="canvas"></canvas>

初始化画布

js获取画布元素,初始化画布背景色为黑色,设置画布真实绘制宽高为1200,浏览器呈现宽高为600px,getContext('2d')获取画布的2D上下文。


let canvas = document.getElementById('canvas')
canvas.style.background = 'black'
// 浏览器渲染出画布宽高
canvas.style.width = 600 + 'px'
canvas.style.height = 600 + 'px'
// 绘制画布真实宽高
canvas.width = 1200
canvas.height = 1200
let context = canvas.getContext('2d');

绘制太阳

绘制一个圆心为(600,600)半径为100的圆,在绘制前有几点要了解,因为canvas只支持两种形式的图形绘制:矩形和路径(由一系列点连成的线段),所以我们要使用到路径绘制函数。其中beginPath()新建一条路径,在该路径闭合前,图像绘制将在该路径中进行,其中fillSyle设置的是图像填充色,通常以closePath()闭合该路径,但由于fill()会自动闭合路径所以closePath()可以省去。详情可以参考MDN|Canvas


context.beginPath() // 开始路径绘制
context.arc(600, 600, 100, 0, Math.PI*2, true)
context.fillStyle = 'red' // 图形填充色
context.fill() // 进行填充

绘制地球轨道

与上面太阳的绘制相差不大,将填充换为了描边。strokeStyle定义图形轮廓颜色,stroke()开始绘制轮廓,最后采用closePath()闭合路径。


context.beginPath()
context.arc(600, 600, 300, 0, Math.PI*2, true) // 圆心(300,300) 半径为150的圆环
context.strokeStyle = 'rgb(255,255,255,0.3)'
context.stroke()
context.closePath()

绘制地球

注意: 这里地球的圆心坐标为(0,0)这是因为我们调用了translate()这一函数,通过这一函数我们将起始点偏移到指定位置,下文将以此坐标为新的起始点。此外需要用save()保存当前画布状态,不然后续循环会出问题。再调用rotate()方法实现旋转,其中rotate()是使得其下文绘制的图形实现旋转,旋转中心为当前起始点坐标。


context.save(); // 保存当前状态

var angle=time*Math.PI/180/8;
context.translate(600,600); // 起始点偏移量,太阳中心
context.rotate(angle);

context.translate(300,0); // 地球,月球轨道中心
context.beginPath()
context.arc(0,0,40,0,2*Math.PI,false);
context.fillStyle = 'blue'
context.strokeStyle = 'blue'
context.fill()

月球轨道及月球


// 月球轨道
context.beginPath()
context.arc(0, 0, 100, 0, Math.PI*2, true)
context.strokeStyle = 'rgb(255,255,255,0.3)'
context.stroke()
context.closePath()

context.rotate(-8*angle);

// 月球
context.beginPath()
context.arc(100,0,20,0,2*Math.PI,false);
context.fillStyle = '#fff'
context.fill()

js完整部分

定义一个绘制函数draw(),通过setInterval()函数循环调用,其中要注意在使用save()函数后要调用restore()函数恢复状态,为下次的绘制做准备。


let canvas = document.getElementById('canvas')
canvas.style.background = 'black'
// 浏览器渲染出画布宽高
canvas.style.width = 600 + 'px'
canvas.style.height = 600 + 'px'
// 绘制画布真实宽高
canvas.width = 1200
canvas.height = 1200
let context = canvas.getContext('2d');
// context.scale(2, 2)

let time = 0
function draw() {
context.clearRect(0,0,canvas.width,canvas.height); // 清除所选区域
// 绘制太阳
context.beginPath() // 开始路径绘制
context.arc(600, 600, 100, 0, Math.PI*2, true)
context.fillStyle = 'red' // 图形填充色
context.fill() // 进行填充
// 绘制地球轨道
context.beginPath()
context.arc(600, 600, 300, 0, Math.PI*2, true) // 圆心(300,300) 半径为150的圆环
context.strokeStyle = 'rgb(255,255,255,0.3)'
context.stroke()
context.closePath()

context.save(); // 保存当前状态

var angle=time*Math.PI/180/8;
context.translate(600,600); // 起始点偏移量,太阳中心
context.rotate(angle);

context.translate(300,0); // 地球,月球轨道中心
// 地球
context.beginPath()
context.arc(0,0,40,0,2*Math.PI,false);
context.fillStyle = 'blue'
context.strokeStyle = 'blue'
context.fill()

// 月球轨道
context.beginPath()
context.arc(0, 0, 100, 0, Math.PI*2, true)
context.strokeStyle = 'rgb(255,255,255,0.3)'
context.stroke()
context.closePath()

context.rotate(-8*angle);

// 月球
context.beginPath()
context.arc(100,0,20,0,2*Math.PI,false);
context.fillStyle = '#fff'
context.fill()

context.restore(); // 恢复状态
time++
}
setInterval(draw,30)


结语


以上过程便能简单的绘制一个简易太阳系图形动画了,通过文档就能快速的绘制一个简单的图形,但是要绘制复杂的图形的话还是要花时间去研究一下文档。


作者:codePanda
来源:juejin.cn/post/7212442380263112760
收起阅读 »

AI能代替前端开发吗?以一键截图分享为例,看看AI到底有多强

最近在使用 ichati 的过程中发现一个问题,我不能很容易的给其他人分享聊天内容。 比如我想给我朋友分享一下 AI 写作的效果,于是开始截屏发微信群。 结果发现我必须截三次屏幕,发三条消息。 没办法一键截屏,这很难受。 于是我问了我们用户群里的一些用户...
继续阅读 »

最近在使用 ichati 的过程中发现一个问题,我不能很容易的给其他人分享聊天内容。


比如我想给我朋友分享一下 AI 写作的效果,于是开始截屏发微信群。


Pasted image 20230319192419.png


Pasted image 20230319192513.png


Pasted image 20230319192528.png


结果发现我必须截三次屏幕,发三条消息。


没办法一键截屏,这很难受。


于是我问了我们用户群里的一些用户,确实有很多用户向我反映是这个问题。但在我主动问之前,他们没有主动提出过这个问题。


所以对大多数的用户来说,他们的思维和习惯就是,适应产品,而不是改变产品。


那怎么办呢?


好的产品一定是能发现用户痛点并解决用户痛点的。


所以我得解决掉这个问题。


其实 ichati 发展到现在,很多功能都是在帮助用户解决使用问题的过程中增加的。


言归正传。


我最初的想法是,增加一个功能,可以一键截屏并分享给好友。


但我不想自己写代码,因为太浪费时间了。


由于我在做 AI 的产品,这种杂活当然不能自己干了。


所以我就打起了盘古的主意。


然后开始让它帮我实现这个功能。


ichati.cn.png


对,不到 2 分钟的时间,80 % 的代码工作已经结束了。


上面的截图就是通过一键分享的功能生成的,效果还不错。同时我还在底部追加了产品的网址和介绍。


剩下的工作就是自己微调一下了。微调的过程中,80% 的代码又都是通过 copilot 来实现的。我一共也没写几行代码。不得不感慨 AI 的能力实在是太过于强大。


这样无论是多么长的聊天内容,都可以在下方点击截图分享按钮一键分享全部内容了。


Pasted image 20230319195109.png


这就相当于我是一个产品经理,我给产品提了个需求,产品告诉我怎么做。这种感觉实在是太奇妙了。


通过这个事儿,我再来聊聊最近很热的话题。「AI 到底能否取代程序员?」


我的观点是能取代一部分,但绝对无法取代全部。


比如用户需要一个一键截图的功能,目前的 AI 基本上是发现不了的,除非用户主动告诉 AI。这是用户痛点,必须由人来挖掘,而负责这事的人一般叫做产品经理。


接下来是实现这个功能,产品经理不清楚怎么实现,因为产品经理不懂技术细节。像 DOM 啊,Canvas 啊,生成图像格式啊等等,都不知道,即使知道也是一知半解。


这就要向程序员去传达,再由程序员去理解和实现。


理解的过程 AI 是可以做的,但需要精准表达才能得到满意的答案。如果是一个合格的产品经理,做这事儿不难。


实现的过程,更是 AI 极其擅长的。在这一点上,我不认为人可以比得过 AI。


所以从开发应用的角度去看,我认为程序员已经没必要再去继续学习各种库,各种框架的使用方式和 API 了。在这一点上,人是不可能比得过 AI 的。甚至于说,再继续做这种事,毫无价值可言。


实现之后,进入微调阶段。


程序员的能力又体现出来了。因为人能否非常快速的发现问题,并修正问题。


AI 也具备这种能力,尽管它修正问题很迅速。但它发现问题的效率远比人低的多。我指的不是代码的问题,而是产品的问题。


有些产品 AI 并不知道人的预期是什么,因为产品是最终用户是人。人自然比 AI 更清楚产品正确的样貌。


但随着 AI 技术的发展,我不能保证未来的 AI 是不是会比人更懂得人需要什么。但我觉得那一天终将到来。


从以上几步来分析,AI 的意义在于,它会让一个懂得使用 AI,并且懂产品的人成指数增强。毫不夸张地说,一个擅长使用 AI,同时又懂技术的产品,战斗力能超过 1 个产品经理加上 5 个普通程序员。


同时,当前的 AI 定位仍然是助理。它不会喧宾夺主,而且它也做不到。


所以不必过度焦虑。


但从如今的趋势来看,个人技术的优势会慢慢变低。因为一个懂得使用 AI 的中级程序员,配合 AI,技术水平直接能提升数个级别。


这个感觉,就像龙珠里面悟空和贝吉塔合体,直接暴打魔人布欧一样。


所以我认为,如今我们笃定的技术这条路。在未来,它有可能会变成一个死胡同。


这不是焦虑,这是实事求是地讲道理。


搁在六七年前,让我来实现一个一键截图功能,可能会自己手写递归,折腾一下午。因为我认为这样会帮助我技术上的成长。


确实,那样做我会懂得更多。懂得更多就可以更快地解决问题,当出现问题更快地定位问题。


也正因为如此,我也可以有自信比别人要更高的薪水。


但是现在我不这么认为了,因为在效率和知识的深度、广度上,AI 都可以轻松的打败我。


如果在人人都在深度使用 AI 的时代,六七年前我做的那些事会显得相当没有意义。与其花费时间读源码,分析原理,不如去做点别的事儿。


所以我认为如今的技术人员,应该多尝试走几条不同的路。继续死磕技术,是绝对磕不过 AI 的。


本文的结论就是:AI 绝对有改变行业布局的能力,这点是毋庸置疑的。但 AI 不会取代你,相反,它会成就你。所以没必要总是把 AI 放到对立面来制造焦虑,在未来,AI 将会是我们最的佳伙伴和助手。


作者:代码与野兽
来源:juejin.cn/post/7212432799848284221
收起阅读 »

下一代代码助手 GitHub Copilot X 发布

GitHub 今日发布了 GitHub Copilot X,这是一款基于OpenAI的GPT-4模型开发的AI助手。GitHub Copilot X 致力于改进开发者体验,将提供聊天和语音界面,支持拉取请求,回答文档问题,并通过 GPT-4 实现更个性化的开发...
继续阅读 »

GitHub 今日发布了 GitHub Copilot X,这是一款基于OpenAI的GPT-4模型开发的AI助手。GitHub Copilot X 致力于改进开发者体验,将提供聊天和语音界面,支持拉取请求,回答文档问题,并通过 GPT-4 实现更个性化的开发者体验。GitHub Copilot 作为AI编程搭档,已经为开发者提供了自动补全代码和注释功能。GitHub Copilot X将继续扩展这一功能,包括:



  1. GitHub Copilot聊天功能:在编辑器中集成与VS Code和Visual Studio的聊天界面,帮助开发者深入分析代码、生成单元测试和修复bug。

  2. GitHub Copilot语音功能:允许开发者通过口头指令进行自然语言提示。

  3. 为拉取请求提供AI生成的描述:基于GPT-4模型生成拉取请求描述和标签。

  4. 自动生成测试建议:GitHub Copilot将警告开发者测试覆盖率不足,并建议潜在的测试方案。

  5. GitHub Copilot文档功能:提供基于聊天界面的文档查询,为开发者提供实时答案。

  6. GitHub Copilot CLI:为命令行界面提供智能建议。


GitHub Copilot X的目标是为每个团队、项目和代码库提供个性化的开发体验,让开发者更高效地开发软

作者:江昪
来源:juejin.cn/post/7213335620126982202
件,提高工作满意度。

收起阅读 »

面试官问我:SharedPreference源码中apply跟commit的原理,导致ANR的原因

1.前言好几年前写过一篇SharedPreference源码相关的文章,对apply跟commit方法讲解的不够透彻,作为颜值担当的天才少年来说,怎么能不一次深入到底呢?2.正文为了熟读源码,下班后我约了同事小雪一起探讨,毕竟三人行必有我师焉。哪里来的三个人,...
继续阅读 »

1.前言

好几年前写过一篇SharedPreference源码相关的文章,对apply跟commit方法讲解的不够透彻,作为颜值担当的天才少年来说,怎么能不一次深入到底呢?

2.正文

为了熟读源码,下班后我约了同事小雪一起探讨,毕竟三人行必有我师焉。哪里来的三个人,不管了,跟小雪研究学术更重要。

在这里插入图片描述

小安学长,看了你之前的文章:Android SharedPreference 源码分析(一)对apply(),commit()的底层原理还是不理解,尤其是线程和一些同步锁他里面怎么使用,什么情况下会出现anr?

既然说到apply(),commit()的底层原理,那肯定是老步骤了,上源码。 apply源码如下:

public void apply() {
final long startTime = System.currentTimeMillis();

final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
public void run() {
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException ignored) {
}

if (DEBUG && mcr.wasWritten) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " applied after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
};
// 将 awaitCommit 添加到队列 QueuedWork 中
QueuedWork.addFinisher(awaitCommit);

Runnable postWriteRunnable = new Runnable() {
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);// 将 awaitCommit 从队列 QueuedWork 中移除
}
};

SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

// Okay to notify the listeners before it's hit disk
// because the listeners should always get the same
// SharedPreferences instance back, which has the
// changes reflected in memory.
notifyListeners(mcr);
}

你这丢了一大堆代码,我也看不懂啊。

别急啊,这漫漫长夜留给我们的事情很多啊,听我一点点给你讲,包你满意。 请添加图片描述

apply()方法做过安卓的都知道(如果你没有做过安卓,那你点开我博客干什么呢,死走不送),频繁写文件建议用apply方法,因为他是异步存储到本地磁盘的。那么具体源码是如何操作的,让我们掀开他的底裤,不是,让我们透过表面看本质。

我们从下往上看,apply方法最后调用了SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);我长得帅我先告诉你,enqueueDiskWrite方法会把存储文件的动作放到子线程,具体怎么放的,我们等下看源码,这边你只要知道他的作用。这个方法的第二个参数 postWriteRunnable做了两件事:
1)让awaitCommit执行,及执行 mcr.writtenToDiskLatch.await();
2)执行QueuedWork.remove(awaitCommit);代码

writtenToDiskLatch是什么,QueuedWork又是什么?

writtenToDiskLatch是CountDownLatch的实例化对象,CountDownLatch是一个同步工具类,它通过一个计数器来实现的,初始值为线程的数量。每当一个线程完成了自己的任务调用countDown(),则计数器的值就相应得减1。当计数器到达0时,表示所有的线程都已执行完毕,然后在等待的线程await()就可以恢复执行任务。
1)countDown(): 对计数器进行递减1操作,当计数器递减至0时,当前线程会去唤醒阻塞队列里的所有线程。
2)await(): 阻塞当前线程,将当前线程加入阻塞队列。 可以看到如果postWriteRunnable方法被触发执行的话,由于 mcr.writtenToDiskLatch.await()的缘故,UI线程会被一直阻塞住,等待计数器减至0才能被唤醒。

QueuedWork其实就是一个基于handlerThread的,处理任务队列的类。handlerThread类为你创建好了Looper和Thread对象,创建Handler的时候使用该looper对象,则handleMessage方法在子线程中,可以做耗时操作。如果对于handlerThread的不熟悉的话,可以看我前面的文章:Android HandlerThread使用介绍以及源码解析

在这里插入图片描述 觉得厉害,那咱就继续深入。
enqueueDiskWrite源码如下所示:

private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final boolean isFromSyncCommit = (postWriteRunnable == null);

final Runnable writeToDiskRunnable = new Runnable() {
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};

// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}

QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

很明显postWriteRunnable不为null,程序会执行QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);从writeToDiskRunnable我们可以看到,他里面做了两件事:
1)writeToFile():内容存储到文件;
2)postWriteRunnable.run():postWriteRunnable做了什么,往上看,上面已经讲了该方法做的两件事。

QueuedWork.queue源码:

    public static void queue(Runnable work, boolean shouldDelay) {
Handler handler = getHandler();

synchronized (sLock) {
sWork.add(work);

if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
private static class QueuedWorkHandler extends Handler {
static final int MSG_RUN = 1;

QueuedWorkHandler(Looper looper) {
super(looper);
}

public void handleMessage(Message msg) {
if (msg.what == MSG_RUN) {
processPendingWork();
}
}
}

这边我默认你已经知道HandlerThread如何使用啦,如果不知道,麻烦花五分钟去看下我之前的博客。
上面的代码很简单,其实就是把writeToDiskRunnable这个任务放到sWork这个list中,并且执行handler,根据HandlerThread的知识点,我们知道handlermessage里面就是子线程了。

接下来我们继续看handleMessage里面的processPendingWork()方法:

 private static void processPendingWork() {
long startTime = 0;

if (DEBUG) {
startTime = System.currentTimeMillis();
}

synchronized (sProcessingWork) {
LinkedList<Runnable> work;

synchronized (sLock) {
work = (LinkedList<Runnable>) sWork.clone();
sWork.clear();

// Remove all msg-s as all work will be processed now
getHandler().removeMessages(QueuedWorkHandler.MSG_RUN);
}

if (work.size() > 0) {
for (Runnable w : work) {
w.run();
}

if (DEBUG) {
Log.d(LOG_TAG, "processing " + work.size() + " items took " +
+(System.currentTimeMillis() - startTime) + " ms");
}
}
}
}

这代码同样很简单,先是把sWork克隆给work,然后开启循环,执行work对象的run方法,及调用writeToDiskRunnable的run方法。上面讲过了,他里面做了两件事:1)内容存储到文件 2)postWriteRunnable方法回调。 执行run方法的代码:

 final Runnable writeToDiskRunnable = new Runnable() {
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);//由于handlermessage在子线程,则writeToFile也在子线程中
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};

writeToFile方法我们不深入去看,但是要关注,里面有个setDiskWriteResult方法,在该方法里面做了如下的事情:

void setDiskWriteResult(boolean wasWritten, boolean result) {
this.wasWritten = wasWritten;
writeToDiskResult = result;
writtenToDiskLatch.countDown();//计数器-1
}

如何上面认真看了的同学,应该可以知道,当调用countDown()方法时,会对计数器进行递减1操作,当计数器递减至0时,当前线程会去唤醒阻塞队列里的所有线程。也就是说,当文件写完时,UI线程会被唤醒。

既然文件写完就会释放锁,那什么情况下会出现ANR呢?

Android系统为了保障在页面切换,也就是在多进程中sp文件能够存储成功,在ActivityThread的handlePauseActivity和handleStopActivity时会通过waitToFinish保证这些异步任务都已经被执行完成。如果这个时候过渡使用apply方法,则可能导致onpause,onStop执行时间较长,从而导致ANR。

private void handlePauseActivity(IBinder token, boolean finished,
boolean userLeaving, int configChanges, boolean dontReport, int seq) {
......
r.activity.mConfigChangeFlags |= configChanges;
performPauseActivity(token, finished, r.isPreHoneycomb(), "handlePauseActivity");

// Make sure any pending writes are now committed.
if (r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}

......
}

你肯定要问,为什么过渡使用apply方法,就有可能导致ANR?那我们只能看QueuedWork.waitToFinish();到底做了什么

 public static void waitToFinish() {
long startTime = System.currentTimeMillis();
boolean hadMessages = false;

Handler handler = getHandler();

synchronized (sLock) {
if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
// Delayed work will be processed at processPendingWork() below
handler.removeMessages(QueuedWorkHandler.MSG_RUN);

if (DEBUG) {
hadMessages = true;
Log.d(LOG_TAG, "waiting");
}
}

// We should not delay any work as this might delay the finishers
sCanDelay = false;
}

StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
processPendingWork();
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}

try {
while (true) {
Runnable finisher;

synchronized (sLock) {
finisher = sFinishers.poll();
}

if (finisher == null) {
break;
}

finisher.run();
}
} finally {
sCanDelay = true;
}

synchronized (sLock) {
long waitTime = System.currentTimeMillis() - startTime;

if (waitTime > 0 || hadMessages) {
mWaitTimes.add(Long.valueOf(waitTime).intValue());
mNumWaits++;

if (DEBUG || mNumWaits % 1024 == 0 || waitTime > MAX_WAIT_TIME_MILLIS) {
mWaitTimes.log(LOG_TAG, "waited: ");
}
}
}
}

看着一大坨代码,其实做了两件事:
1)主线程执行processPendingWork()方法,把之前未执行完的内容存储到文件的操作执行完,这部分动作直接在主线程执行,如果有未执行的文件操作并且文件较大,则主线程会因为IO时间长造成ANR。
2)循环取出sFinishers数组,执行他的run方法。如果这时候有多个异步线程或者异步线程时间过长,同样会造成阻塞产生ANR。

第一个很好理解,第二个没有太看明白,sFinishers数组是在什么时候add数据的,而且根据writeToDiskRunnable方法可以知道,先写文件再加锁的,为啥会阻塞呢?

在这里插入图片描述

sFinishers的addFinisher方法是在apply()方法里面调用的,代码如下:

        @Override
public void apply() {
......
// 将 awaitCommit 添加到队列 QueuedWork 中
QueuedWork.addFinisher(awaitCommit);

Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);// 将 awaitCommit 从队列 QueuedWork 中移除
}
};
......
}

正常情况下其实是不会发生ANR的,因为writeToDiskRunnable方法中,是先进行文件存储再去阻塞等待的,此时CountDownLatch永远都为0,则不会阻塞主线程。

final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);//写文件,写成功后会调用writtenToDiskLatch.countDown();计数器-1
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();//回调到awaitCommit.run();进行阻塞
}
}
};

但是如果processPendingWork方法在异步线程在执行时,及通过enqueueDiskWrite方法触发的正常文件保存流程,这时候文件比较大或者文件比较多,子线程则一直在运行中;当用户点击页面跳转时,则触发该Activity的handlePauseActivity方法,根据上面的分析,handlePauseActivity方法里面会执行waitToFinish保证这些异步任务都已经被执行完成。
由于这边主要介绍循环取出sFinishers数组,执行他的run方法造成阻塞产生ANR,我们就重点看下sFinishers数组对象是什么,并且执行什么动作。

private static final LinkedList<Runnable> sFinishers = new LinkedList<>();
@UnsupportedAppUsage
public static void addFinisher(Runnable finisher) {
synchronized (sLock) {
sFinishers.add(finisher);
}
}

addFinisher刚刚上面提到是在apply方法中调用,则finisher就是入参awaitCommit,他的run方法如下:

final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
try {
mcr.writtenToDiskLatch.await();//阻塞
} catch (InterruptedException ignored) {
}

if (DEBUG && mcr.wasWritten) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " applied after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
};

不难看出,就是调用CountDownLatch对象的await方法,阻塞当前线程,将当前线程加入阻塞队列。也就是这个时候整个UI线程都阻塞在这边,等待processPendingWork这个异步线程执行完毕,虽然你是在子线程,但是我主线程在等你执行结束才会进行页面切换,所以如果过渡使用apply方法,则可能导致onpause,onStop执行时间较长,从而导致ANR。

小安学长不愧是我的偶像,我都明白了,那继续讲讲同步存储commit()方法吧。

commit方法其实就比较简单了,无非是内存和文件都在UI线程中,我们看下代码证实一下:

 @Override
public boolean commit() {
long startTime = 0;

if (DEBUG) {
startTime = System.currentTimeMillis();
}

MemoryCommitResult mcr = commitToMemory();//内存保存

SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);//第二个参数为null
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
if (DEBUG) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " committed after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}

可以看到enqueueDiskWrite的第二个参数为null,enqueueDiskWrite方法其实上面讲解apply的时候已经贴过了,为了不让你往上翻我们继续看enqueueDiskWrite方法:

   private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final boolean isFromSyncCommit = (postWriteRunnable == null);//此时postWriteRunnable为null,isFromSyncCommit 则为true

final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}
synchronized (mLock) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};

// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) { //当调用commit方法时,isFromSyncCommit则为true
boolean wasEmpty = false;
synchronized (mLock) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();//主线程回调writeToDiskRunnable的run方法,进行writeToFile文件的存储
return;
}
}

QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

关键代码已经注释过了,由于postWriteRunnable为null,则isFromSyncCommit为true,代码会在主线程回调writeToDiskRunnable的run方法,进行writeToFile文件的存储。这部分动作直接在主线程执行,如果文件较大,则主线程也会因为IO时间长造成ANR的。

所以SharedPreference 不管是commit()还是apply()方法,如果文件过大或者过多,都会有ANR的风险,那如何规避呢?

解决肯定有办法的,下一篇就介绍SharedPreference 的替代方案mmkv的原理,只是今晚有点晚了,咱们早上睡吧,不是,早点回家吧~~~


作者:天才少年_
链接:https://juejin.cn/post/7209447968218382392
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

不一样的Android堆栈抓取方案

背景 曾几何时,我们只需要简简单单的一行 Thread.currentThread().getStackTrace() 代码就可以轻轻松松的获取到当前线程的堆栈信息,从而分析各种问题。随着需求的不断迭代,APP 遇到的问题越来越多,卡顿,ANR,异常等等问题接...
继续阅读 »

背景


曾几何时,我们只需要简简单单的一行 Thread.currentThread().getStackTrace() 代码就可以轻轻松松的获取到当前线程的堆栈信息,从而分析各种问题。随着需求的不断迭代,APP 遇到的问题越来越多,卡顿,ANR,异常等等问题接踵而来,那么简简单单某个时刻的堆栈信息已经不能满足我们的需求了,我们的目光逐渐转移到了每个时刻的堆栈上,如果能获取一个时间段内,每个时刻的堆栈信息,那么卡顿,以及 ANR 的问题也将被解决。


抓栈方案


目前对于一段时间内的抓栈方案有两种:



  • 方法插桩抓栈

  • Native 抓栈


代码插桩抓栈


基本思路


APP 编译阶段,对每个方法进行插桩,在插桩的同时,填入当前方法 ID,发生卡顿或者异常的时候,将之前收集到的方法 ID 进行聚合输出。


插桩流程图:



优点:简单高效,无兼容性问题


缺点:插桩导致所有类都非 preverify,同时 verify 与 optimize 操作会在加载类时被触发。增加类加载的压力照成一定的性能损耗。另外也会导致包体积变大,影响代码 Debug 以及代码崩溃异常后错误行数


Native 抓栈


使用 Native 抓栈之前,我们先了解一下 Java 抓栈的整个流程


JAVA堆栈获取流程图


抓栈当前线程


抓栈当前线程


抓栈其他线程


抓栈其他线程


Java堆栈获取原理分析


由于当前线程抓栈和其他线程抓栈流程类似,这里我们从其他线程抓栈的流程进行分析
首先从入口代码出发,Java 层通过 Thread.currentThread().getStackTrace() 开始获取当前堆栈数据


Thread.java

public StackTraceElement[] getStackTrace() {

StackTraceElement ste[] = VMStack.getThreadStackTrace(this);
return str!=null?ste:EmptyArray.STACK_TRACE_ELEMENT;

}

Thread 中的 getStackTrace 只是一个空壳,底层的实现是通过 native 来获取的,继续往下走,通过 VMStack 来获取我们需要的线程堆栈数据


dalvik_system_vmstack.cc

static jobjectArray VMStack_getThreadStackTrace(JNIEnv* env, jclass, jobject javaThread) {

ScopedFastNativeObjectAccess soa(env);

// fn 方法是线程挂起回调
auto fn = [](Thread* thread, const ScopedFastNativeObjectAccess& soaa)
REQUIRES_SHARED(Locks::mutator_lock_) -> jobject {
return thread->CreateInternalStackTrace(soaa);
};

// 获取堆栈
jobject trace = GetThreadStack(soa, javaThread, fn);
if (trace == nullptr) {
return nullptr;
}

// trace 是一个包含 method 的数组,有这个数据之后,我们进行数据反解,就能获取到方法堆栈明文
return Thread::InternalStackTraceToStackTraceElementArray(soa, trace);

}

上述代码中,需要注意三个元素




  • fn={return thread->CreateInternalStackTrace(soaa);}。 // 这个是线程挂起后的回调函数




  • GetThreadStack(sao,javaThread,fn) // 用来获取实际的线程堆栈信息




  • Thread::InternalStackTraceToStackTraceElementArray(sao,trace),这里 trace 就是我们拿到的目标产物,这里面就包含了当前线程此时此刻的堆栈信息,需要对堆栈进行进一步的解析,才能获取到可识别的堆栈文本




接下来我们从获取堆栈信息函数着手,看看 GetThreadStack 的具体行为。


dalvik_system_vmstack.cc

static ResultT GetThreadStack(const ScopedFastNativeObjectAccess& soa,jobject peer,T fn){

********
********
********

ThreadList* thread_list = Runtime::Current()->GetThreadList();

// 【Step1】: 挂起线程
Thread* thread = thread_list->SuspendThreadByPeer(peer,SuspendReason::kInternal,&timed_out);
if (thread != nullptr) {
{
ScopedObjectAccess soa2(soa.Self());

// 【Step2】: FN 回调,这里面执行的就是抓栈操作,回到外层的回调函数逻辑中
trace = fn(thread, soa);
}

// 【Step3】: 恢复线程
bool resumed = thread_list->Resume(thread, SuspendReason::kInternal);
}
}
return trace;
}

在该操作的三个步骤中,就包含了抓栈的整个流程,




  • 【Step1】: 挂起线程,线程每时每刻都在执行方法,这样就导致当前线程的方法堆栈在不停的增加,如果想要抓到瞬时堆栈,就需要把当前线程暂停,保留瞬时的堆栈信息,这样抓出来的数据才是准确的。




  • 【Step2】: 执行 FN 的回调,这里的 FN 回调,就是上文介绍的回调方法 fn={return thread->CreateInternalStackTrace(soaa)}




  • 【Step3】: 恢复线程的正常运行。




上述流程中,我们需要重点关注一下 FN 回调里面做了什么,以及怎么做到的


thread.cc

jobject Thread::CreateInternalStackTrace(const ScopedObjectAccessAlreadyRunnable& soa) const {

// 创建堆栈回溯观察者
FetchStackTraceVisitor count_visitor(const_cast<Thread*>(this),&saved_frames[0],kMaxSavedFrames);
count_visitor.WalkStack(); // 回溯核心方法

// 创建堆栈回溯观察者 2 号,详细的堆栈数据就是 2 号处理返回的
BuildInternalStackTraceVisitor build_trace_visitor(soa.Self(), const_cast<Thread*>(this), skip_depth);

mirror::ObjectArray<mirror::Object>* trace = build_trace_visitor.GetInternalStackTrace();
return soa.AddLocalReference<jobject>(trace);

}



  • 创建堆回溯观察者 1 号 FetchStackTraceVisitor,最大深度 256 进行回溯,如果深度超过了 256,则使用 2 号继续进行回溯




  • 创建堆回溯观察者 2 号 BuildInternalStackTraceVisitor,承接 1 号的回溯结果,1 号没回溯完,2 号接着回溯。




栈回溯的详细过程


回溯是通过 WalkStack 来实现的。StackVisitor::WalkStack 是一个用于在当前线程堆栈上单步遍历帧的函数。它可以用来收集当前线程堆栈上特定帧的信息,以便进行调试或其他分析操作。 例如,它可以用来找出当前线程堆栈上哪些函数调用了特定函数,或者收集特定函数的参数。 也可以用来找出线程调用的函数层次结构,以及每一层调用的函数参数。 使用这个函数,可以更好地理解代码的执行流程,并帮助进行异常处理和调试。


stack.cc

void StackVisitor::WalkStack(bool include_transitions) {

for (const ManagedStack* current_fragment = thread_->GetManagedStack();current_fragment != nullptr; current_fragment = current_fragment->GetLink()) {

cur_shadow_frame_ = current_fragment->GetTopShadowFrame();

****
****
****

do {
// 通知子类,进行栈帧的获取
bool should_continue = VisitFrame();
cur_depth_++;
cur_shadow_frame_ = cur_shadow_frame_->GetLink();
} while (cur_shadow_frame_ != nullptr);
}

}

ManagedStack 是一个单链表,保存了当前 ShadowFrame 或者 QuickFrame 栈指针,先依次遍历 ManagedStack 链表,然后遍历其内部的 ShadowFrame 或者 QuickFrame 还原一个可读的调用栈,从而还原出当前的 Java 堆栈



还原操作是通过 VisitFrame 来实现的,它是一个抽象接口,实现类我们需要看 BuildInternalStackTraceVisitor 的实现


thread.cc

class BuildInternalStackTraceVisitor : public StackVisitor {

mirror::ObjectArray<mirror::Object>* trace_ = nullptr;
bool VisitFrame() override REQUIRES_SHARED(Locks::mutator_lock_) {

****
****
****

// 每循环一帧,将其添加到 arrObj 中
ArtMethod* m = GetMethod();
AddFrame(m, m->IsProxyMethod() ? dex::kDexNoIndex : GetDexPc());
return true;
}

void AddFrame(ArtMethod* method, uint32_t dex_pc) REQUIRES_SHARED(Locks::mutator_lock_) {
ObjPtr<mirror::Object> keep_alive;
if (UNLIKELY(method->IsCopied())) {
ClassLinker* class_linker = Runtime::Current()->GetClassLinker();
keep_alive = class_linker->GetHoldingClassLoaderOfCopiedMethod(self_, method);
} else {
keep_alive = method->GetDeclaringClass();
}

// 添加每一次遍历到的 artMethod 对象,在添加完成之后,进行 count++,进行 Arr 的偏移
trace_->Set<false,false>(static_cast<int32_t>(count_) + 1, keep_alive);
++count_;
}

}

在执行 VisitFrame 的过程中,会将每次的 method 拎出来,然后添加至 ObjectArray 的集合中。当所有方法查找完成之后,会进行 method 的反解。


堆栈信息反解关键操作


反解的流程在文章开头,通过 Thread::InternalStackTraceToStackTraceElementArray(soa,trace) 来进行反解。


thread.cc

jobjectArray Thread::InternalStackTraceToStackTraceElementArray(const ScopedObjectAccessAlreadyRunnable& soa,jobject internal,jobjectArray output_array,int* stack_depth) {

int32_t depth = soa.Decode<mirror::Array>(internal)->GetLength() - 1;

for (uint32_t i = 0; i < static_cast<uint32_t>(depth); ++i) {
ObjPtr<mirror::ObjectArray<mirror::Object>> decoded_traces = soa.Decode<mirror::Object>(internal)->AsObjectArray<mirror::Object>();
const ObjPtr<mirror::PointerArray> method_trace = ObjPtr<mirror::PointerArray>::DownCast(decoded_traces->Get(0));

// 【Step1】: 提取数组中的 ArtMethod
ArtMethod* method = method_trace->GetElementPtrSize<ArtMethod*>(i, kRuntimePointerSize);
uint32_t dex_pc = method_trace->GetElementPtrSize<uint32_t>(i + static_cast<uint32_t>(method_trace->GetLength()) / 2, kRuntimePointerSize);

// 【Step2】: 将 ArtMethod 转换成业务上层可识别的 StackTraceElement 对象
const ObjPtr<mirror::StackTraceElement> obj = CreateStackTraceElement(soa, method, dex_pc);
soa.Decode<mirror::ObjectArray<mirror::StackTraceElement>>(result)->Set<false>(static_cast<int32_t>(i), obj);
}
return result;

}

static ObjPtr<mirror::StackTraceElement> CreateStackTraceElement(

const ScopedObjectAccessAlreadyRunnable& soa,
ArtMethod* method,
uint32_t dex_pc) REQUIRES_SHARED(Locks::mutator_lock_) {

// 【Step3】: 获取行号
line_number = method->GetLineNumFromDexPC(dex_pc);

// 【Step4】: 获取类名
const char* descriptor = method->GetDeclaringClassDescriptor();
std::string class_name(PrettyDescriptor(descriptor));
class_name_object.Assign(mirror::String::AllocFromModifiedUtf8(soa.Self(), class_name.c_str()));

// 【Step5】: 获取类路径
const char* source_file = method->GetDeclaringClassSourceFile();
source_name_object.Assign(mirror::String::AllocFromModifiedUtf8(soa.Self(), source_file));


// 【Step6】: 获取方法名
const char* method_name = method->GetInterfaceMethodIfProxy(kRuntimePointerSize)->GetName();
Handle<mirror::String> method_name_object(hs.NewHandle(mirror::String::AllocFromModifiedUtf8(soa.Self(), method_name)));

// 【Step7】: 数据封装回抛
return mirror::StackTraceElement::Alloc(soa.Self(),class_name_object,method_name_object,source_name_object,line_number);
}

到这里我们已经分析完一次由 Java 层触发的堆栈调用链路一直到底层的实现逻辑。


核心流程


我们的目标是抓栈,因此我们只需要关注 count_visitor.WalkStack 之后的栈回溯流程。



耗时阶段


这里最后阶段将 ArtMethod 转换成业务上层可识别的 StackTraceElement,由于涉及到大量的字符串操作,给 Java 堆栈的执行贡献了很大的耗时占比。


抓栈新思路


传统的抓栈产生的数据很完善,过程也比较耗时。我们是否可以简化这个流程,提高抓栈效率呢,理论上是可以的,我们只需要自己将这个流程复写一份,然后抛弃部分的数据,优化数据获取时间,同样可以做到更高效的抓栈体验。


Native抓栈逻辑实现


根据系统抓栈流程,我们可以梳理出要做的几个事情点


要做的事情:




  • 挂起线程【获取挂起线程方法内存地址】




  • 进行抓栈【获取抓栈方法内存地址】【优化抓栈耗时】




  • 恢复线程的执行【获取恢复线程方法内存地址】




遇到的问题及解决方案:



  • 如何获取系统 threadList 对象


threadList 是线程执行挂起和恢复的关键对象,系统未暴露该对象的直接访问操作,因此我们只能另辟蹊径来获取它,threadList 获取依赖流程图如下:



如果想要执行线程的挂起 thread_->SuspendThreadByPeer 或者恢复 thread_list->Resume ,首先需要获取到 thread_list 系统对象,该对象是通过 Runtime::Current()->getThreadList() 获取而来,,因此我们要先获取 Runtime , Runtime 的获取可以通过 JavaVmExt 来获取,而 JavaVmExt 可以通过 JNI_OnLoad 时的 JavaVM 来获取,完整流程如下代码所示


JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {

JNIEnv *env = NULL;
if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) {
return -1;
}

JavaVM *javaVM;
env->GetJavaVM(&javaVM);
auto *javaVMExt = (JavaVMExt *) javaVM;
void *runtime = javaVMExt->runtime;

// JavaVMExt 结构
// 10.0 https://android.googlesource.com/platform/art/+/refs/tags/android-10.0.0_r1/runtime/jni/java_vm_ext.h

// 【Step1】. 找到 Runtime_instance_ 的位置
if (api < 30) {
runtime_instance_ = runtime;
} else {
int vm_offset = find_offset(runtime, MAX_SEARCH_LEN, javaVM);
runtime_instance_ = reinterpret_cast<void *>(reinterpret_cast<char *>(runtime) + vm_offset - offsetof(PartialRuntimeR, java_vm_));
}

// 【Step2】. 以 runtime_instance_ 的地址为起点,开始找到 JavaVMExt 在 【https://android.googlesource.com/platform/art/+/refs/tags/android-10.0.0_r29/runtime/runtime.h】中的位置
// 7.1 https://android.googlesource.com/platform/art/+/refs/tags/android-7.1.2_r39/runtime/runtime.h
int offsetOfVmExt = findOffset(runtime_instance_, 0, MAX, (size_t) javaVMExt);
if (offsetOfVmExt < 0) {
ArtHelper::reduce_model = 1;
return;
}

// 【Step3】. 根据 JavaVMExt 的位置,根据各个版本的结构,进行偏移,生成 PartialRuntimeSimpleTenR 的结构
if (ArtHelper::api == ANDROID_P_API || ArtHelper::api == ANDROID_O_MR1_API) {
PartialRuntimeSimpleNineR *simpleR = (PartialRuntimeSimpleNineR *) ((char *) runtime_instance_ + offsetOfVmExt - offsetof(PartialRuntimeSimpleNineR, java_vm_));
thread_list = simpleR->thread_list_;
}else if (ArtHelper::api <= ANDROID_O_API) {
PartialRuntimeSimpleSevenR *simpleR = (PartialRuntimeSimpleSevenR *) ((char *) runtime_instance_ + offsetOfVmExt - offsetof(PartialRuntimeSimpleSevenR, java_vm_));
thread_list = simpleR->thread_list_;
}else{
PartialRuntimeSimpleTenR *simpleR = (PartialRuntimeSimpleTenR *) ((char *) runtime_instance_ + offsetOfVmExt - offsetof(PartialRuntimeSimpleTenR, java_vm_));
thread_list = simpleR->thread_list_;
}

}

经过三个步骤,我们就可以获取到底层的 Runtime 对象,以及最关键的 thread_list 对象,有了它,我们就可以对线程执行暂停和恢复操作。



  • 线程的暂停和恢复


因为 SuspendThreadByPeer 和 Resume 方法我们访问不到,但如果我们能够找到这两个方法的内存地址,那么就可以直接执行了,怎么获取到内存地址呢?这里使用 Nougat_dlfunctions 的 fake_dlopen() 和 fake_dlsym() 来获取已被加载到内存的动态链接库 libart.so 中方法内存地址。


    WalkStack_ = reinterpret_cast<void (*)(StackVisitor *, bool)>(dlsym_ex(handle,"_ZN3art12StackVisitor9WalkStackILNS0_16CountTransitionsE0EEEvb"));
SuspendThreadByThreadId_ = reinterpret_cast<void *(*)(void *, uint32_t, SuspendReason, bool *)>(dlsym_ex(handle,"_ZN3art10ThreadList23SuspendThreadByThreadIdEjNS_13SuspendReasonEPb"));
Resume_ = reinterpret_cast<bool (*)(void *, void *, SuspendReason)>(dlsym_ex(handle, "_ZN3art10ThreadList6ResumeEPNS_6ThreadENS_13SuspendReasonE"));
PrettyMethod_ = reinterpret_cast<std::string (*)(void *, bool)>(dlsym_ex(handle, "_ZN3art9ArtMethod12PrettyMethodEb"));


到这里,我们已经已经可以完成线程的挂起和恢复了,接下来就是抓栈的操作处理流程。



  • 自定义抓栈


同样的,由于我们已经获取到用于栈回溯的 WalkStack 方法地址,我们只需要提供一个自定义的 TraceVisitor 类即可实现栈回溯


class CustomFetchStackTraceVisitor : public StackVisitor {

bool VisitFrame() override {

// 【Step1】: 系统堆栈调用时我们分析到的流程,每帧遍历时会走一次当前流程
void *method = GetMethod();

// 【Step2】: 获取到 Method 对象之后,使用 circular_buffer 存起来,没有多余的过滤逻辑,不反解字符串
if (CustomFetchStackTraceVisitorCallback!= nullptr){
return CustomFetchStackTraceVisitorCallback(method);
}
return true;
}

}

获取到 Method 之后,为了节省本次的抓栈耗时,我们使用固定大小的 circular_buffer 将数据存储起来,新数据自动覆盖老数据,根据需求,进行异步反解 Method 中的详细堆栈数据。到这里,自定义的 Native 抓栈逻辑就完成了。


总结


目前自定义 native 抓栈的多个阶段需要兼容不同系统版本的 thread_list 获取,以及不同版本的线程挂起,线程恢复的函数地址获取。这些都会导致出现或多或少的兼容性问题,这里可以通过两种方案来规避,第一种是过滤读取到的不合法地址,对于这类不合法地址,需要跳过抓栈流程。另外一种就是动态配置下发过滤这些不兼容版本机型。


参考资料



作者:网易云音乐技术团队
链接:https://juejin.cn/post/7212809255946469432
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

技术简历该如何写

很久没有写文章了,一直不知道如何再起笔,也一直没有想好要写些什么。这几年感受最深的就是互联网跌宕起伏,身边多多少少的技术小伙伴都有不同程度的受到影响。裁员,求职,招聘这几个词一直围绕着这两年的技术人。因此,重新执笔后的第一篇就来聊聊技术简历该如何写。 简历不仅...
继续阅读 »

很久没有写文章了,一直不知道如何再起笔,也一直没有想好要写些什么。这几年感受最深的就是互联网跌宕起伏,身边多多少少的技术小伙伴都有不同程度的受到影响。裁员,求职,招聘这几个词一直围绕着这两年的技术人。因此,重新执笔后的第一篇就来聊聊技术简历该如何写。


简历不仅仅是敲门砖


我们都经常会听到简历是敲门砖。但是它也不仅仅只是敲门砖,一个技术岗位的求职流程,从投递简历之后,一般会经历以下几个流程:


hr的简历筛选 -> 部门简历筛选 -> N轮技术面 -> HR面 -> 谈offer -> 接offer ->背调 -> 入职,而简历只有在前两轮的简历筛选才会起到敲门砖的作用。除了得到面试的机会以外,它在技术面中也有着非常重要的作用。在技术面中,一般面试官包括你所投递的岗位所在的小组的同事、你的组长、部门的Leader,事业线的Leader等,如果有交叉面,可能有别的小组或者部门的人,这些人对候选人的第一印象就是简历,其次是之前面试官的面评,而在这个技术面环节,简历的内容将会直接影响你的技术面走向。所以一定要重视自己的简历,如果连求职者自己都没能重视自己的简历,又怎么能希望面试官很好的考察和了解你。


如何编排简历内容


首先来聊聊简历整体内容编排的原则:



  1. 不要出现重复的内容:简历的空间大小一般跟一张A4纸大小差不多,在简历这寸土寸金的地方,切记不要出现重复的内容来浪费简历的空间。尽可能的利用简历空间。

  2. 字号适中和字体统一:字号过小会导致在阅读简历时非常吃力,而字号过大会比较占用简历空间,所以可以根据自己的内容,适当调节字号。而全篇的字体尽量保持一种风格,不要出现多种字体风格。

  3. 简历内容需要有侧重点:侧重的内容有以下四种方式:加图标来凸显侧重的内容、加粗、改变文字颜色以及改变字号。比较推荐用加粗和适当的字号改变来凸显侧重的内容,但是在一些正文部分不建议用改变字号来凸显,因为会显得整体很乱,一般在正文部分加粗就可以达到凸显的目的。

  4. 把精力从提升美感转移到充实内容:可能很多人会觉得简历美观不是更加分吗?的确,在内容相同的情况下,精美的简历的确会更有优势,但是在技术岗位中,除了产品设计等与美搭边的岗位,其他岗位对此的加分可以忽略不计,而有这个时间纠结简历的边框颜色用什么,不如多审视自己的内容,注重内容的编排和撰写。虽然可以适当不注重美感,但是简历基本的整洁还是需要保证的。


除了简历整体的编排原则外,最重要的就是技术简历的内容,技术简历可以拆解成以下六部分,其中前五个部分必须要有,最后一个部分是可选:



  • (必选)基本信息:在基本信息中必须呈现的内容有姓名、性别、年龄、民族、籍贯、电话、邮箱。基本信息必须准确且真实,否则面试官很难联系到候选人。这些内容一般都不太会有很大的问题,但是在基本信息中,对于照片,除非是对自己的外貌非常自信,并且认为外貌出众到能够起到加分效果的,才把照片附着在简历上,否则大可不必在简历上加上照片,因为它既起不到加分的作用,还会占用简历空间。

  • (必选)教育背景:教育背景一般罗列从大学学历至最高学历的教育背景,那些从高中学历开始写的真的大可不必。并且在排版上建议把最高学历放在最前面,倒叙的方式描述你的教育背景,这样可以让hr和面试官第一眼就了解你的学历优势。如果是一些不太耳熟能详的学校,但是却是985或者211的学校,建议在学校名称后面加括号备注,以免筛选简历的人不了解学校背景而导致被误杀。

  • (必选)职业技能:在写职业技能时,首先从岗位要求中去提炼关键字,一般的岗位要求会列举一些具体的职业技能关键字,以及比较笼统的职业技能关键字,比如岗位要求熟悉Docker、Kubernetes等云原生技术,其中熟悉Kubernetes是一项比较具体的职业技能,而云原生就是一个比较笼统的职业技能描述,它涵盖的范围就很大。而我们在描述职业技能时,第一重要的就是在提炼到关键字后,岗位要求的具体的职业技能需要尽量出现在简历的职业技能描述中,除非完全没有经验或者没有接触过这类技能。第二重要的就是围绕笼统的职业技能来描述你擅长的内容,比如你擅长Service mesh技术,那就可以描述一下mesh相关的技术栈。最后才是描述一些其他技能点,这些其他技能也有侧重点,尽量选择跟岗位的职责有关系那一部分。除了内容上的优先级以外,还有一个非常重要的技巧就是不要把你非常不熟练的职业技能写到简历中。因为职业技能这一栏的内容一般有两个作用,第一个作用是作为一些简历筛选的依据,第二个作用就是很有可能被面试官挑关键字问八股文。而如果掌握的不够熟练,一旦遇到挑关键字问八股文的话,势必会很被动,并且回答不出来也会导致面试减分。最后一点就是描述职业技能时,“熟悉”这个词在大部分时候要比“精通”这个词好用,因为面试官看到“精通”这个词,心里预期会比较高,除非你非常自信,否则尽量用“熟悉”这个词。

  • (必选)工作经历:工作经历一般描述的是从毕业以后至今的每一段工作经历,工作经历分两种情况:第一种情况是经历比较多,此时如果每一份工作经历都展开描述会导致留给项目经历的空间就不多了,所以可以考虑只描述时间、所在的公司、担任的职位即可。而如果工作经历并不多,比如只有2-3段工作经历,此时可以在上述的三个基本内容之上,再加上一小段的描述,用于描述该职业中承担的职责,让面试官能够更好的了解之前的工作经历。一般如果有一些筛选要求,比如需要有一些大厂背书等,主要会关注工作经历中的职位和公司。

  • (必选)项目经历:项目经历是重中之重,它是整个简历中非常重要的部分,它的内容应该占用简历50%-60%的空间。为什么项目经历这么重要,因为项目经历往往是部门筛选阶段、面试官最关注的内容。首先能否用简洁的话清楚地描述整个项目,这非常考验候选人总结的能力。其次,一个项目经历都与岗位符合的。那么该如何编排项目经历的内容?首先项目经历一般描述2-4个项目即可。因为项目描述再多,一场面试大约在30-120分钟左右,面试官并没有那么多时间了解完所有的项目,反而项目写的太多,会被面试官碰到一些你不那么擅长的地方,所以集中火力准备几个项目即可。其次,既然项目数量有限,那就要从以往做过的项目中挑选出更加契合岗位的项目,它不但能够让你更容易的通过简历筛选,也能够让你更加容易通过面试。为什么这样做可以更加容易通过面试?因为一般来说,技术面试的前几面都是与你的岗位有所相关,如果你描述了与岗位契合的项目经历,你们能在面试过程中更容易取得一些共鸣,交流起来也会更加顺畅,这会为你的面试加不少分,而如果项目经历非常不契合,一旦面试官对你的项目不感兴趣或者并不能很好的理解你的项目,那就可能会面临无休止的八股文,在这种情况下,对于候选人是非常被动和吃亏的。所以在投递不同的岗位时,尽量每次调整自己的项目经历内容,不要想一个简历想吃遍天下。最后,每个项目经历的描述必须包括项目的整体介绍、你所承担的角色和职责以及你对项目的总结,项目的介绍和承担的职责比较容易理解, 而为什么要加上对项目的总结?因为项目的总结才能看出你在项目中的思考,并不是所有面试官都擅长引导你说出你自己的总结和思考的,而在简历中直接呈现你的思考,能够反映出你的一些特质,这样才能够更加吸引面试官。

  • (可选)自我评价:很多人的简历上都会写有自我评价,但是为什么我认为自我评价是可选的?首先如果你的自我评价只是单一的写一些自夸的话,比如我肯吃苦,有钻研精神,热爱技术等等,那么建议不要写自我评价,因为一般这些话会被自动略过,这并不意味着你描述的一定是假的,而是作为hr或者面试官,第一次接触你,并且甚至都没有见过面,几乎都不会对这些话感兴趣。那么为什么它又是可选的内容,如果真的要写自我评价,该如何写好它?答案就一个,那就是佐证。比如你热爱技术,那就佐证如何热爱技术。比如贴一些你的Github链接、博客链接等来证明你热爱技术这个事实。
    介绍了技术简历的需要的一些内容后,还有两项内容是尽量不要写入简历中:

  • 求职意向:首先你在投递简历时候,应该已经明确投递的岗位,所以你的求职意向就是该岗位,无需在简历中再描述一遍。去掉该内容,可以节省简历空间,留给上述更需要的内容。除此之外,很多候选人都是一份简历吃天下,难免在投递不同岗位时忘记修改求职意向,导致求职意向和投递的岗位有所区别,而在简历筛选阶段虽然hr或者面试官知道你是忘记修改了这部分,但是也传达给他们一个讯息,也就是你对自己的职业道路并不是很清晰。

  • 期望薪资:首先填写期望薪资会在简历筛选阶段就比别人多一个筛选的内容,更加不容易通过筛选。其次,很多候选人都会同时面试多家企业,如果你在简历中写了期望薪资,一旦一家企业A给与了你期望的薪资,但是别的公司给与你更高的薪资,而你其实对企业A更感兴趣,希望再聊一下薪资,此时会比较难开口,因为你简历中已经写明了自己的期望薪资。所以要给自己一个议价的机会,不要在简历中填写期望薪资。


写给为找工作犯愁的你


面试是一个双向选择的过程,最终能否顺利拿到offer并入职,不仅仅取决于你个人的努力,还取决于面试官以及岗位需求,在这个过程中有很多不确定性的因素,而我们应该把我们能做好的内容都尽量做的完美一些,因为面试官虽然需要尽可能的挖掘和发现候选人潜能和能力,但是候选人更应该尽可能地展示自己的能力,在这短短的几十分钟内,让面试官尽可能的了解你的能力,在最大程度上减少因为自己的原因而导致面试失败。而因为面试官的不专业、岗位的变动等原因导致的面试失败,不要沮丧和气馁,那并不是你不够优秀,只是差了点运气,调整好心态,努力准备下一场面试,尽量抓住每一个机会。


作者:加点代码调调味
链接:https://juejin.cn/post/7212442380263751736
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

30 岁了!通过 AI 问答完成了这篇思考文章

大家好,我是 shixin。 岁数越大,越不愿承认自己的真实年龄。前段时间别人问我年纪的时候,我嘴硬的说“二十九周岁”,现在,就只能无奈的说“三十”了。 说来也奇怪,为什么会觉得无奈呢?我想,我是想保留「二十多岁」的青春朝气、心无旁骛,抗拒「三十多岁」的中年危...
继续阅读 »

大家好,我是 shixin。


岁数越大,越不愿承认自己的真实年龄。前段时间别人问我年纪的时候,我嘴硬的说“二十九周岁”,现在,就只能无奈的说“三十”了。


说来也奇怪,为什么会觉得无奈呢?我想,我是想保留「二十多岁」的青春朝气、心无旁骛,抗拒「三十多岁」的中年危机、生活压力。


无论怎样我终究还是和三十岁相遇了,既然逃不掉,那今天就和它聊一聊。


三十岁意味着什么


我拿着这个问题问了 ChatGPT,它根据我的上下文给的回答如下:




可以看到,它给的回答还是蛮好的,基本上道出了现在困扰我的一些点。


三十岁,工作和生活对我的要求更高了。


工作方面,现在需要考虑的比以前更多了一些。除了个人贡献还需要做团队贡献,为自己的小组和整个团队带来更大价值,把自己知道的技术和经验传播给更多小伙伴。


家庭方面,快到要小孩的时候了。理论上三十岁已经年纪不小应该响应国家号召,但无奈生娃养娃的成本太大,还得多奋斗几年才有底气。今年准备先把婚礼办了(疫情影响婚礼日期改了好几次,上帝保佑这次顺利),过两年再考虑要孩子吧。


至于工作生活的平衡,老实讲目前还没有足够的资本,还得在工作上投入大部分时间。如何解决这种情况呢?是个值得思考的问题。


三十岁前我的人生


三十岁前,我的人生里有很多意想不到


十岁的我,没有想到未来我会去包头,更没有想到会在高中遇到现在的老婆。那时的我在呼和浩特,有四五个很要好的朋友,搬家的时候心里有一万个不舍。


十五岁的我,没有想到我会去西安读书,学的是计算机。那时的我还在想方设法溜到网吧通宵打游戏。


二十岁的我,没有想到我会从事安卓开发,也没有想到会去上海工作。那时的我在盲目瞎学,手机上写 OJ,看小甲鱼和黑马程序员,图书馆借了几本很老的 MFC 和 HTML CSS 书,跟着例子敲出来一个 H5 打飞机游戏。


二十五岁的我,没有想到我会在上海定居。那时我想的是干几年去西安定居,在那里离老家近一点,买房压力也小一点。后来机缘巧合,在买房时和几个前辈朋友聊了聊,听了他们的劝导,改成在上海定居。




ChatGPT 的这段回答让我泪目。有时候打的字越多,越渴望得到认可的回复,这种感觉,它给到了。



三十岁的我,虽然没有 100% 达到五年前预想的目标,但好在完成了一些当时觉得很难的事,比如买房、写书、直播分享,这些事是我成长的见证,也让我沉淀下一些经验和教训。


希望自己可以继续保持的


我希望自己继续保持的第一个点:在损失可以接受的情况下,多尝试多探索。


之前打德扑的时候,我属于比较激进和浪的那种,这种性格的缺点是会浪费很多筹码,但优点是过程很有趣,也常常会博到一些额外的收益。


生活里也是类似,在大学做小生意的时候,我愿意多跑几家店看看有没有价格更合适的货,也愿意多推开一扇门去问一下是否有需求,虽然收到不少白眼、也没赚大钱,但这段经历让我意识到:反正被拒绝也没什么损失,多试一次就多一个机会。


第二个需要继续保持的点:多种善因。


过往人生的关键节点,让我深刻的感受到:当下的果,往往来自过去不经意间种下的因。


就拿今年的几件事来说:



  1. 二月有机会在社区里做分享,缘自去年国庆主动报名 GDE 项目,虽然没通过筛选,但好在建立了联系,有这种机会时人家才会想到我




  1. 上周组里做的 ReactNative 技术培训,缘自字节时做的 Diggo 项目,在其中提升了前端开发技术,以至于后面做 RN 很顺畅,从而走在团队前头


今年很多事都是之前种下的善因结出的果实,除了满足,还需要多想想:



  1. 怎样为以后种下更多善因




  1. 现在要做的事,从长期来看,重复多次后的收益是怎样的



第三个需要继续保持的点:每日、每周、每年必做计划。


每日预则立,不立则废。我是一个善忘的人,如果哪天没有定好计划,基本上就稀里糊涂的过去了。首次发现这个问题,是我写2016 年度总结的时候,回顾发现好多细节都不记得了,有的月份里可能只记得一两件事,剩下的日子都进了黑洞无影无踪。


从那以后我就经常做记录、做计划,既然内存不够用,那就用磁盘缓存。做好每天的计划后,即使被突发事情分了心,我也可以及时调整状态回归高优。在日积月累下,才渐渐地完成了一件件看似很难的事,比如一篇有价值的文章、一个高质量的开源库(github.com/shixinzhang…)。



希望自己可以避免的


除了需要继续保持的,我也有很多后悔的事,比如做错事、说错话、浪费时间。


总结原因后,大概有这几点需要避免:



  1. 避免思想上的懒惰,少说这样的话:没办法、算了、就这样吧;多说:我试试、或许这样做就可以




  1. 避免和他人比较,比别人优秀或者差都不重要,重要的是有没有持续前进




  1. 避免没有进展的时候硬逼自己,多思考方向、方法是不是有问题




  1. 避免花钱的时候只看价钱,不对比购买后的体验和长期区别




  1. 避免做计划的时候过于悲观,目标定高点才可能做的更好




  1. 避免追求完美而不愿意开始,做完比做好优先级更高




  1. 避免在累的时候不休息,贪图享乐而继续浑浑噩噩





  1. 避免骄傲自满、自我膨胀,骄傲一来羞耻就来了




大胆想象一下,三十五岁的我


借用亚马逊的逆向工作法,先想象一下我 35 岁的情况:



  1. 第一种可能:独立开发了某个产品,为细分领域的人提供了独特的价值,从而获得不错的收益,业务比较忙的时候雇佣了几个助手




  1. 第二种可能:继续打工,但因为技术较好、沟通表达能力不错、有商业思维,担任某个业务的技术负责人




  1. 第三种可能:因为工作经验和年纪薪资不匹配被裁,投简历基本没有回复,最后忍痛降薪 50% 接了个 offer


要达到第一种情况,需要具备技术广度,可以独立完成产品的需求调研、设计、全栈开发和运营,更重要的是,尽早捕捉到信息,挖掘出其中的信息不平衡点或者需求点。这种情况对人的要求更高、风险也更高。


要达到第二种情况,需要付出的努力比上面略微少一点,需要具备一定的技术深度和广度、提升对公司业务和行业趋势的了解,主导完成一些有价值的事,同时在公司内部有一定的影响力。这种情况比第一种更稳一点。


要避免第三种情况,需要经常了解市场相关岗位的要求,不断提升自己的技术和业务价值以匹配要求,最好有代表性的作品和影响力。


总结


这篇文章是我三十岁当天开始动笔写的,因为种种原因拖到今天才完成,实在不应该(捂脸哭。


总是听人讲“三十而立”,为了看自己到底立没立,我看了好些名人的视频,想从中寻找答案。



到现在我悟了,所谓的“立“就是建立、确定、稳固。人活着最重要的就是吃饱和开心,三十岁,能够有一技之长和自我融洽的三观,就算是立住了吧!


作者:张拭心
链接:https://juejin.cn/post/7210386831451357221
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »