注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

为什么大家都说 SELECT * 效率低?

无论在工作还是面试中,关于SQL中不要用“SELECT *”,都是大家听烂了的问题,虽说听烂了,但普遍理解还是在很浅的层面,并没有多少人去追根究底,探究其原理。 效率低的原因 先看一下最新《阿里java开发手册(泰山版)》中 MySQL 部分描述: 【强制】在...
继续阅读 »

无论在工作还是面试中,关于SQL中不要用“SELECT *”,都是大家听烂了的问题,虽说听烂了,但普遍理解还是在很浅的层面,并没有多少人去追根究底,探究其原理。


效率低的原因


先看一下最新《阿里java开发手册(泰山版)》中 MySQL 部分描述:


【强制】在表查询中,一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明。说明:



  • 增加查询分析器解析成本。

  • 增减字段容易与 resultMap 配置不一致。

  • 无用字段增加网络 消耗,尤其是 text 类型的字段。


开发手册中比较概括的提到了几点原因,让我们深入一些看看:


1. 不需要的列会增加数据传输时间和网络开销



  • 用“SELECT * ”数据库需要解析更多的对象、字段、权限、属性等相关内容,在 SQL 语句复杂,硬解析较多的情况下,会对数据库造成沉重的负担。

  • 增大网络开销;* 有时会误带上如log、IconMD5之类的无用且大文本字段,数据传输size会几何增涨。如果DB和应用程序不在同一台机器,这种开销非常明显

  • 即使 mysql 服务器和客户端是在同一台机器上,使用的协议还是 tcp,通信也是需要额外的时间。


2. 对于无用的大字段,如 varchar、blob、text,会增加 io 操作


准确来说,长度超过 728 字节的时候,会先把超出的数据序列化到另外一个地方,因此读取这条记录会增加一次 io 操作。(MySQL InnoDB)


3. 失去MySQL优化器“覆盖索引”策略优化的可能性


SELECT * 杜绝了覆盖索引的可能性,而基于MySQL优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式。


例如,有一个表为t(a,b,c,d,e,f),其中,a为主键,b列有索引。


那么,在磁盘上有两棵 B+ 树,即聚集索引和辅助索引(包括单列索引、联合索引),分别保存(a,b,c,d,e,f)和(a,b),如果查询条件中where条件可以通过b列的索引过滤掉一部分记录,查询就会先走辅助索引,如果用户只需要a列和b列的数据,直接通过辅助索引就可以知道用户查询的数据。


如果用户使用select *,获取了不需要的数据,则首先通过辅助索引过滤数据,然后再通过聚集索引获取所有的列,这就多了一次b+树查询,速度必然会慢很多。


由于辅助索引的数据比聚集索引少很多,很多情况下,通过辅助索引进行覆盖索引(通过索引就能获取用户需要的所有列),都不需要读磁盘,直接从内存取,而聚集索引很可能数据在磁盘(外存)中(取决于buffer pool的大小和命中率),这种情况下,一个是内存读,一个是磁盘读,速度差异就很显著了,几乎是数量级的差异。


索引知识延申


上面提到了辅助索引,在MySQL中辅助索引包括单列索引、联合索引(多列联合),单列索引就不再赘述了,这里提一下联合索引的作用。


联合索引 (a,b,c)


联合索引 (a,b,c) 实际建立了 (a)、(a,b)、(a,b,c) 三个索引


我们可以将组合索引想成书的一级目录、二级目录、三级目录,如index(a,b,c),相当于a是一级目录,b是一级目录下的二级目录,c是二级目录下的三级目录。要使用某一目录,必须先使用其上级目录,一级目录除外。


image.png


联合索引的优势


1) 减少开销


建一个联合索引 (a,b,c) ,实际相当于建了 (a)、(a,b)、(a,b,c) 三个索引。每多一个索引,都会增加写操作的开销和磁盘空间的开销。对于大量数据的表,使用联合索引会大大的减少开销!


2)覆盖索引


对联合索引 (a,b,c),如果有如下 sql 的,


SELECT a,b,c from table where a='xx' and b = 'xx';

那么 MySQL 可以直接通过遍历索引取得数据,而无需回表,这减少了很多的随机 io 操作。减少 io 操作,特别是随机 io 其实是 DBA 主要的优化策略。所以,在真正的实际应用中,覆盖索引是主要的提升性能的优化手段之一。


3)效率高


索引列多,通过联合索引筛选出的数据越少。比如有 1000W 条数据的表,有如下SQL:


select col1,col2,col3 from table where col1=1 and col2=2 and col3=3;

假设:假设每个条件可以筛选出 10% 的数据。



  • A. 如果只有单列索引,那么通过该索引能筛选出 1000W 10%=100w 条数据,然后再回表从 100w 条数据中找到符合 col2=2 and col3= 3 的数据,然后再排序,再分页,以此类推(递归);

  • B. 如果是(col1,col2,col3)联合索引,通过三列索引筛选出 1000w 10% 10% *10%=1w,效率提升可想而知!


4)索引是建的越多越好吗


答案自然是否定的



  • 数据量小的表不需要建立索引,建立会增加额外的索引开销

  • 不经常引用的列不要建立索引,因为不常用,即使建立了索引也没有多大意义

  • 经常频繁更新的列不要建立索引,因为肯定会影响插入或更新的效率

  • 数据重复且分布平均的字段,因此他建立索引就没有太大的效果(例如性别字段,只有男女,不适合建立索引)

  • 数据变更需要维护索引,意味着索引越多维护成本越高。

  • 更多的索引也需要更多的存储空间

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

Android 关于集成环信点击行为不唤起落地页问题

1、华为推送前台点击行为不唤起落地页问题华为系统级别通知,应用系统特性,华为指定固定跳转页面action(如:刷屏页面、广告页面),可实现前台或后台都唤起落地页{ "async":false, "targets": [ "cs1" ...
继续阅读 »

1、华为推送前台点击行为不唤起落地页问题

华为系统级别通知,应用系统特性,华为指定固定跳转页面action(如:刷屏页面、广告页面),可实现前台或后台都唤起落地页

{
"async":false,
"targets": [
"cs1"
],
"strategy": 2,
"pushMessage": {
"title": "环信",
"content": "你好,欢迎使用环信推送服务",
"sub_title": "环信",
"ext":{
"test":"skip test"
},
"config": {
"clickAction": {
"action": "com.a.b.shot"
}
}
}
}

2、小米vivo离线点击未触发重写方法

vivo 没有点击回调,可在跳转页面获取,vivo指定 activity


注意:小米预定义通知不走onNotificationMessageClicked。指定跳转类型为预定义通知。


不指定,点击回调我们的应用测试正常。可自行重写PushMessageReceiver 验证是否小米sdk 未触发 PushMessageReceiver.onNotificationMessageClicked

指定,小米推送配置指定action,在相应的Activity中可以调用Intent的getSerializableExtra(PushMessageHelper.KEY_MESSAGE)方法得到MiPushMessage对象。


public class MyMipushReceiver extends EMMiMsgReceiver {

@Override
public void onNotificationMessageClicked(Context context, MiPushMessage miPushMessage) {
String extStr = miPushMessage.getContent();
JSONObject extras = new JSONObject(extStr);
if (extras !=null ){
String t = extras.getString("xxxx");
//handle
}
}
}

public class EMMiMsgReceiver extends PushMessageReceiver {
private static final String TAG = "EMMiMsgReceiver";

public void onNotificationMessageClicked(Context context, MiPushMessage message) {
EMLog.i(TAG, "onNotificationMessageClicked is called. " + message.toString());
Intent msgIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
msgIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(msgIntent);
}
}


4、oppo 和魅族的点击能力(是否和华为一样)

经验证行为和华为一样,前台情况下也需要指定跳转页,oppo, 魅族指定 activity

{
"async":false,
"targets": [
"cs1"
],
"strategy": 2,
"pushMessage": {
"title": "公子小白有点黑",
"content": "你好,欢迎使用环信推送服务",
"sub_title": "环信",
"config": {
"clickAction": {
"action": "com.a.b.shot",
"activity": "com.hyphenate.easeim.section.me.activity.AboutHxActivity"
}
}
}
}


5、离线扩展对应问题,设置的内容和获取对应即可。

如设置:"ext":{"test1":"t1", "test2":"t2"},获取如下

public class MainActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
Bundle extras = getIntent().getExtras();
if (extras != null) {
String t = extras.getString("test1");
String f = extras.getString("test2");
//TODO
}
}
}


收起阅读 »

通过官网项目来学习——Jetpack之Startup库

nowinandroid项目作为目前google官方来演示MAD(现代Android开发技术)的示例项目,里面大量依赖运用了jetpack包下的各种库。通过分析学习这些库在项中的实际使用可以帮助我们比直接阅读库的文档来更好的理解和学习。希望通过学习后可以帮助到...
继续阅读 »

nowinandroid项目作为目前google官方来演示MAD(现代Android开发技术)的示例项目,里面大量依赖运用了jetpack包下的各种库。通过分析学习这些库在项中的实际使用可以帮助我们比直接阅读库的文档来更好的理解和学习。希望通过学习后可以帮助到我们能熟练地在我们自己的项目中正确高效的使用到jetpack里面的各种强大库。不废话了,下面进入我们今天的正题——Startup


简单认识一下Startup


image.png


App Startup  |  Android Developers 官网的指南有兴趣可以看看


我们今天不讲原理,你只需知道这个库比之前用多个content provider去实现初始化更高效,更精确,更显性,也就是说能合并content provider提升app的启动速度,能准确的控制初始化顺序,能清晰的从代码知道依赖关系。仅仅这些可能jym会说,我们项目不在乎那点启动速度的提升,也没有很多三方库需要走初始化等,根本用不到这个库。是的,我之前也是这么理解的,但是通过nowinandroid项目发现,有些jetpack内的其他库的初始化现在也交给Startup来完成了,这一点就很重要了。意味着我们可以少写很多样板代码,少写也意味着少犯错。所以我觉的还是有必要单独写一篇文章来说说Startup


编写初始化的代码步骤很简单主要就分3步:



  1. 定义实现Initializer接口的实现类

  2. 配置manifest

  3. 自动或手动调用初始化操作


OK了!就这简单3步,下面我们结合项目例子来看


项目代码



  • 先看第一步


object Sync {
// This method is a workaround to manually initialize the sync process instead of relying on
// automatic initialization with Androidx Startup. It is called from the app module's
// Application.onCreate() and should be only done once.
fun initialize(context: Context) {
AppInitializer.getInstance(context)
.initializeComponent(SyncInitializer::class.java)
}
}

internal const val SyncWorkName = "SyncWorkName"

/**
* Registers work to sync the data layer periodically on app startup.
*/
class SyncInitializer : Initializer<Sync> {
override fun create(context: Context): Sync {
WorkManager.getInstance(context).apply {
// Run sync on app startup and ensure only one sync worker runs at any time
enqueueUniqueWork(
SyncWorkName,
ExistingWorkPolicy.KEEP,
SyncWorker.startUpSyncWork(),
)
}

return Sync
}

override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(WorkManagerInitializer::class.java)
}

定一个SyncInitializer类实现了泛型为SyncInitializer接口。需要重写接口定义的两个方法:



  •  create() 方法, 它包含初始化组件所需的所有操作,并返回一个Sync的实例.

  •  dependencies() 方法, 返回当前初始化器需要依赖的其他初始化器集合,我们可以用这个方法来变相的实现各个初始化器的执行顺序。


所以在create方法里面的执行WorkManager.getInstance(context)方法是安全的。我们这篇只关注Startup所以我们只用知道在这个地方WorkManager做了些事情就行,后面会另开一篇单独讲WorkManager。为啥是安全的呢?因为在dependencies方法里面先执行了WorkManagerInitializer::class.java初始化。我们再来看看这个类。


public final class WorkManagerInitializer implements Initializer<WorkManager> {

private static final String TAG = Logger.tagWithPrefix("WrkMgrInitializer");

@NonNull
@Override
public WorkManager create(@NonNull Context context) {
// Initialize WorkManager with the default configuration.
Logger.get().debug(TAG, "Initializing WorkManager with default configuration.");
//这个地方已经完成了单例的构建,后面再调用WorkManager.getInstance(context)获取实例,否则报错
WorkManager.initialize(context, new Configuration.Builder().build());
return WorkManager.getInstance(context);
}

@NonNull
@Override
public List<Class<? extends androidx.startup.Initializer<?>>> dependencies() {
//这里WorkManager的初始化不需要其他初始化构造器,所以返回的是个空集合
return Collections.emptyList();
}
}

以上我们就把第一步走完了,现在再来看第二步



  • 再看第二步


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- TODO: b/2173216 Disable auto sync startup till it works well with instrumented tests -->
<meta-data
android:name="com.google.samples.apps.nowinandroid.sync.initializers.SyncInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>

</application>

</manifest>

这里需要注意的是tools:node="remove",在provider层级用的话是全局取消自动初始化,在meta-data层级用的话是单个组件取消自动初始化。例子展示的是单个组件取消自动初始化。另外注意的一点是被依赖的初始化组件是不需要再另外在manifest里面声明的,这就是为什么WorkManagerInitializer没有声明。



  • 最后一步


@HiltAndroidApp 
class NiaApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
// Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this)
}

/**
* Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this
* format. During Coil's initialization it will call `applicationContext.newImageLoader()` to
* obtain an ImageLoader.
*
* @see <a href="https://github.com/coil-kt/coil/blob/main/coil-singleton/src/main/java/coil/Coil.kt">Coil</a>
*/
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(SvgDecoder.Factory())
}
.build()
}
}

上面的代码是app的Application,我们今天的重点是Startup,所以我们先不管其他的。只用看onCreate下的Sync.initialize(context = this)方法。


object Sync {
// This method is a workaround to manually initialize the sync process instead of relying on
// automatic initialization with Androidx Startup. It is called from the app module's
// Application.onCreate() and should be only done once.
fun initialize(context: Context) {
AppInitializer.getInstance(context)
.initializeComponent(SyncInitializer::class.java)
}
}

AppInitializer.getInstance(context).initializeComponent(SyncInitializer::class.java)传入SyncInitializer类,实现手动初始化完成。



以上就是nowinandroid项目对Startup库的使用,并且上面我们也知道了我们自定义的初始化器在初始化的时候通过WorkManager做了些事情。那么下篇我们还是通过这个例子来看看nowinandroid是怎么使用WorkManager这个库的。



水平有限,写作不易。各位jym高抬贵手点个赞留个言都是对我最大的鼓励


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

悲剧!Google华裔“网红”炫耀公司福利,突然被裁了!

2023年开年,全球科技企业的裁员人数已经超过了9万人。 图源网络 版权属于原作者开年裁员最狠的莫过于亚马逊、谷歌、微软等老牌科技大厂。亚马逊和微软同一天1月18日裁员,人数分别是1.8万和1万,被网友们戏称为“西雅图不眠夜”。谷歌紧随其后,在中国春节前,1月...
继续阅读 »

2023年开年,全球科技企业的裁员人数已经超过了9万人。


图源网络 版权属于原作者

开年裁员最狠的莫过于亚马逊、谷歌、微软等老牌科技大厂。

亚马逊和微软同一天1月18日裁员,人数分别是1.8万和1万,被网友们戏称为“西雅图不眠夜”。

谷歌紧随其后,在中国春节前,1月20日凌晨2点左右宣布裁员1.2万。

这场轰轰烈烈的裁员影响了不少人。特别是春节前,很多华人被裁就像是晴天霹雳。

随着裁员潮,员工无限福利时代似乎也一去不复返了...

科技大厂的各种福利总是令人艳羡。最近几年,越来越多员工开始在社交媒体上分享自己“在科技大厂工作的一天”,这类主题也通常能获得不错的点击量。

于是,一批批大厂“网红”就这样出现了,他们热衷于在网上展现公司的办公环境、福利待遇、工作气氛...

Google华裔女员工晒公司福利,突然被裁

Nicole Tsai 是一位在谷歌洛杉矶办事处工作的TikToker ,她经常使用tik tok分享她作为谷歌员工的生活和工作场景。

她分享的照片中,很多奢华的办公场景着实吸引了不少羡煞的目光,也收获了数万名粉丝!

她曾展示过谷歌的主题会议室、免费的午餐和happy hour的酒吧小酌。


图源于网络 版权属于原作者


图源于网络 版权属于原作者

然而在这次谷歌的12000人裁员大潮中,Nicole Tsai 没能幸免。

她发布了一条视频,标题为“A Day In My Life Getting Laid Off At Google”。

Nicole Tsai 表示,她醒来时看到上级发来的消息,她意识到了事情的不对,她迅速冲下楼,发现无法访问工作中的任何东西。无法登录电子邮件,甚至无法查看她的日历。

她意识到,她被裁员了。


图源于网络 版权属于原作者

Nicole Tsai 表示,她觉得这场浩荡的裁员就像是一场俄罗斯轮盘赌,完全是随机的。

她不知道接下来会发生什么,但她还会选择在社交媒体上和大家分享她裁员之后的生活。

值得一提的是,外国网友似乎并没有对Nicole Tsai表示同情。

“这些几乎没有技能的人不仅有工作,工资还高得离谱。随着利率的上升和印钞机的停止,派对结束了!”


“这些人据称是最聪明的人,但没有意识到在发布的视频中,自己表现的更像是在度假,而不是在工作,这不会有好结果。”


“没有人是无可替代的。”


“他们期望发生什么?我对他们没有同情心。他们中的大多数人在社交媒体上疯狂炫耀福利、高薪和假期。希望他们现在依旧能保持同样的精力,因为财富列车已经结束,他们已经失业了。”


身边遭遇裁员的小伙伴...

被裁员后大家面对的处境也大不相同,有些同学已经有绿卡或者身份问题无忧,找下一份工作的压力会小一些。

但是有一些小伙伴被裁即面临着身份问题,再加上自己的个人情况,要面对的压力也变得巨大。

有个小伙伴发帖分享了自己的情况:


图源一亩三分地 版权属于原作者

楼主身份今年10月就到期了,也没钱再去读书了,加上与家人的关系不好。她感觉自己仿佛一叶孤舟…

在除夕夜,面对这次裁员的楼主显得格外落寞。

在地里很多看了帖子的暖心小伙伴送上了自己的祝福,还帮助楼主分析如何面对未来的方法。

有网友说:我们都会在磨砺中成长变得更强大,请楼主相信塞翁失马焉知非福,您一定会找到走下去的路的!

作者:一亩三分地
来源:mp.weixin.qq.com/s/ZYkGk2c6WIKrUcY96RfDhw

收起阅读 »

记一次浏览器播放实时监控rtsp视频流的解决历程(利用Ffmpeg + node.js + websocket + flv.js实现)

web
背景笔者目前在做一个智慧楼宇的产品(使用react开发的),在交付项目的时候,遇到了需要在浏览器端播放rtsp视频流的场景,而浏览器端是不能直接播放rtsp视频流的,原本打算让客户方提供flv格式的视频流 鉴于项目现场的环境以及种种原因,客户方只能提供rtsp...
继续阅读 »

背景

笔者目前在做一个智慧楼宇的产品(使用react开发的),在交付项目的时候,遇到了需要在浏览器端播放rtsp视频流的场景,而浏览器端是不能直接播放rtsp视频流的,原本打算让客户方提供flv格式的视频流 鉴于项目现场的环境以及种种原因,客户方只能提供rtsp视频流,所以就只能自己解决了。

于是乎,去网上随便一搜就搜到了Ffmpeg + node.js + websocket + flv.js的解决方案,但是真正自己实现下来,遇到了几个棘手的问题,例如:莫名其妙的报错,部分监控视频转换失败,不能转码h265格式的视频等等(本文会介绍自己遇到的问题以及解决方案)。

涉及到的技术点

  • ffmpeg:ffmpeg是一个转码工具,将rtsp视频里转换成flv格式的视频流

  • node.js

  • websocket

  • flv.js

node.js端

点击进入node端的github

用到的关键库

  • @ffmpeg-installer/ffmpeg

自动为当前node服务所在的平台安装适合的ffmpeg,无需自己再去手动下载、安装配置了。通过该库安装的ffmpeg,其路径在node_modules/@ffmpeg-installer/darwin-x64/ffmpeg (我用的是mac,自动安装的是darwin-x64,不同平台不一样)

  • fluent-ffmpeg

该库是对ffmpeg 命令的封装,简化了命令的使用流程,原生ffmpeg的命令是比较复杂难懂的。

点击进入fluent-ffmpeg的github地址

完整可复制直接运行的node代码

const ffmpegPath = require('@ffmpeg-installer/ffmpeg'); // 自动为当前node服务所在的系统安装ffmpeg
const ffmpeg = require('fluent-ffmpeg');
const express = require('express');
const webSocketStream = require('websocket-stream/stream');
const expressWebSocket = require('express-ws');

ffmpeg.setFfmpegPath(ffmpegPath.path);

/**
* 创建一个后端服务
*/
function createServer() {
   const app = express();
   app.use(express.static(__dirname));
   expressWebSocket(app, null, {
       perMessageDeflate: true
  });
   app.ws('/rtsp/', rtspToFlvHandle);

   app.get('/', (req, response) => {
       response.send('当你看到这个页面的时候说明rtsp流媒体服务正常启动中......');
  });

   app.listen(8100, () => {
       console.log('转换rtsp流媒体服务启动了,服务端口号为8100');
  });
}

/**
* rtsp 转换 flv 的处理函数
* @param ws
* @param req
*/
function rtspToFlvHandle(ws, req) {
   const stream = webSocketStream(ws, {
       binary: true,
       browserBufferTimeout: 1000000
  }, {
       browserBufferTimeout: 1000000
  });
   // const url = req.query.url;
   const url = new Buffer(req.query.url, 'base64').toString(); // 前端对rtsp url进行了base64编码,此处进行解码
   console.log('rtsp url:', url);
   try {
       ffmpeg(url)
          .addInputOption(
               '-rtsp_transport', 'tcp',
               '-buffer_size', '102400'
          )
          .on('start', (commandLine) => {
               // commandLine 是完整的ffmpeg命令
               console.log(commandLine, '转码 开始');
          })
          .on('codecData', function (data) {
               console.log(data, '转码中......');
          })
          .on('progress', function (progress) {
               // console.log(progress,'转码进度')
          })
          .on('error', function (err, a, b) {
               console.log(url, '转码 错误: ', err.message);
               console.log('输入错误', a);
               console.log('输出错误', b);
          })
          .on('end', function () {
               console.log(url, '转码 结束!');
          })
          .addOutputOption(
               '-threads', '4',  // 一些降低延迟的配置参数
               '-tune', 'zerolatency',
               '-preset', 'ultrafast'
          )
          .outputFormat('flv') // 转换为flv格式
          .videoCodec('libx264') // ffmpeg无法直接将h265转换为flv的,故需要先将h265转换为h264,然后再转换为flv
          .withSize('50%') // 转换之后的视频分辨率原来的50%, 如果转换出来的视频仍然延迟高,可按照文档上面的描述,自行降低分辨率
          .noAudio() // 去除声音
          .pipe(stream);
  } catch (error) {
       console.log('抛出异常', error);
  }
}

createServer();

react 前端

用到的关键库

  • flv.js

用于前端播放flv格式视频库

完整可直接复制使用的react组件

import React, { useEffect, useRef } from 'react';
import './FlvVideoPlayer.scss';
import flvjs from 'flv.js';
import { Button } from '@alifd/next';

interface FlvVideoPlayerProps {
 url?: string; // rtsp 的url
 isNeedControl?: boolean;
 fullScreenRef?: any; // 方便组件外部调用全屏方法的ref
}

const FlvVideoPlayer = React.forwardRef<any, FlvVideoPlayerProps>(({ isNeedControl, url, fullScreenRef }, ref) => {
 const videoDomRef = useRef<any>();
 const playerRef = useRef<any>(); // 储存player的实例

 React.useImperativeHandle(ref, () => ({
   requestFullscreen,
}));

 useEffect(() => {
   if (videoDomRef.current) {
     if (fullScreenRef) {
       fullScreenRef.current[url] = requestFullscreen;
    }
     // const url = `${videoUrl}/rtsp/video1/?url=${url}`;
     playerRef.current = flvjs.createPlayer({
       type: 'flv',
       isLive: true,
       url,
    });
     playerRef.current.attachMediaElement(videoDomRef.current);
     try {
       playerRef.current.load();
       playerRef.current.play();
    } catch (error) {
       console.log(error);
    }
  }

   return () => {
     destroy();
  };
}, [url]);

 /**
  * 全屏方法
  */
 const requestFullscreen = () => {
   if (videoDomRef.current) {
    (videoDomRef.current.requestFullscreen && videoDomRef.current.requestFullscreen()) ||
      (videoDomRef.current.webkitRequestFullScreen && videoDomRef.current.webkitRequestFullScreen()) ||
      (videoDomRef.current.mozRequestFullScreen && videoDomRef.current.mozRequestFullScreen()) ||
      (videoDomRef.current.msRequestFullscreen && videoDomRef.current.msRequestFullscreen());
  }
};

 /**
  * 销毁flv的实例
  */
 const destroy = () => {
   if (playerRef.current) {
     playerRef.current.pause();
     playerRef.current.unload();
     playerRef.current.detachMediaElement();
     playerRef.current.destroy();
     playerRef.current = null;
  }
};

 return (
   <>
     <Button type="primary" onClick={requestFullscreen}>
       全屏按钮
     </Button>
     <video controls={isNeedControl} ref={videoDomRef} className="FlvVideoPlayer" loop />
   </>
);
});

export default FlvVideoPlayer;

组件用到的url

  • 本地开发时

本地开发时,node服务是启动在自己电脑上,所以node服务的地址就是 ws://127.0.0.1:8100,为了防止在传rtsp地址的过程中出现参数丢失的情况,故采用window.btoa()方法对rtsp进行base64编码一下,又由于node端代码中监听的是/rtsp/,故完整的组件的url是

ws://127.0.0.1:8100/rtsp?url=${window.btoa('rtsp地址')}
  • 部署线上

直接将服务器ip替换掉127.0.0.1即可

  • 提供一个测试的rtsp地址

rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mp4

遇到的问题

1. rtsp地址中存在?拼接的参数,传到后端丢失

  • 错误详情

An error occured: ffmpeg exited with code 1: rtsp://... Server returned 404 Not Found
  • 原因

完整的url是ws://127.0.0.1:8100/rtsp?url=${window.btoa('rtsp地址')},如果rtsp地址中再含有?拼接参数的话,那么就会出现两个?,传到node端之后,会被express去除掉rtsp地址中的?

  • 解决方式

在前端对rtsp使用window.btoa方法进行base64编码,在node端使用new Buffer进行解码即可

2. 连接超时

  • 报错截图

  • 原因

部署到客户内网发现的,是两台服务网络不通造成的

  • 解决方式

找运维解决

3. CPU飚到100%,卡顿

  • 错误详情

监控视频采用分页显示,每页8个监控视频,切换到下一页的时候,上一页转换监控视频的ffmpeg进程,仍然存在,没有被kill掉。所以ffmpeg的进程不停地增加,导致CPU占用100%

  • 原因

封装flvjs 的react组件中,在组件卸载的时候,没有把flvjs的实例销毁掉,导致进程不会被自动kill掉

  • 解决方式

组件卸载的时候,将flvjs的实例销毁掉

4. 不能转码h265视频流

  • 错误详情

Video codec hevc not compatible with flv 。Could not write header for output file #0 (incorrect codec parameters ?): Function not implemented
  • 原因

有些监控摄像头的视频格式是 hevc h265, flv不支持,需要先将h265转化至h264格式

  • 解决方式

node端代码中。ffmpeg添加 videoCodec('libx264') 配置即可

优化 ffmpeg 低延迟配置参数

'-threads', '4'
'-tune', 'zerolatency'
'-preset', 'ultrafast'

更新

当我把ffmpeg配置参数中的输出分辨率配置移除后,目前的延时在1~2s左右

作者:huisiyu
来源:juejin.cn/post/7124188097617051685

收起阅读 »

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileServ...
继续阅读 »


我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileService的方案实现。

TileService继承自Service,所以它也是Android的四大组件之一,不过它是一个特殊的组件,开发者不需要手动开启调用,系统可以自动识别并完成调用,系统会通过绑定服务(bindService)的方式调用。

创建使用:

快捷开关是Android 7(target 24)的新能力,因此在使用该能力前必须先判断版本大小(大于等于target 24)。

1、自定义一个TileService类。

class MyQSTileService: TileService() {
 override fun onTileAdded() {    
     super.onTileAdded()  
}
 
 override fun onStartListening() {    
     super.onStartListening()  
}
 
 override fun onStopListening() {    
     super.onStopListening()  
}
 
 override fun onClick() {    
     super.onClick()  
}
 
 override fun onTileRemoved() {    
     super.onTileRemoved()  
}
}

TileService是通过绑定服务(bindService)的方式被调用的,因此,绑定服务生命周期包含的四种典型的回调方法(onCreate()、onBind()、onUnbind()和 onDestroy())都会被调用。但是,TileService也包含了以下特殊的生命周期回调方法:

  • onTileAdded():当用户从编辑栏添加快捷开关到通知栏的快速设置中会调用。

  • onTileRemoved():当用户从通知栏的快速设置移除快捷开关时调用。

  • onClick():当用户点击快捷开关时调用。

  • onStartListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileAdded添加之后会调用一次。

  • onStopListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileRemoved移除之前会调用一次。

2、在应用程序的清单文件中声明TileService

<service
    android:name=".MyQSTileService"
    android:label="@string/my_default_tile_label"  
    android:icon="@drawable/my_default_icon_label"
    android:exported="true"
    android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
    <intent-filter>
        <action android:name="android.service.quicksettings.action.QS_TILE" />
    </intent-filter>
</service>
  • name:自定义的TileService的类名。

  • label:快捷开关在通知栏上显示的名称。

  • icon:快捷开关在通知栏上显示的图标。

  • exported:该服务能否被外部应用调用。该属性必须为true。如果为false,那么快捷开关的功能将失效,原因是exported="false"时,TileService将不支持外部应用调起,手机系统自然不能再和该快捷开关交互。必须配置。

  • permission:需要给service配置的权限,BIND_QUICK_SETTINGS_TILE即允许应用程序绑定到第三方快速设置。必须配置。

  • intent-filter:意图过滤器,只有匹配内部的action,才能调起该service。必须配置。

监听模式

TileService的监听模式(或理解为启动模式)有两种,一种是主动模式,另一种是标准模式。

  • 主动模式

在主动模式下,TileService被请求时该服务会被绑定,并且TileService的onStartListening也会被调用。该模式需要在AndroidManifeast清单文件中声明:

<service ...>
  <meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
        android:value="true" />
  ...
</service>

通过TileService.requestListeningState()这一静态方法,就可以实现对TileService的请求,示例如下:

      TileService.requestListeningState(
           applicationContext, ComponentName(
               BuildConfig.APPLICATION_ID,
               MyQSTileService::class.java.name
          )
      )

主动模式下值得注意的是:

  • 用户在通知栏快速设置的地方点击快捷开关时,TileService会自动完成绑定、TileService的onStartListening会被调用。

  • TileService无论是通过点击被绑定还是通过requestListeningState请求被绑定,TileService所在的进程都会被调起。

标准模式

在标准模式下,TileService可见时(即用户下拉通知栏看见快捷开关)该服务会被绑定,并且TileService的onStartListening也会被调用。标准模式不需要在AndroidManifeast清单文件中进行额外的声明,默认就是标准模式。

标准模式下值得注意的是:

  • 和主动模式相同,TileService被绑定时,TileService所在的进程就会被调起。

  • 而和主动模式不同的是,标准模式绑定TileService是通过用户下拉通知栏实现的,这意味着TileService所在的进程会被多次调起。因此为了避免主进程被频繁调起、避免DAU等数据统计受到影响,我们还需要为TileService指定一个特定的子进程,在Androidmanifest清单文件中设置:

      <service
          ......
          android:process="自定义子进程的名称">
          ......
      </service>

更新快捷开关

如果需要对快捷开关的数据进行更新,可以通过getQsTile()获取快捷开关的对象,然后通过setIcon(更新icon)、setLable(更新名称)、setState(更新状态,包括STATE_ACTIVE——表示开启或启用状态、STATE_INACTIVE——表示关闭或暂停状态、STATE_UNAVAILABLE:表示暂时不可用状态,在此状态下,用户无法与您的磁贴交互)等方法设置快捷开关新的数据,最后调用updateTile()方法实现。

  override fun onStartListening() {
  super.onStartListening()
  if (qsTile.state === Tile.STATE_ACTIVE) {
      qsTile.label = "inactive"
      qsTile.icon = Icon.createWithResource(context, R.drawable.inactive)
      qsTile.state = Tile.STATE_INACTIVE
  } else {
      qsTile.label = "active"
      qsTile.icon = Icon.createWithResource(context, R.drawable.active)
      qsTile.state = Tile.STATE_ACTIVE
  }
  qsTile.updateTile()
}

操作快捷开关

  • 如果想要实现点击快捷开关时、关闭通知栏并跳转到某个页面,可以调用以下方法:

startActivityAndCollapse(Intent intent)
  • 如果想要在点击快捷开关时弹出对话框进行交互,可以调用以下方法:

override fun onClick() {
   super.onClick()
   if(!isLocked()) {
       showDialog()
  }
}

因为快捷开关有可能在用户锁屏时出现,所以必须加上isLocked()的判断。只有非锁屏的情况下,对话框才会出现。

  • 如果快捷开关含有敏感信息,需要使用isSecure()进行设备安全性判断,当设备安全时,才能执行快捷开关相关的逻辑(如点击的逻辑)。当设备不安全时(手机处于锁屏状态时),可调用unlockAndRun(Runnable runnable),提示用户解锁屏幕并执行自定义的runnable操作。

以上是通知栏增加快捷开关的全部介绍。欢迎关注公众号度熊君,一起分享交流。

作者:度熊君
来源:juejin.cn/post/7190663063631036473

收起阅读 »

通过官方项目来学习——枚举、密封类、密封接口的区别和使用场景

相信点进来愿意看这篇文章的jym都用过密封类、枚举类甚至也已经用到了密封接口,但是多多少少可能还是有点迷惑。写这篇文章就是希望能跟大家一起梳理这三类的区别,搞清楚在哪种情况用哪种实现最合适。 从java到kotlin,大家肯定对枚举已经比较熟悉了,这里就不专门...
继续阅读 »

相信点进来愿意看这篇文章的jym都用过密封类、枚举类甚至也已经用到了密封接口,但是多多少少可能还是有点迷惑。写这篇文章就是希望能跟大家一起梳理这三类的区别,搞清楚在哪种情况用哪种实现最合适。


从java到kotlin,大家肯定对枚举已经比较熟悉了,这里就不专门说它了。但是密封类和密封接口的概念最先是在kotlin上实现的,java之前是没有的。那么我们可能会想,为什么在已经有枚举的情况下还要新增密封类和密封接口这两个新的概念出来呢?这三点又有什么不同?什么情况我们应该用他们呢?如果你没有一个清晰的答案,那么请带着这三个问题请继续看下去


下面用nowinandroid项目内的代码来当个例子


密封类


sealed class Icon {
data class ImageVectorIcon(val imageVector: ImageVector) : Icon()
data class DrawableResourceIcon(@DrawableRes val id: Int) : Icon()
}

这个密封类很简单。里面就封了两个数据类,两个类的参数类型不同。根据命名不难看出,Icon密封类是提供Icon给ui用的,ImageVectorIcon通过提供ImageVector实现icon显示,DrawableResourceIcon提供资源id来实现icon显示。那么很容易想象出来后面的使用场景无非就是通过一个类型判断,来执行对应条件下的加载就完了。这里大家只需留意一点,就是这个密封类我们是没有做任何初始化操作的。


枚举类


enum class TopLevelDestination(
val selectedIcon: Icon,
val unselectedIcon: Icon,
val iconTextId: Int,
val titleTextId: Int,
) {
FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you,
titleTextId = R.string.app_name,
),
BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved,
),
INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
iconTextId = interestsR.string.interests,
titleTextId = interestsR.string.interests,
),
}

现在我们来简单看一眼这个枚举类。4个参数组成了构造参数,前两个参数的类型是上面定义的Icon密封类。然后定义了三个值For_You,BOOKMARKS,INTERESTS。这里需要注意了,这三个值的参数都并必须完成初始化提供实例才行,而前面定义的密封类并不需要。那么我们发现了一个枚举类跟密封类的区别了。这个区别我个人觉得也是枚举类和密封类最大的一个区别-复杂度。这里的枚举类的使用场景是给app的底部导航栏用的,我们知道一般导航栏需要的东西很简单数量也不多,一般都是一个选中时的icon,未选中时的icon,一个标题就完了,所以很简单一点都不复杂。后期我们要添加多少个新的也都是这个模版,很方便我们统一维护。但是这里的Icon密封类就相对要复杂灵活点了,首先可以实现提供显示icon的方法有还很多种,我们不太可能一次把所有的方式都写进去,所以我们可以通过Icon密封类随时扩展,再则这个icon可能是会经常更换的(比方说版本更新,配合活动动态更换等),那么我们这个资源肯定就不能写死了,也就是不推荐用枚举去实现,不然换一次icon就要新建一个新值,维护起来麻烦也不优雅。那么这个时候的密封类就又起到作用了,我们只用替换原有枚举类初始化的资源就行了。



小结一下:简单稳定的用枚举,复杂灵活的用密封



密封接口


密封接口这个概念并不是跟密封类一同出现的。是先有的密封类后面高版本kotlin才出现的密封接口。刚出来的时候,我也不懂为啥还要特意再整个密封接口出来,正好在掘金上看到了大佬写的一篇很好的文章介绍了密封接口和密封类的区别,这里我就不再复述了,建议大家直接点链接去学习 Kotlin 1.5 新特性:密封接口比密封类强在哪? - 掘金 (juejin.cn)。下面还是提供一下nowinandroid项目里面部分用到密封接口地方的代码。


package com.google.samples.apps.nowinandroid.core.result

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart

sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null) : Result<Nothing>
object Loading : Result<Nothing>
}


用来封装网络请求返回的几种情况,可读性强于密封类实现



sealed interface NewsFeedUiState {
/**
* The feed is still loading.
*/
object Loading : NewsFeedUiState

/**
* The feed is loaded with the given list of news resources.
*/
data class Success(
/**
* The list of news resources contained in this feed.
*/
val feed: List<UserNewsResource>,
) : NewsFeedUiState
}


封装页面加载的情况,可读性强于密封类实现


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

ARouter ksp注解处理器 实现思路

注解处理器到底承担了什么作用? 考虑清楚这个问题 对我们的ksp实现 会有非常大的帮助, 否则那一坨注解处理器的实现 你是根本不知道为什么要那样实现,自然ksp的实现你是无从下手的。 首先我们来看一个典型的例子, 我们有2个module 一个叫 moduleA...
继续阅读 »

注解处理器到底承担了什么作用?


考虑清楚这个问题 对我们的ksp实现 会有非常大的帮助, 否则那一坨注解处理器的实现 你是根本不知道为什么要那样实现,自然ksp的实现你是无从下手的。


首先我们来看一个典型的例子, 我们有2个module 一个叫 moduleA 一个叫moduleB , moduleA中的类 想跳转到moudleB中的类 要怎么做? 一般都是


startActivity(B.class)


这就会带来一个问题了, moduleA 要引用到moduleB 中的类, 你只能让moduleA依赖moduleB, 那如果反过来呢?moduleB还得依赖moduleA了, 这种相互间的引用 肯定是不行的了,那应该怎么做?


就是参考Arouter的做法就可以了, 例如moduleB中的B.class 我想对外暴露 ,让别的module中可以跳转到我这个activity ,那就我在B.class中 加一个注解


例如:
image.png


这是大家最熟悉的代码了,那么关键的地方就在于, 我的注解处理器 到底要做什么? 要生成什么样的类,来达成我们最终的目的。


本质上来说,我们一个apk,下面肯定有多个module, 不管他们的依赖关系如何,他的编译关系都是确定的,这句话怎么理解?


moduleA 编译以后生成一堆class文件,moduleB 编译以后也生成一堆class文件, 等等。 最终这些class文件
都是在我们的app moudle 编译时汇总的, 思考明白这个问题 那就好理解了,


回到前面的例子中,我们在moduleB中 加了注解,然后我们可以利用注解处理器 来生成一个类,这个类中维护一个map,这个map的key就是 我们注解中的path的字符串之,value 则是本身我们B.class


这样多个module 在app module 中汇总编译的时候 我们就可以拿到一个巨大的map 这个map中key就是path的值,value 就是目标的class


之后跳转的时候只要在navigation中传递一下path的值,然后根据再到map中寻找到对应的class就可以了。


你看,arouter的注解处理器 是干啥的,我们就想清楚了吧,就是生成一堆辅助类,这个辅助类的最终目的就是帮我们生成path 和class的对应关系的


理想和现实中的差别


在上一个小节中,我们基本弄清楚了arouter 注解处理器的作用, 但是仅靠这一节的知识要完全看懂arouter-compiler的代码还是不够, 因为实际上arouter 的map生成要比 我们前面一个小节 所说的要复杂的多。为什么?


你仔细思考一下, 如果是多个module 都使用了route注解,那这些注解的类中的path的值 是不是有可能是重复的?


比如moduleB中 有一个类叫X.class 他的path是 /x1/xc
moduleC中 有一个类叫Y.class 他的 path值也是 /x1/xc


这就会导致一个问题了,在app 编译的时候 同样一个path 会对应着2个class,此时跳转就会出现错误了。


我们来看下,Arouter中 是如何设计来解决这个问题的 他首先引入了一个Group的概念, 比如我们上面的path
x1 就是group, 当然你也可以手动指定group ,但是意思都是一样的


首先生成一个名为


Arouter$$Root$$moduleName

的类,这个类继承的是IRouteRoot这个接口


这里我们要关注的是moduleName ,我们在用annotaionProcessor 或者kapt 或者ksp的 这三种注解处理器的时候 都要传递一个参数的


image.png


image.png


然后再关注下 loadInto 这个方法


这个方法一看就是生成了一个map 对吧, 这个map的key 就是 group的值,而value则是注解处理器生成的一个类 实现了IRouteGroup接口


Arouter$$Group$$group的值

我们来看一下这个类里面干了啥


image.png


这个类也有一个loadInfo 方法


它的key 就是path的值, value 就是RouteMeta对象,注意这个对象中就具体包含了Activity.class了,


所以Arouter 实际上就是把我们的map给分了级,


首先是利用 moduleName 来生成 IRouteRoot的类 ,这样可以规避不同module之间有冲突的现象
其次是利用 group的概念 再次对路由进行分层, 这样一方面是降低冲突几率,另外一方面,利用group的概念,我们还可以做路由的懒加载,毕竟项目大了以后 一次性加载全部路由信息也是有成本的,有了group的概念,


我们就可以按照group的级别来加载了,实际上arouter本身路由加载也是这样做的。


路由利用group分组以后, 默认任何实际路由信息都不会加载, 当每次调用者发起一次路由加载事件时,都会按照group的信息来查找,第一次触发某个group 时,再去加载这个group下面的所有路由信息


ksp的基础实现


首先我们新建一个module ,命名大家随意,注意这个module的build 文件写法即可


apply plugin: 'java'
apply plugin: 'kotlin'

compileJava {
sourceCompatibility = '1.8'
targetCompatibility = '1.8'
}

sourceSets.main {
java.srcDirs("src/main/java")
}

dependencies {
implementation 'com.alibaba:arouter-annotation:1.0.6'
implementation("com.squareup:kotlinpoet:1.11.0")
implementation("com.squareup:kotlinpoet-ksp:1.11.0")
implementation("com.squareup:kotlinpoet-metadata:1.11.0")
implementation 'com.alibaba:fastjson:1.2.69'
implementation 'org.apache.commons:commons-lang3:3.5'
implementation 'org.apache.commons:commons-collections4:4.1'
implementation("com.google.devtools.ksp:symbol-processing-api:1.6.20-1.0.5")

}

apply from: rootProject.file('gradle/publish.gradle')

其次,去meta-inf 下 新建一个文件,文件名是固定的


com.google.devtools.ksp.processing.SymbolProcessorProvider


image.png


里面的内容就简单了,把我们的ksp注解处理器配置进去即可


com.alibaba.android.arouter.compiler.processor.RouteSymbolProcessorProvider
com.alibaba.android.arouter.compiler.processor.InterceptorSymbolProcessorProvider
com.alibaba.android.arouter.compiler.processor.AutowiredSymbolProcessorProvider

这里要注意一下,即使是一个纯java代码的module 也可以使用ksp来生成代码的


注解处理器如何debug?


注解处理器的代码其实还挺晦涩难懂的,全靠日志打印很麻烦,这里还是会debug 比较好


image.png


image.png


稍微配置一下即可, 然后打上断点,按下debug开关,rebuild 工程即可触发注解处理器的调试了


使用ksp 注解处理器来生成辅助类


这里篇幅有限, 我们只做辅助类的生成, 至于辅助类里面的loadInto方法 我们暂不做实现,具体的实现我们留到下一篇文章再说,这一节只做一下 辅助类生成这个操作


首先我们来配置一下 使用ksp的module


ksp {
arg("AROUTER_MODULE_NAME", project.getName())
}

ksp project(':arouter-compiler')

然后要注意的是,即使是纯java代码的module 也可以利用ksp来生成代码的, 唯一要注意的是你需要在这个module下 添加


apply plugin: 'kotlin-android'

现在注解处理器也配置好了, 我们就可以干活了。


先放一个基础类就行


class RouteSymbolProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return RouteSymbolProcessor(environment.options, environment.logger, environment.codeGenerator)
}
}

第一步,我们要取出moduleName,这个东西的作用前面已经介绍过了,


val moduleName = options[KEY_MODULE_NAME]

第二步,我们要取出项目中 使用Route注解的类,拿到这些类的信息


// 取出来 使用route注解的
val symbols = resolver.getSymbolsWithAnnotation(Route::class.qualifiedName!!)

// 先取出来 有哪些 类用了Route注解
val elements = symbols.filterIsInstance<KSClassDeclaration>().toList()

第三步, 也是最关键的一步,我们要取出Route 中的关键信息作一个map,key是group,value是path的list


其实也就是一个group 下面对应的所有path信息


这里有几个关键点, 要把Route中的path和group的值 都提取出来, 如果没有指定group 则 path的第一段作为group的值


另外就是在取的时候 要判断一下 这个element的注解是不是Route注解, 因为一个类可以有多个注解,我们要取特定的Route注解 才能取到我们想要的值


关键代码


val map = mutableMapOf<String, List<String>>()
elements.forEach {
it.annotations.toList().forEach { ks ->
// 防止多个注解的情况
if (ks.shortName.asString() == "Route") {
var path = ""
var group = ""
ks.arguments.forEach { ksValueA ->
if (ksValueA.name?.asString() == "path") {
path = ksValueA.value as String
}
if (ksValueA.name?.asString() == "group") {
group = ksValueA.value as String
}
}

// 如果没有配置group 则去path中取
if (group.isEmpty()) {
group = path.split("/")[1]
}

if (map.contains(group)) {
map[group] = map[group]!!.plus(path)
} else {
map[group] = listOf(path)
}
}
}
}

第四步,我们生成IRouteRoot 辅助类


这里有一个难点 就是 如何写这个方法参数的类型


image.png


看下具体代码 如何来解决这个问题


private fun String.quantifyNameToClassName(): com.squareup.kotlinpoet.ClassName {
val index = lastIndexOf(".")
return com.squareup.kotlinpoet.ClassName(substring(0, index), substring(index + 1, length))
}

// IRouteRoot 这个接口 方法参数的定义 MutableMap<String, Class<out IRouteGroup>>?
val parameterSpec = ParameterSpec.builder(
"routes",
MUTABLE_MAP.parameterizedBy(
String::class.asClassName(),
Class::class.asClassName().parameterizedBy(
WildcardTypeName.producerOf(
Consts.IROUTE_GROUP.quantifyNameToClassName()
)
)
).copy(nullable = true)
).build()

参数的这个问题解决掉以后 就很简单了


直接按照名字规则 生成一下 类即可


val rootClassName = "ARouter$$Root$$$moduleName"

val packageName = "com.alibaba.android.arouter"
val file = FileSpec.builder("$packageName.routes", rootClassName)
.addType(
TypeSpec.classBuilder(rootClassName).addSuperinterface(
com.squareup.kotlinpoet.ClassName(
"com.alibaba.android.arouter.facade.template",
"IRouteRoot"
)
).addFunction(
FunSpec.builder("loadInto").addModifiers(KModifier.OVERRIDE)
.addParameter(parameterSpec)
.addStatement("TODO()").build()
).build()
)
.build()

file.writeTo(codeGen, false)

最后一步, 我们要生成IRrouteGroup的辅助类,里面放入对应path的信息


这里path的信息 我用注释表示下即可,


// 生成group 辅助类
map.forEach { (key, value) ->

val rootClassName = "ARouter$$Group$$$key"

// IRouteGroup 这个接口 方法参数的定义 MutableMap<String,RouteMeta>?
val parameterSpec = ParameterSpec.builder(
"atlas",
MUTABLE_MAP.parameterizedBy(
String::class.asClassName(),
RouteMeta::class.asClassName()
).copy(nullable = true)
).build()

val packageName = "com.alibaba.android.arouter"
// val rootClass = com.squareup.kotlinpoet.ClassName("", rootClassName)
val file = FileSpec.builder("$packageName.routes", rootClassName)
.addType(
TypeSpec.classBuilder(rootClassName).addSuperinterface(
com.squareup.kotlinpoet.ClassName(
"com.alibaba.android.arouter.facade.template",
"IRouteGroup"
)
).addFunction(
FunSpec.builder("loadInto").addModifiers(KModifier.OVERRIDE)
.addParameter(parameterSpec)
.addComment("path: $value")
.addStatement("TODO()").build()
).build()
)
.build()

file.writeTo(codeGen, false)
}

最后看下实现效果:


对应的辅助类 应该是都生成了:


image.png


path的信息:


image.png


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

简单聊聊 compose 的remember

前言 在前面聊mutableStateOf 的时候用了这段代码来讲述了Compose 状态的更新。 code-1 val name = mutableStateOf("hello compose") setContent...
继续阅读 »

前言


在前面聊mutableStateOf 的时候用了这段代码来讲述了Compose 状态的更新。


code-1
val name = mutableStateOf("hello compose")
setContent {
    Text(name.value)
}

lifecycleScope.launch {
    delay(3000)
    name.value = "android"
}

接下来,我们继续通过这段代码来一起聊下 Composeremember


浅聊


我们先对code-1的代码稍微做下修改


code-2    
    setContent {
            var name by mutableStateOf("hello compose")
            Text(name)
            //ps:此处代码仅做演示使用,compose中协程的使用并非如此
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
     }

当我们这样进行修改之后,发现3s过后,“hello compose” 并没有如期变成“android”。


这是为什么呢? 是协程没有执行吗?还是没有进行重组刷新?用最简单的方法,我们来加日志看一下执行。


code-3   
      setContent {
            Log.i("TAG", "setContent: ")
            var name by mutableStateOf("hello compose")
            Text(name)
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
                Log.i("TAG", "launch: ")
            }
        }


可以看到,协程已经执行了,而且也进行了重组刷新,但是为什么值没有改变呢?


这是因为使用变量的组件会被包起来,当变量改变的时候会随之进行重组刷新,每次刷新的时候,就会重新创建一个MutableState对象,这个对象就会取默认值“hello compose”。所以才会看起来每次都进行了刷新,但是文字却没有任何改变。



刷新重组的范围就叫做重组作用域。



我们想让Text() 进行刷新怎么办?可以进行包一层。我们对code-2的代码稍微做下修改。


code-4           
setContent {
           var name by mutableStateOf("hello compose")
           Button(onClick = {}){
               Text(name)
           }
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
        }

我们让Button 对他进行一个包裹,然后来看看效果。





可以看到进行包裹了之后,文字发生了改变。虽然这样满足了我们的需求,但是不能每次有使用变量的组件,每次都进行一个包裹吧,这岂不是会疯掉。


接下来就需要有请我们今天的主角 remember了,它就是为了帮助我们解决这个问题。


它可以在每次重组的时候,去帮我们去拿已经缓存的值,不需要每次都是重新创建。


code-5             
setContent {
            var name by remember { mutableStateOf("hello compose") }
            Text(name)
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
}





remember 也是一个Composable 函数,因此只能在Composable 中调用。



现在,我们有个场景,我们需要频繁展示相同的数据,如果使用Text() 直接进行展示,就会每次就会重新计算,但是这些计算没必要的。


为了避免不必要的资源浪费,我们也可以使用remember 来解决。


code-6       
      setContent {
            var name by remember { mutableStateOf("hello compose") }
            ShowCharLenth(name)
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
        }

code-7
@Composable
fun ShowCharLenth(value: String) {
    val str = remember { value }
    Text(str)
}

这样使用,就避免了code-7 中的频繁计算重组。


可是这样还会产生一个问题,如果我们展示的数据 如果变了怎么办? 前面的数据进行了缓存,后面的数据即使变了 还会取之前缓存的数据,那直接产生的问题就是数据改变了,但是UI上没有变化。


remember 也早就对这种情况有了解决措施,而且非常简单。


code-8
@Composable
fun ShowCharLenth(value: String) {
    val str = remember(value) { value }
    Text(str)
}

小括号中的value 就是一个key值,根据key值 再去拿remember缓存的数据,这样就完美解决了我们的问题。


至此,我们从这段很短的代码中学到了 remember 的作用 以及使用,感兴趣的同学可以简单上手实践下。


总结


今天的内容就到这里了,非常的简短,主要就是介绍了一下 remember的作用和使用,希望对于新上手Compose的同学有所帮助。


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

30岁转行程序员晚了吗?分享30岁转行的经历

按惯例,先说下我基本情况。我是85年的,计算机专业普通本科毕业。在一个二线城市,毕业后因为自身能力问题、认知水平问题,再加上运气不好,换过多份工作,每份工作都干不长。导致我30多岁时,还一事无成,也几乎没有积累到什么经验技术、行业知识等。甚至还一度去开过网约车...
继续阅读 »

按惯例,先说下我基本情况。我是85年的,计算机专业普通本科毕业。在一个二线城市,毕业后因为自身能力问题、认知水平问题,再加上运气不好,换过多份工作,每份工作都干不长。导致我30多岁时,还一事无成,也几乎没有积累到什么经验技术、行业知识等。甚至还一度去开过网约车,送过外卖。

转行做程序员前,我每个月收入仅三四千元。2017年下定决心,准备转行!我知道自己最大的劣势就是年龄太大了,但考虑再三,因为以下几个原因,我还是决定走这条路:

  1. 我目前的情况已经算是城市里的底层了。我不会做生意,没其它经验和技能,性格偏内向,销售和交际也不太擅长。所以我不怕失去什么,因为我也没有什么可失去的。

  2. 我想学个谋生技能从新开始,而学开发最适合我。因为我并非0基础,毕竟大学时学的这个专业,而且自己也曾经很喜欢编程。

  3. 我对待遇的要求不高,只要工资在5k以上就行。如果能有6、7 那我就太满意了。

  4. 当时我的瞎推断:因为国内出生率特别低,以后可能没那么多年轻人供雇主挑选,所以我们这些中老年人,也有一定的机会。

  5. 受到一些心灵鸡汤的鼓励,比如什么种一颗树最好的时机是十年前,其次,就是现在!

我辞去工作,开始在家自学web开发。主要的学习方式就是在网上看视频教程。那些视频教程,初级的基本上免费。中高级的有些会收费,大概50-300元左右一套。反正“学费”上我没花什么钱,总共不到一千元吧。

先是学了些前端基础。在学后端时,犹豫了下学PHP还是JAVA。在我读书时是学过JAVA的,虽然已记得不多了。于是我就想学个新的吧!还因为PHP比JAVA简单,更适合中小型项目。我这个情况,肯定进不了大厂,做不了大项目了。所以就决定学PHP。(现在有点小后悔了)

学完前后端基础后,我还跟着视频教程,自己做了两三个简单的项目。在我学习过程中,让我比较有自信的是——没遇到什么挫折。当然,肯定遇到过各种问题被卡住,但自己都去百度解决了。

接下来开始准备找工作,第一个难点就是简历。快32岁的人了,如果老实说刚自学出来没任何实际工作经验,肯定是没人要的。没办法,只好去包装简历,说成有两年开发经验。瞎编简历的过程还是很困难的。以前呆的哪家公司,做了什么项目,完全凭想像去编造。

然后海量投简历,艰难地面试,不断地被淘汰......当时我的信念就是,大不了我花一年时间去找工作,找不到就继续学,直到找到为止!最终我花了近两个月时间,可能参加了20次的面试,才勉强找到份工作。

因为没有实际工作经验,造假的简历,是很容易被发现的。只要稍有经验的面试官,多追问你几个细节,就露馅了。因为你并没有做过,不可能编造出全部的细节。所以面试过程很艰难。说几次印象深刻的:

1、一家特别小的公司,还是与人合租的办公室,我当时见到的员工,似乎只有两个人。与技术面试官简单交流了几句后,他当场决定录用我,让我明天来上班,并且说稍后会发短信给我确认。但不知道为什么,后来没再联系我了。

2、另一家小公司,还是在居民小区里办公,不过办公环境还不错,约有七八个人的样子。与面试官交流的地点是在生活阳台上,面试官坐着一个简易塑料板凳,旁边是个洗衣机。和他聊了较长时间,自以为他问的所有问题,我回答得都不错,也很希望拿到这个offer。满心期待的回家等了好几天,结果也没下文了。

3、也是一家在居民楼里办公的小公司,办公环境给人压抑的感觉。去的时候就遇上,老板模样的人,在对开发人员大发脾气,那些开发人员都不敢吱声。然后那老板对待我们这些面试者,态度特别粗鲁,抱怨我们为什么早到了10分钟?笔试的内容甚至包含直接给他们现有项目找bug,和改bug。我心中生气,中途离开了。

4、最后能找到工作,通过面试,主要是运气。是老板直接跟我聊的,没经过技术面试官。老板觉得我有相关行业工作经验(其实没啥帮助),又是统招本科,专业对口,就直接决定录用我了。如果当时他让懂技术的人来面试我下,估计我多半过不了。

那是家小公司,连同老板在内,总共十几个人,给的待遇是6k。这待遇对于开发来说,算是最低水平了。但我当时还是很高兴。说出不来怕丢人,比起以前的工作,6k对我来说已是高薪了。

刚去上班那段时间,还是发现了很多“新奇”的东西。比如,原来还有一个岗位,叫做“产品经 理”。以前我一直以为,只需要有开发人员撸代码就行了。更让我汗颜的是,我都不知道PHP也是可以做APP的。恰好我看的那些视频教程,都没提到这点。讲课的例子,以及做的实战项目练习,全是pc网站!

当我同事滑动着手机告诉我,app中的这些数据,都是接口中获取的。我点头,装作原来如此的样子。其实内心却震惊了:然来PHP还可以做APP啊,真是牛B!没错,我就是这么菜,甚至当时我都搞不明白,啥是api接口。感觉这是一个很难的东西。还把它和OOP中的"接口"搞混了。因为他们都叫“接口”二字。

在那里呆了两三个月后,我就没那么白痴了。给我安排的日常开发任务,都能按时完成。呆了半年后,我渐渐地发现。我后端同事些,水平也不过如此,可以说基础还不如我,我还时不时给他们解答下技术上的问题。当时我还有点飘飘然了,觉得他们只不过对业务更熟悉些。

现在的我回想起来,当时我的看法大致没错。道理很简单:愿意去这家公司,拿6-7k工资(除了工资以外,啥也没有)的程序员,只能是初级水平。

经过一年的时间,我已成长为一个合格的增删改查工程师。这里已学不到什么东西了,现在回想起来,那个公司没有任何技术氛围,在里面呆着,有一种养老的感觉,外部没有什么动力和压力,逼自己继续学习。

经朋友介绍,我跳槽去了另一家公司,很顺利地通过了笔试和面试,工资开的9k。

刚去的第一个月,我经受了很大的压力。部门负责人,看我年龄这么大,就以为我是一个很牛逼的人。就给我安排了些较难的任务,我很费力地完成了一两个后。他就给我安排了个更难的,说:“这个都是架构师搞了的,你好好研究下吧。”我就在焦虑和不安中,研究了一周,还是云里雾里的,只好鼓起勇气跟他摊牌了,说我做不来。还好那负责人也没说啥,安排我做增删改查的业务了。

我第一家公司,还有个大问题是,用的都是些落伍的技术。而我在第二家公司这里也跟上了主流的技术。比如git(上家是svn)、laravel5(上家是tp3)、 docker(上家是phpstudy) 、前后端分离(以前是混起的)、任务进度管理系统(上家没有) 、专业的测试工程师(上家是运营人员兼测试)等等。光是学习和熟悉这些,我都花了大量的时间。

这里开发人员的技术水平,和上家公司完全不是一个档次的。氛围也和第一家公司不一样了,同事们经常聊各种新出来的技术。哪怕聊点8卦,也是以IT新闻为主。他们聊的东西,很多我都听不懂。让我深感自己知识面的狭窄。

这里的学习气氛也浓厚。做过开发的都知道,忙的时候真是喝水都顾不上。但有时没事,又闲得很。上家公司在没事干时,大家就偷偷玩游戏、看视频、小说等。这里没事时,大家就是看文档,学新东西。

两三个月后,我慢慢适应这里了。但还是那个问题,一直让我焦虑——年龄。同事闲聊时,我最怕聊到年龄的话题。哪怕和年龄有一点点关系的话题,我也会警觉。比如同事们聊起用的第一部智能手机。我一般就不接话,因为我用的第一个智能手机还是palm!

但是,怕什么还是来什么了。

有一次聚会,又聊起年龄。每个人就在说说笑笑地报自己的年龄。轮到我时,我强笑着说:“我嘛,永远18岁。”一个正常情商的人,都会明白,这就是不愿意说,识趣的话应该也就别多问了。但是,在坐有个同事,情商真的太低了,他直接跳出来说:“他85年的!”此时,刚走进来一个95后同事,听到“85年”这三个字,顺口就说了句:“谁85年的啊?”当时我感觉整个空气都凝固了,我的大腿控制不住地抖动,背上密密麻麻地出汗,把衬衣都湿透了。我只好举手示意,说:“是我,我是85年的...”当时我的尴尬和羞愧,永远也无法忘记。

目前我工资12k,仍然是一个技术普通的后端开发人员。对于这个收入我是知足和满意的。

我是个脸皮薄,性格敏感的人,2020年又要满35岁了,哎!

其实让我目前感到尴尬和羞愧的并不完全是因为年龄。而是我的年龄和技术能力完全不匹配!公司中也有年龄和我相仿的人,但在我的眼中,他们都是技术大牛了。感觉什么都懂,随便说一个问题,他们都能给你上上课,讲讲底层原理。当我新听到一个技术概念,觉得很新鲜,正准备去了解个大概时。他们不仅熟悉,甚至还知道茴香豆的茴字,有四种写法!

青春逝去,时光不再。比我聪明,比我入行早的人,都还在努力,我现在能做的,只能是继续努力学习,仅仅希望不要那么丢脸。

对于那些一毕业就干开发,目前不到30岁,但经常听说程序员只能做到35岁,并为此焦虑的同学。请你们尽管放心,只要你们做的不是养老的工作,每年在技术上都有明显进步,找到好工作绝对没问题。至少在中型公司当个leader是没问题的。

但在此劝那些30多岁想转行程序员的人,如果你们像我一样,不是一个脸皮厚的人,一定要慎重!

不过如果呆在那种10个人左右的小公司,这种年龄尴尬,要稍好点,但就没什么技术氛围了,成长较慢。

至于有人问我他该不该转行程序员,我想说职业规划是大事,每个人的情况都不一样,这很难回答。何况我也不是个“人生导师”,只是个技术普通的大龄程序员。我个人意见总结起来是这样的,就不再一一单独回复了:

1.你是否有兴趣和能力去做好开发?

  • 有个简单的方法,可以判断自己是否有能力。那就是回顾一下自己中学或大学时的数学成绩!

  • 如果数学成绩好,说明你有天赋,反之就没有。

  • 这并不是说,做项目开发需要多少数学知识——相关性不等于因果性。

  • 只是因为,数学成绩好,代表你比较聪明,抽象思维能力强,这是开发所需要的。

  • 我自己读的是一个普通中学,普通大学。我的数学成绩,一般在班上排名前5。我自己感觉就是学初级、中级的知识较容易。高级点的知识,学起来就特别吃力。

  • 我公司里有位同事,很年轻,技术特别厉害。我就很好奇,问了他一句:“你大学时,是不是数学很好?”结果他回答,他数学一直是全校第一名。

2.你的现状是什么?

  • 如果现在有份收入不错,且能长期干下去的工作,那也没必要去转行。

  • 反之,如果像我当初一样,做着一份毫无前途,月收入仅三、四千元的工作,那可以考虑转行。

3.你的年龄?

  • 如果你还年轻,大概在26岁以下,且前2个条件都满足,那可以去转。

  • 而如果像我一样,当时都30多岁了,要慎重。


2021年1月27日更新

我的近况:半年前,换了家公司,待遇差不多。但要轻松了很多,让我压力和焦虑都小了些。这家公司规模不算小,但并不是互联网公司,研发部门人很少。同事们的平均年龄,也相对大些。所以对我来说,整体气氛比较轻松。也让我有更多时间去学习。我又花了很多时间,重新把前端基础学了一遍,现在的前端技术和我几年前时学的,变化太大了!

2021年6月24日更新

3月初,我一个朋友的朋友,是一个小公司的老板。他想给自己公司做一个内部用的业务系统,联系上了我。真是巧了,正好我才重新学完前端。充分了解了这个项目的需求后,我发现不算难。价格我就报了4万,对方毫不犹豫的答应了。我一个人撸后端和前端代码,前端只有pc端,前前后后,加调试修改,一共花了一个多月时间完成。交付后,那个老板很满意。不过这种私单,我感觉是可遇不可求的。我去网上各种发包接单平台看了下(比如猪八戒),价格都是超低的。

不久后,又因为一些朋友关系。了解到健身房相关行业的软件需求。比如约课,会员管理之类的。有两三个人打算新开健身房的人,都对我说,你只要把这个系统开发现来,我肯定买(付费模式是月付或年付)。我就去仿造其它健身房系统,做了一个类似的多商户SaaS系统,用户端是微信公众号。结果没想到做成后,之前答应说一定买的,却因为各种原因,要么没开成店,要么推迟开。我挂到网上去,也没卖出去。不过我也并没有在意,就当自己学习了,练手了。

能搞这么多事情的前提,是我目前呆的这个公司,比较轻松。有时整整一个月都没啥事。

作者:老去的80后
来源:zhuanlan.zhihu.com/p/99944212

收起阅读 »

vue-video-player 播放m3u8视频流

该问题网上答案较少,翻阅github得到想要的答案,在此记录一下首先,为了减少包体积,在组件中局部引入vue-video-player(在main.j s中引入会增加包体积)播放m3u8需要注意两点:需要引入videojs并绑定到window上安装依赖vide...
继续阅读 »

该问题网上答案较少,翻阅github得到想要的答案,在此记录一下

首先,为了减少包体积,在组件中局部引入vue-video-player(在main.j s中引入会增加包体积)

播放m3u8需要注意两点:

  1. 需要引入videojs并绑定到window上

  2. 安装依赖videojs-contrib-hls( npm i videojs-contrib-hls)并引入

  3. sources要指定type为application/x-mpegURL

代码如下:

<template>
   <section>
       <video-player :options="options"></video-player>
   </section>
</template>

<script>
import { videoPlayer } from 'vue-video-player'

import videojs from 'video.js'
//注意点1:需要引入videojs并绑定到window上
window.videojs = videojs
//注意点2:引入依赖
require('videojs-contrib-hls/dist/videojs-contrib-hls.js')


require('video.js/dist/video-js.css')
require('vue-video-player/src/custom-theme.css')

export default {
   name: 'test-video-player',
   components: {
       videoPlayer
  },
   data() {
       return {
           options: {
               autoplay: false,
               height: '720',
               playbackRates: [0.7, 1.0, 1.25, 1.5, 2.0],
               sources: [
                  {
                       withCredentials: false,
                       type: 'application/x-mpegURL', //注意点3:这里的type需要指定为 'application/x-mpegURL'
                       src:
                           'https://tx-safety-video.acfun.cn/mediacloud/acfun/acfun_video/47252fc26243b079-e992c6c3928c6be2dcb2426c2743ceca-hls_720p_2.m3u8?pkey=ABDuFNTOUnsfYOEZC286rORZhpfh5uaNeFhzffUnwTFoS8-3NBSQEvWcqdKGtIRMgiywklkZvPdU-2avzKUT-I738UJX6urdwxy_ZHp617win7G6ga30Lfvfp2AyAVoUMjhVkiCnKeObrMEPVn4x749wFaigz-mPaWPGAf5uVvR0kbkVIw6x-HZTlgyY6tj-eE_rVnxHvB1XJ01_JhXMVWh70zlJ89EL2wsdPfhrgeLCWQ&safety_id=AAKir561j0mZgTqDfijAYjR6'
                  }
              ],
               hls: true
          }
      }
  },
   computed: {},
   methods: {},
   created() {}
}
</script>

<style lang="" scoped></style>

参考

作者:我只是一个API调用工程师
来源:juejin.cn/post/7080748744592850951

收起阅读 »

项目没发版却出现了bug,原来是chrome春节前下毒

web
前言农历: 腊月二十五阳历: 2023-01-16过年和年兽已经临近过年,公司的迭代版本也已经封版,大家都在一片祥和又掺杂焦虑的气氛中等待春节的到来。 当然,等待的人群里面也有我,吼吼哈嘿。突然企业微信的一声响,我习惯性的抬头瞅了一眼屏幕,嗯? 来至线上bug...
继续阅读 »

前言

  • 农历: 腊月二十五

  • 阳历: 2023-01-16

过年和年兽

已经临近过年,公司的迭代版本也已经封版,大家都在一片祥和又掺杂焦虑的气氛中等待春节的到来。 当然,等待的人群里面也有我,吼吼哈嘿。

突然企业微信的一声响,我习惯性的抬头瞅了一眼屏幕,嗯? 来至线上bug群?

不过因为最近咱前端项目也没有发版,心里多少有点底气的。

于是怀着好奇的心情点开了群消息, 准备看看是什么情况。

结果进群看到是某前端页面元素拖拽功能的位置失效了。晴天霹雳啊,我们有一个类似给运营做自定义活动页面,说是无法拖拽了。然后需要做活动比较紧急,需要尽快修复。

这活脱脱就是跟着春节来的年兽啊。我还没放烟花打年兽,年兽就先朝我冲过来了,那说什么也得较量较量了。


项目背景

我们这个功能是属于一个基础功能,通过npm私有仓库维护版本

这个基础功能呢,很多项目中都在使用。

如果基础功能发了新版本,业务部门不进行升级安装,那么这个业务线的项目也是不会出问题的。所以只要线上出了问题,那么要满足两个条件

1、基础功能进行了发布了npm新版本,且这个版本有问题

2、业务部门进行了升级,使用了这个新版本

排查问题

一般来说:造成问题的可能性有

  1. 有人发过新迭代版本

  2. 是不是存在莫名的缓存

  3. 有人在以前的版本里面下毒了,然后现在发作了(可能性不大)

经过粗略排查

猜测结果
1、发版导致?近期两周,该服务部分未更新,排除
2、缓存导致已经清理,没用,排除
3、下毒了看了相关代码,没什么问题,排除

问题初见端倪

接着发生了两件事情

1、然后我本地跑了一下项目的时候,在操作的时候,存在报错。

2、一个测试兄弟反馈说他那儿可以正常操作

这他么莫不是浏览器兼容问题了吧。

我去他那看了一下,都是chrome浏览器(这个项目我们只支持到chrome就可以)

这时的我感觉可能问题有点大了,莫不是chrome又调整了吧

点开测试兄弟的版本看了下,是108,而且处于重启就会升级的状态。 我赶紧回到我的工位,打开电脑发现是109。


在看了下那个报错, event.path为undefined, 这里先介绍下path是个什么玩意,他是一个数组,里面记录着从当前节点冒泡到顶层window的所有node节点。我们借助这个功能做了一写事情。。。

这直接被chrome釜底抽薪了。(path属于非标准api, 这些非标准api慎用,说不定什么时候就嘎了)

解决问题

1、问题一

既然是event.path没了,那么我们怎么办呢,首先得找到代替path的方法, 上面我们也说了,path里面记录的是从当前节点冒泡到顶层window的所有node节点(我们是拖拽事件)


那么我们可以自己遍历一下当前节点+他的父节点+父节点的父节点+...+window

    let path = [];
   let target = event.target;
   while(target.parentNode !== null){
     path.push(target);
     target = target.parentNode;
  }
   path.push(document, window);
   return path;

在项目里面试了一下,emm,很稳定。

1、问题二

但是我们又遇到了第二个问题,使用到event.path的项目还比较多,这就日了狗了 如果没有更好的方法,那么我只能挨个项目改,然后测试,然后逐个项目发版

这种原始的方法我们肯定是不会采用的,换个思路,既然event下的path被删除了,那么我们在event对象下追加个一个path属性就可以了

当然我们要记得判断下path属性是否存在,因为有部分用户的chrome是老版本的,我们只对升级后的版本做一些兼容就可以了

if (!Event.prototype.hasOwnProperty("path")){
   Object.defineProperties(Event.prototype, {
     path: {
         get: function(){
             var target = this.target;
             console.log('target', target)
             var path = [];
             while(target.parentNode !== null){
                 path.push(target);
                 target = target.parentNode;
            }
             path.push(document, window);
             return path;
        }
    },
     composedPath: {
         value: function(){
             return this.path;
        },
         writable: true
    }
  });
}

这样,我们只需要在每个项目的根html,通过script引入这个js文件就可以了

反思

如题,这个事情怪chrome吗?其实不能怪的。 1、chrome在之前就已经给出了更新通知,只是我们没有去关注这个事情 2、本身event.path不是标准属性,我们却使用了(其实其他浏览器是没有这个属性的,只是chrome提供了path属性, 虽然现在他删除了) 3、总之还是自己不够警惕,同时使用了不标准的属性,以此为戒,共勉

作者:大鱼敢瞪猫
来源:juejin.cn/post/7193520080808837180

收起阅读 »

如何让一套代码完美适配各种屏幕?

一、适配的目的 区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。 2021市场移动设备分辨率统计 可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一...
继续阅读 »

一、适配的目的


区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。


2021市场移动设备分辨率统计


可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一套代码如何完美的展示在不同的设备上,可以看下面的一些适配方案。


二、UI适配


2.1、常见的适配方式


2.1.1、xml布局控件适配



  1. 避免写死View的宽高,尽量使用warp_content和match_parent;

  2. 父布局为LinearLayout,选择使用android:layout_weight属性,为布局中的每个子View设置权重;

  3. 父布局为RelativeLayout,可以选择使用layout_centerInParent等属性,设置子View的相对位置;

  4. 谷歌官方在之前版本中提供了一个百分比布局方式:support:percent,它支持RelativeLayout和FrameLayout的百分比布局,但是目前官方已经不再维护,而将他取而代之的是新晋布局:ConstraintLayout,ConstraintLayout强大之处不仅在于它能够进行百分比布局,还可以进行相对定位、角度定位、尺寸约束、宽高比、Chainl链布局等,在不同设备间都能处理的游刃有余。


2.1.2、图片适配



  1. .9图

    .9.png图片本质上还是png图片,相对于普通png图来说,.9图可以让图片在指定的位置拉伸和在指定的位置显示内容且不会失真;

  2. 见2.1.4分辨率限定符;


2.1.3、依据产品设计适配


所谓产品设计适配,指的是产品流程在不同设备上有不同的展示方式,例如手机与Pad的区别,在手机设备上,一般来说具体Item列表是一个页面,点击每个Item会跳转至新的详情页;而在宽度>高度的Pad上,为了防止页面空白浪费,一般会要求屏幕左侧为Item列表,右侧即详情页,item与详情页会同时出现在用户的视觉内,如下图


Pad.png


关于这种类型的设计,其实郭霖《第一行代码》给出了一个方案,我在这里抛砖引玉一下,给出基本思路。


这种情况下,适配的核心在于利用android动态加载布局的机制,使得程序能够根据分辨率或者屏幕大小在运行时动态加载不同的布局,而动态加载就需要使用到限定符



  • 限定符
    所谓限定符,指的是给res目录中的子目录加上“-限定符”,可以给不同设备提供不同的资源以及布局,如下图,layout添加-large,-small。


image.png


layout-small:指的是提供给小屏幕设备的资源;

layout-large:指的是提供给大屏幕设备的资源;

layout/layout-normal:指的是提供给中等屏幕设备的资源,也就是默认状态;

layout-xlarge:值得是提供给超大屏幕设备的资源;


在上面所提出的情景下,Pad即指的大屏幕,手机一般可看作为中等屏幕设备,为了在大屏幕下显示双页模式,我们可以在layout-large和layout目录下新建同一个name的布局xml,在layout-large下的xml针对Pad做双页处理,即左半边View+右半边View样式,layout目录下xml还是做普通处理。


在最后项目运行时,会根据不同设备来加载不同目录下的xml资源,即Pad会加载layout-large目录下的xml,普通手机设备会加载layout目录下的xml资源。


从而实现一套代码在不同设备上产品逻辑。


限定符可以大范围的区分设备,但是你还是不知道-large代表是多大的设备,-small代表的是多小的设备,如果需要清楚的区分各个屏幕的大小,那就需要用到最小宽度限定符。



  • 最小宽度限定符(Smallest-width Qualifier),简称SW
    最小宽度限定符指的是,对屏幕的宽度设立一个最小的值(dp),当当前设备屏幕宽度大于这个值就加载一个布局,


image.png


例如在res下新建一个layout-sw720dp的文件夹,当屏幕宽度大于720dp时,项目就会加载layout-sw720dp/***.xml 资源文件。


2.1.4、限定符适配


在2.1.3中提到了限定符的概念,也解决了一部分的设计适配问题,但是还有一些限定符的概念没有涉及到,该目录下将会提到不同的限定符的概念,可以结合2.1.3一起食用。



  • 分辨率限定符
    在Android项目中,会把放置图片资源的文件夹分为drawable-hdpi、xhdpi xxhdpi xxxhdpi等,这些指的就是分辨率限定符。


Andriod系统会根据手机屏幕的大小及屏幕密度去选择不同文件夹下的图片资源,以此来实现在不同大小不同屏幕分辨率下适配的问题。


这里提一点AS对图片资源的匹配规则:


举个例子,当当前的设备密度为xhdpi,此时代码中ImageView需要去引用drawable中的图片,那么根据匹配规则,系统首先会在drawable-xhdpi文件夹中去搜索,如果需要的图片存在,那么直接显示;如果不存在,那么系统将会开始从更高dpi中搜索,例如drawable-xxhdpi,drawable-xxxhdpi,如果在高dpi中搜索不到需要的图片,那么就会去drawable-nodpi中搜索,有则显示,无则继续向低dpi,如drawable-hdpi,drawable-mdpi,drawable-ldpi等文件夹一级一级搜索.


当在比当前设备密度低的文件夹中搜到图片,那么在ImageView(宽高在wrap_content状态下)中显示的图片将会被放大.图片放大也就意味着所占内存也开始增多.这也就是为什么分辨率不高的图片随意放置在drawable中也会出现OOM,而在高密度文件夹中搜到图片,图片在该设备上将会被缩小,内存也就相应减少。


在理想的状态下,不同dpi的文件下应该放置相应dpi的图片资源,以对不同的设备进行适配。




  • 尺寸限定符和最小宽度限定符
    见2.1.3




  • 屏幕方向限定符
    屏幕方向限定符即“-land”、“-port”,分别代表横排和竖屏。




手机会存在横竖屏切换的场景,当设备横屏时,会主动加载layout-land/目录下的资源文件,当设备为竖屏时,则加载layout-port目录下的资源文件。


2.2、今日头条适配方式


在开始今日头条的适配方案之前,需要提及px、dpi、density的概念。


px:即像素,我们常看到的480 * 800 、720 * 1280、1080 * 1920指的就是像素值宽高的意思;


dpi:即densityDpi,每英寸中的像素数;


density:屏幕密度,density = dpi / 160;


scaledDensity:字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值


android中的dp在渲染前会将dp转为px,计算公式:



  • px = density * dp


从dp和px的转换公式 :px = dp * density 可以看出,如果设计图宽为360dp,想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,我们只能修改 density 的值。这就是该方案的核心。


那如何修改系统的density?


可以通过DisplayMetrics获取系统density和scaledDensity值,


val displayMetrics = application.resources.displayMetrics

val density = displayMetrics.density
val scaledDensity = displayMetrics.scaledDensity

设配的目的在于使用一套设计稿,能完好的展示在不同设备上,所以UI需要确定一个固定的尺寸,依据density=px / dp的公式,确定density的值,其中px指的是真实设备的值,
这里我们以设计稿的宽度作为一个纬度进行测算。


举个例子,如设计稿中固定宽度为360dp,当前设备的屏幕宽度为720,那么density = 720 / 360 = 2,其中当前设备的屏幕宽度也可以用DisplayMetrics来获取:


val targetDensity = displayMetrics.widthPixels / 360

整体思路


//0.获取当前app的屏幕显示信息
val displayMetrics = application.resources.displayMetrics
if (appDensity == 0f) {
//1.初始化赋值操作 获取app初始density和scaledDensity
appDensity = displayMetrics.density
appScaleDensity = displayMetrics.scaledDensity
}

/*
2.计算目标值density, scaleDensity, densityDpi
targetDensity为当前设备的宽度/设计稿固定的宽度
targetScaleDensity:目标字体缩放Density,等比例测算
targetDensityDpi:density = dpi / 160 即dpi = density * 160
*/
val targetDensity = displayMetrics.widthPixels / WIDTH
val targetScaleDensity = targetDensity * (appScaleDensity / appDensity)
val targetDensityDpi = (targetDensity * 160).toInt()

//3.替换Activity的density, scaleDensity, densityDpi
val dm = activity.resources.displayMetrics
dm.density = targetDensity
dm.scaledDensity = targetScaleDensity
dm.densityDpi = targetDensityDpi

三、刘海屏适配















image.pngimage.png


  • 有状态栏的界面:刘海区域会显示状态栏,无需适配;

  • 全屏界面:刘海区域可能遮挡内容,需要适配;


针对刘海屏适配,在Android P以上,谷歌官方给出了适配方案,可参考developer.android.google.cn/guide/topic… ,所以在 targetApi >= 28 上可以使用谷歌官方推荐的适配方案进行刘海屏适配。
而在Android O的设备上,如华为、小米、oppo等厂商给出了适配方案。


3.1、Android9.0官方适配


将内容呈现到刘海区域中,则可以使用 WindowInsets.getDisplayCutout() 来检索 DisplayCutout 对象,同时可以使用窗口布局属性 layoutInDisplayCutoutMode 控制内容如何呈现在刘海区域中。


layoutInDisplayCutoutMode



  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT :在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:在竖屏模式和横屏模式下,内容都会呈现到刘海区域中。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER:内容从不呈现到刘海区域中。


/**
* @param mode 刘海屏下内容显示模式,针对Android9.0
LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT = 0; //在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边
LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER = 2;//不允许内容延伸进刘海区
LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES = 1;//在竖屏模式和横屏模式下,内容都会呈现到刘海区域中
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun setDisplayCutoutMode(mode: Int) {
window.attributes.apply {
this.layoutInDisplayCutoutMode = mode
window.attributes = this
}

}

判断是否当前设备是否有刘海:


/**
* 判断当前设备是否有刘海
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun hasCutout(): Boolean {
window.decorView.rootWindowInsets?.let {
it.displayCutout?.let {
if (it.boundingRects.size > 0 && it.safeInsetTop > 0) {
return true
}
}
}
return false
}

在activity的
setContentView(R.layout.activity_main)之前设置layoutInDisplayCutoutMode。

















LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULTLAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVERLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
image.pngimage.pngimage.png

3.2、各大厂商适配方案(华为、小米、oppo等)


除了在AndroidP系统下官方给了适配方案,各大厂商针对自家系统也给出了相应的适配方案,可参考:


oppo

vivo

小米

华为


参考文档

今日头条适配方案

Android9.0官方适配方案


推荐阅读

视频直播小窗口(悬浮窗)展示方案

探究ANR原理-是谁控制了ANR的触发时间


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

Compose太香了,不想再写传统 xml View?教你如何在已有View项目中混合使用Compose

前言 在我的文章 记一次 kotlin 在 MutableList 中使用 remove 引发的问题 中,我提到有一个功能是将多张动图以N宫格的形式拼接,并且每个动图的宽保证一致,但是高不保证一致。 在原本项目中我使用的是传统 view 配合 Recycler...
继续阅读 »

前言


在我的文章 记一次 kotlin 在 MutableList 中使用 remove 引发的问题 中,我提到有一个功能是将多张动图以N宫格的形式拼接,并且每个动图的宽保证一致,但是高不保证一致。


在原本项目中我使用的是传统 view 配合 RecyclerView 和 GridLayout 布局方式进行拼图的预览,但是这会存在一个问题。


实际上是这样排列的:


s1.png


但是预想中应该是这样排列:


s2.png


可以看到,我们的需求应该是完全按照顺序来排列,但是瀑布流布局却是在每一行中,哪一列的高度最小就优先排到哪一列,而不是严格按照给定顺序排列。


显然,这是不符合我们的需求的。


我曾经试图找到其他的替代方式实现这个效果,或者试图找到 GridLayout 的某个参数可以修改为按顺序排列,但是一直无果。


最终,只能用自定义布局来实现我想要的效果了。但是对于原生 View 的自定义布局非常麻烦,我也没有接触过,所以就一直不了了之了。


最近一直在学习 compose ,发现 compose 的自定义布局还挺简单的,所以就萌生了使用 compose 的自定义布局来实现这个需求的想法。


由于这个项目是使用的传统 View ,并且已经上线运行很久了,不可能一蹴而就直接全部改成使用 compose,并且这个项目也还挺复杂的,移植起来也不简单。所以,我决定先只将此处的预览界面改为使用 compose,也就是混合使用 View 与 compose。


开始移植


compose 自定义布局


在开始之前我们需要先使用 compose 编写一个符合我们需求的自定义布局:


@Composable
fun TestLayout(
modifier: Modifier = Modifier,
columns: Int = 2,
content: @Composable ()->Unit
) {
Layout(
modifier = modifier,
content = content,
) { measurables: List<Measurable>, constrains: Constraints ->
val itemWidth = constrains.maxWidth / columns
val itemConstraints = constrains.copy(minWidth = itemWidth, maxWidth = itemWidth)
val placeables = measurables.map { it.measure(itemConstraints) }

val heights = IntArray(columns)
var rowNo = 0
layout(width = constrains.maxWidth, height = constrains.maxHeight){
placeables.forEach { placeable ->
placeable.placeRelative(itemWidth * rowNo, heights[rowNo])
heights[rowNo] += placeable.height

rowNo++
if (rowNo >= columns) rowNo = 0
}
}
}
}

这个自定义布局有三个参数:


modifier Modifier 这个不用过多介绍


columns 表示一行需要放多少个 item


content 放置于其中的 itam


布局的实现也很简单,首先由于每个子 item 的宽度都是一致的,所以我们直接定义 item 宽度为当前布局的最大可用尺寸除以一行的 item 数量: val itemWidth = constrains.maxWidth / columns


然后创建一个 Array 用于存放每一列的当前高度,方便后面摆放时计算位置: val heights = IntArray(columns)


接下来遍历所有子项 placeables.forEach { placeable -> } 。并使用绝对坐标放置子项,且 x 坐标为 宽度乘以当前列, y 坐标为 当前列高度 placeable.placeRelative(itemWidth * rowNo, heights[rowNo])


最后将高度累加 heights[rowNo] += placeable.height 并更新列数到下一列 rowNo++if (rowNo >= columns) rowNo = 0


下面预览一下效果:


@Composable
fun Test() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TestLayout {
Rectangle(height = 120, color = Color.Blue, index = "1")
Rectangle(height = 60, color = Color.LightGray, index = "2")
Rectangle(height = 140, color = Color.Yellow, index = "3")
Rectangle(height = 80, color = Color.Cyan, index = "4")
}
}
}


@Composable
fun Rectangle(height: Int, color: Color, index: String) {
Column(
modifier = Modifier
.size(width = 100.dp, height = height.dp)
.background(color),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = index, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp)
}
}


@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
fun PreviewTest() {
Test()
}

效果如下:


s2.png


完美符合我们的需求。


增加修改 gradle 配置


为了给已有项目增加 compose 支持我们需要增加一些依赖以及更新一些参数配置。


检查 AGP 版本


首先,我们需要确保 Android Gradle Plugins(AGP)版本是最新版本。


如果不是的话需要升级到最新版本,确保 compose 的使用,例如我写作时最新稳定版是 7.3.0


点击 Tools - AGP Upgrade Assistant 打开 AGP 升级助手,选择最新版本后升级即可。


检查 kotlin 版本


不同的 Compose Compiler 版本对于 kotlin 版本有要求,具体可以查看 Compose to Kotlin Compatibility Map


例如,我们这里使用 Compose Compiler 版本为 1.3.2 则要求 kotlin 版本为 1.7.20


修改配置信息


首先确保 API 等级大于等于21,然后启用 compose:


buildFeatures {
// Enables Jetpack Compose for this module
compose true
}

配置 Compose Compiler 版本:


composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}

并且确保使用 JVM 版本为 Java 8 , 需要修改的所有配置信息如下:


android {
defaultConfig {
...
minSdkVersion 21
}

buildFeatures {
// Enables Jetpack Compose for this module
compose true
}
...

// Set both the Java and Kotlin compilers to target Java 8.
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}

composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}
}

添加依赖


dependencies {
// Integration with activities
implementation 'androidx.activity:activity-compose:1.5.1'
// Compose Material Design
implementation 'androidx.compose.material:material:1.2.1'
// Animations
implementation 'androidx.compose.animation:animation:1.2.1'
// Tooling support (Previews, etc.)
implementation 'androidx.compose.ui:ui-tooling:1.2.1'
// Integration with ViewModels
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
// UI Tests
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.2.1'
}

自此所有配置修改完成,Sync 一下吧~


将 view 替换为 compose


根据我们的需求,我们需要替换的是用于预览拼图的 RecyclerView:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.gifTools.JointGifPreviewFragment">

<!-- ... -->

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/jointGif_preview_recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="8dp"
android:transitionName="shared_element_container_gifImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<!-- ... -->

</androidx.constraintlayout.widget.ConstraintLayout>

将其替换为承载 compose 的 ComposeView:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.gifTools.JointGifPreviewFragment">

<!-- ... -->

<androidx.compose.ui.platform.ComposeView
android:id="@+id/jointGif_preview_recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="8dp"
android:transitionName="shared_element_container_gifImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<!-- ... -->

</androidx.constraintlayout.widget.ConstraintLayout>

在原本初始化 RecyclerView 的地方,将我们上面写好的 composable 设置进去。


将:


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// ...

initRecyclerView()

// ...
}

改为:


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// ...

bind.jointGifPreviewRecyclerView.setContent {
Test()
}

// ...
}

ComposeViewsetContent(content: @Composable () -> Unit) 方法只有一个 content 参数,而这个参数是一个添加了 @Composable 注解的匿名函数,也就是说,在其中我们可以正常的使用 compose 了。


更改完成后看一下运行效果:


s3.png


可以看到,混合使用完全没有问题。


但是这里我们使用的是写死的 item 数据,而不是用户动态选择的图片数据,所以下一步我们需要搞定 compose 和 view 之间的数据交互。


数据交互


首先,因为我们需要显示的动图,所以需要引入一下对动图的支持,这里我们直接使用 coil 。


引入 coil 依赖:


// coil compose
implementation 'io.coil-kt:coil-compose:2.2.2'
// coil gif 解码支持
implementation 'io.coil-kt:coil-gif:2.2.2'

定义一个用于显示 gif 的 composable:


@Composable
fun GifImage(
uri: Uri,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val imageLoader = ImageLoader.Builder(context)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()

Image(
painter = rememberAsyncImagePainter(model = uri, imageLoader = imageLoader),
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.FillWidth
)
}

其中,rememberAsyncImagePaintermodel 参数支持多种类型的图片,例如:File Uri String Drawable Bitmap 等,这里因为我们原本项目中使用的是 Uri ,所以我们也定义为使用 Uri。


而 coil 对于不同 API 版本支持两种解码器 ImageDecoderDecoderGifDecoder 按照官方的说法:



Coil includes two separate decoders to support decoding GIFs. GifDecoder supports all API levels, but is slower. ImageDecoderDecoder is powered by Android's ImageDecoder API which is only available on API 28 and above. ImageDecoderDecoder is faster than GifDecoder and supports decoding animated WebP images and animated HEIF image sequences.



简单翻译就是 GifDecoder 支持所有 API 版本,但是速度较慢; ImageDecoderDecoder 仅支持 API >= 28 但是速度较快。


因为我们的需求是宽度一致,等比缩放长度,所以需要给 Image 加上缩放类型 contentScale = ContentScale.FillWidth


之后把我们的自定义 Layout 改一下名字,其他内容不变: SquareLayout


增加一个 JointGifSquare 用作界面入口:


@Composable
fun JointGifSquare(
columns: Int,
uriList: ArrayList<Uri>,
) {
SquareLayout(columns = columns) {
uriList.forEachIndexed { index, uri ->
GifImage(
uri = uri,
)
}
}
}

其中 columns 表示每一行有多少列;uriList 表示需要显示 GIF 动图 Uri 列表。


最后,将 Fragmnet 中原本初始化 RecyclerView 的方法改为:


private fun initRecyclerView() {
val showGifResolutions = arrayListOf()

// 获取用户选择的图片列表,初始化 showGifResolutions

// ...

var lineLength = GifTools.JointGifSquareLineLength[gifUris!!.size]

bind.jointGifPreviewRecyclerView.setContent {
JointGifSquare(
lineLength,
gifUris!!
)
}
}

其中,GifTools.JointGifSquareLineLength 是我定义的一个 HashMap 用来存放所有图片数量与每一行数量的对应关系:


val JointGifSquareLineLength = hashMapOf(4 to 2, 9 to 3, 16 to 4, 25 to 5, 36 to 6, 49 to 7, 64 to 8, 81 to 9, 100 to 10)

从上面可以看出,其实要从 compose 中拿到 View 的数据也很简单,直接传值进去即可。


最终运行效果:


g1.gif


原本使用 view 的运行效果:


g2.gif


可以看到,使用 compose 重构后的排列方式才是符合我们预期的排列方式。


总结


自此,我们就完成了将 View 中的其中一个界面替换为使用 compose 实现,也就是混合使用 view 和 compose 。


其实这个功能还有两个特性没有移植,那就是支持点击预览中的任意图片后可以更换图片和长按图片可以拖拽排序。


这两个功能的界面实现非常简单,难点在于,我怎么把更换图片和重新排序图片后的状态传回给 View。


这个问题我们就留着以后再说吧。


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

探索环信IMuni-app(小程序)在集成中遇到的断联问题,如何保持较为稳定的websocket链接?

BB在前说一下写这个文章的契机吧,目前在自己负责的项目中尤其在移动端H5,以及uni-app开发小程序项目当中较为经常会遇到,登录环信IM之后长连接断开问题,主要出现的场景也较为集中,下面列举一下我复现出现的场景,附带有一些我的解决场景,不能保证百分百的解决大...
继续阅读 »

BB在前
说一下写这个文章的契机吧,目前在自己负责的项目中尤其在移动端H5,以及uni-app开发小程序项目当中较为经常会遇到,登录环信IM之后长连接断开问题,主要出现的场景也较为集中,下面列举一下我复现出现的场景,附带有一些我的解决场景,不能保证百分百的解决大家的问题,不求有功,但求有用。

常见复现的链接断开场景


异常场景一

用户长时间息屏,此类操作较为高频复现,操作步骤就是open环信IM,然后进入聊天界面,发送几条消息后直接熄灭手机屏幕长时间不再进行开屏操作(这个长时间怎么理解?五分钟十分钟甚至三十分钟都可能),然后重新打开评估直接进行消息发送,如果你使用的是4.x SDK 这时候发送消息直接会出现type 39 not login类报错,如果不是是3.x之类的SDK可能会发送不产生success消息回调,无任何反应。【高复现】

异常场景二
用户原来在聊天界面进行消息发送,退出聊天界面到后台进行挂起,然后就手机撂那了,对就是直接撂那了🤔,经过了一段时间等待用户又回到了聊天界面进行聊天,偶现消息发送无反应等待1、2秒自动进行了连接。【偶现】

异常场景三
用户退出聊天界面,开始进行其他应用内的操作,下班了,准备刷会B站,随手点开了微信,在微信里面回了一个小时的工作信息。(艹、血压开始高了),然后回到聊天界面发送消息出现没有反应。【长时间切出高复现】

异常场景四
长时间挂起中间可能出现了网络波动,自动进行了网络切换发现继续消息发送没有反应。【偶现】

异常场景五
用户发现4G信号不太好用,手动连接了一下WIFI,结果发现WIFI信号更差,又切了回来,结果发现这几通操作之后发送消息失败或者不触发消息成功回调。【较高复现】

解决时主要用到的方法&API

环信SDK当中提供的几种判断是否正在连接的API

/* 主动调用类 **/
WebIM.conn.open() //打开IM登录
WebIM.conn.close() //关闭IM连接
WebIM.conn.isOpened() //boolean false未连接 true连接
WebIM.logOut //boolean false在登录 true已退出
/* 被动触发类 **/
//消息监听,两种类型写法一种为3.x 一种为4.x
WebIM.conn.listen({
onOpened: function () {}, //连接成功回调
onClosed: function () {}, //连接关闭回调
})
WebIM.conn.addEventHandler("handlerId", {
onConnected: () => {
console.log("onConnected");
},
onDisconnected: () => {
console.log("onDisconnected"
);
})


2、【以uni-app为例】生命周期钩子函数

<script>
export default {
onShow: function() {

},
onHide: function() {

}
}
</script>

3、【以uni-app为例】网络状态变更监听API

uni.onNetworkStatusChange()


上述场景优化的方式方法


关于场景一、二、三的优化探索:

回顾一下场景一、二、三主要面临的问题是,用户在进入聊天页面操作后息屏了手机可能去忙其他的事情,或者进入到其他应用,后台挂起。
过了很长时间才再次恢复操作手机,进入到IM聊天页面,并且很有可能开屏直接就展示了聊天界面并且进行继续聊天用户用户在整个操作流程中是无感知的并且应该是无感知的。
这里分享一个在探索无感知重连过程中,实际使用并且有效的解决方案,以及代码片段。
1、IM登录后全局保留登录状态,可以存储在globdata或者Vuex之类的全局状态管理方法里。
2、在App.vue的根组件中增加onShow生命周期钩子函数,或者在IM相关页面中增加onShow钩子函数,主要目的想必大家也已经猜到了,每次用户开屏或者进入到页面应用中都会触发onShow钩子函数,在此钩子函数中获取当前应用的登录状态,并且调用SDK内部提供的获取SDK本地连接状态的api去进行判断是否需要手动补偿登录,话不多说上代码。

<script>
export default {
globalData: {
isLoginIM: false
},
onShow() {
console.log('>>>>>this.isLoginIM', this.globalData.isLoginIM)
//判断的逻辑是如果全局已经是登录状态,但是获取当前SDK本地状态却为false未连接那么需要进行手动补偿重新登录。
if (this.globalData.isLoginIM && !uni.WebIM.conn.isOpened()) {
console.log('执行重连逻辑')
uni.WebIM.conn.close && uni.WebIM.conn.close();
//增加延时目的是为了,确保连接完全断开再去执行重新openIM操作
setTimeout(()=>{
this.loginIM()
},500)

} else {
console.log('不需要执行重新登陆')
}
},
}
</script>


关于场景四、五的优化探索:
场景四、五的点其实主要还是在弱网切换或者网络波动的情况下出现的连接层面问题,网络的稳定其实是较为不可控的,我们没有办法保证终端用户时刻保持在一个较为良好稳定的网络环境下,默认情况下,环信SDK是有重连逻辑在里面的,默认重连次数为5次,触发时机也基本为网络切换或者网络完全断开,重连结束之后就没有办法再进一步进行重连了。

下面探讨的优化是如果觉得切换网络后SDK的重连速度不能满足需求(其实在4.1.2后续的SDK版本中切网重连速度进行了优化,基本满足了实际需求,老的版本确实发现有切网连接较慢问题。),没有在第一时间就介入进行重连,所以通过监听网络层面的变化手动将其断开再进行IM连接的形式进行重连。

<script>
exprot default {
onLoad(){
//通过uni提供的网络状态变化监听,主动监听网络进行了变化就进行断开手动进行连接。
uni.onNetworkStatusChange((info) => {
console.log('>>>>>>>>>>>>>网络变化', info);
uni.showToast({
icon: 'none',
title: '网络变化',
});

uni.WebIM.conn.close();
console.log('>>>>>重新连接', this.login);
//加延时是断开是异步操作,有可能还未断开就进行了登录,此时登录是无效的。
setTimeout(() => {
this.login();
}, 500);
});
}
}
</script>


极端补偿连接的手段
这种方案在非常极端的情况下考虑使用,在App.vue中增加onShow,onHide钩子函数(加在App.vue根组件是因为全局退出进入必然会触发这个组件,不用担心在某个页面没加载的时候不触发的情况),应用息屏切出,都必然会触发onHide钩子函数,在这一步直接选择断开与环信的链接,不用担心断开后又收到消息的问题,因为离线后收到的消息是会存储在环信的离线服务器当中的,再次登录后会再次进行投递的,恢复页面后会触发onShow再次进行IM连接这样,就规避调用切出切入连接不稳定的问题,这种方案可以用但是不推荐,因为断开重连太过于频繁属于比较重的操作,并且据观察,不同机型上,可能选择发送图片从系统中选择文件都会触发全局的onHide,这种断开肯定是我们不希望的,而且通常上述方案就已经能够满足使用。

<script>
export default {
globalData:{
isChangeConnect:true //这个是因为在安卓上选择相册也会触发onHide onShow,所以增加个状态在选择发图片的时候更改,让其不触发断开重连。
},
onLaunch: function() {
console.log('App Launch')
this.listenIM()
},
onShow: function() {
console.log('App Show',this.globalData.isChangeConnect)
if (this.globalData.isChangeConnect) {
this.loginIM()
}
},
onHide: function() {
console.log('App Hide',this.globalData.isChangeConnect)
if (this.globalData.isChangeConnect) {
this.closeIM()
}
},
</script>


BB在后
上面是我遇到的一些场景优化手段,为什么是探索呢?因为有些点也是在进行尝试的手段,不一定是最佳实践,也不一定必然能够解决全部场景的问题,我看这个还能重新再编辑,后续如果有更高的形式,或者方案再加进去,或者在评论区与大家共同探讨,如果觉得这篇小破文对你有帮助,留个赞吧。


收起阅读 »

你还在用merge么,了解了解rebase吧

前言 Git作为我们日常开发代码的版本管理,开发分支的管理方面起着很大作用,我们开发过程中分支通常有生产、预发、测试、开发这几个分支,我们会根据项目进行的某个阶段,将代码提交到某个版本上,正常流程是先开发 —>测试 —>预发—>生产,但是通常...
继续阅读 »

前言


Git作为我们日常开发代码的版本管理,开发分支的管理方面起着很大作用,我们开发过程中分支通常有生产、预发、测试、开发这几个分支,我们会根据项目进行的某个阶段,将代码提交到某个版本上,正常流程是先开发 —>测试 —>预发—>生产,但是通常会有很多版本,有先后上线顺序,并且我们的开发人员也会是多个,在各种因素下项目的开发版本远程分支,以及开发人员的本地分支管理就由为的关键。


普通开发流程


正常一个版本需要经过的几个阶段,分别是dev、test、uat、master,我们通过下面流程图这么做是没什么问题的,每个阶段去将从master拉取的版本分支,push到对应的分支上进行发布,正常预发和生产环境的代码应该保持一致,test分支由于会有多个版本并行开发,所以代码和预发和生产比起来会有一些不一样。


B6C4A36C-1922-4B72-954E-414C41A8D7D5.png


多版本并行开发


在多个版本并非开发的时候,对分支的管理就不像上面那么简单了,涉及到多个version,这些版本的上线时间节点也是不同的,意味着上test和uat的时间节点也是不一样的。


这里涉及到多种情况



  1. 在后端开发人员较少的情况下,通常2-3人为例,完全可以从master拉取一个开发分支,分支格式已 服务名+上线时间,例如xxx_20230130这个本地分支,后端开发人员一起在这个分支上进行并行开发,开发阶段将自己的本地分支merge到dev分支,因为只有2-3人所以冲突解决起来还好,有冲突解决冲突。

  2. 后端开发人员较多的情况,通常在5-8人为例,这时候从master分支拉取分支,分支格式就需要已 服务名+姓名缩写+上线时间来命名,尽量每个人在自己命名的分支下进行开发,这样在开发阶段本地测试的时候,可以做到相互不影响,但是在merge到远程分支的时候,解决代码冲突的时候需要认真仔细一些,这种活还是交给心细的人来做吧,测试的时候也需要根据版本上线的优先级进行测试。

  3. 版本比较多的情况,比如一个月会有4-5个版本的开发,那么上线时间也是分4-5个节点,这样就需要每次从先发上线的远程分支,将代码merge到下个版本的本地开发分支上,以此类推。


58C8F39E-4161-4FBA-9861-CF48E436F5AF.png


Git merge


作为git 合并分支的命令,也是在日常开发过程中经常用到的一个命令,通常我们会将拥有最新代码的一个版本merge到较老的一个版本,实现版本同步。


3EE8E0C2-67C1-4136-9703-67726D5B1005.png


大体就是这么一个步骤,从刚开始的公共分支,变为master和feature分支,
通过git merge master 命令将master分支merge到feature分支。
Merge命令会将前面featrue分支所有的commit提交全部合并为一个新的commit提交。
⚠️这里只有会在产生冲突的时候,才能产生新的commit记录。


可以理解为git pull =git fetch +git merge,拉取最新的远程分支,然后将这个分支合并到另一个分支。


在公司开发的时候,通常大家喜欢这个命令,因为简单粗暴,直接将其他分支合并到自己分支,简单好理解。


Git rebase


作为自己的个人喜好,比较喜欢rebase这个命令,核心理念就是“变基”。


3F362A81-B158-4CCA-86A8-FA7715E2FDF7.png



  1. 由上图可看见,通过reabse命令将feature分支延续到了master分支后面。

  2. 在多人开发过程中,如果其他人在master进行commit,这个时候你在feature分支提交了几个commit,这时候你使用rebase命令,会将你的commit提交记录放在master的commit记录的后面,而merge就会将不同分支的commit合并成一个新的commit记录,这就是merge和rebase的不同点。

  3. 本地feature分支和远端的master分支如果是同一条分支的话,可以使用rebase,保证commit的记录的清晰性,这个很关键!


⚠️不要在公共分支使用rebase命令,这样会污染公共分支,这样公共分支就会存在你的commit记录,别人拉取的时候会存在你的最新的commit记录。


总结


在开发中不仅需要代码质量高,在版本管理上也是由为的重要,上线前漏掉代码的事情,相信大家都曾遇到过,但是这种事情是很危险⚠️的,希望此文章能给大家在日常代码版本管理中提交警惕,合理合并分支,最后祝大家在新的一年,少出bug、多多学习、多多进步。


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

谷歌的bug:当 CompileSdk 33 遇上Kotlin

最近项目里compose 要升级到1.3, 要求compile sdk 也要到33版本,大家都知道 一般情况下,我们修改compilesdk 都不会有什么问题,最多就是一些api的适配,编译不过啥的, 但是不会引发线上故障,但是这里要注意了target sd...
继续阅读 »

最近项目里compose 要升级到1.3, 要求compile sdk 也要到33版本,大家都知道 一般情况下,我们修改compilesdk 都不会有什么问题,最多就是一些api的适配,编译不过啥的, 但是不会引发线上故障,但是这里要注意了target sdk 的修改 就要复杂的多了, 这里不多说,只介绍一下 我碰到的一个compilesdk 33的问题


在Compile sdk 33版本中,这个手势监听的接口 代码发生了一些变化:


image.png


在这些接口方法说 参数前面加上了一个NonNull的 注解,这个注解的意思就是 告诉开发者 这个参数不可能为空


image.png


注意了 在<=32的版本中 这个注解是没有的


image.png


对于java的开发者来说,这个影响微乎其微,但是如果你跟我一样是kotlin的开发者就要倒霉了,


因为在<=32的时候 你继承这个接口的时候 会提示你参数要定义成可空的


但是当你升级到33的sdk的时候,你就会发现编译不过了


image.png


为啥?


因为33的sdk 前面说过了,方法前面有了 不可空的注解了


要让他编译过很简单 我们只要把? 去掉即可


image.png


到这里还没结束,最坑的地方来了, 虽然你能编译过,但是在运行时,有可能会发生crash


image.png


为啥? 熟悉kotlin的人就知道了,当你定义一个参数为不可空的类型的时候,你如果传了一个null给这个参数,他就会报这个crash了,这种情况常见于 java代码调用kotlin代码的时候 这是kotlin编译器的魔法,有兴趣的可以自己反编译看一下字节码,实际上,当你定义一个变量为不可空的时候,如果传值给他 他就会校验这个值 是不是为null 为null 则直接抛异常


搞清楚问题所在以后 就得想想怎么解决了,目前的情况就是 如果不改,就编译不过,改了 在运行时会crash


另外:
这里有个链接,可以看下该问题的讨论,目前状态是显示 谷歌承认了该bug,看状态显示fixed,但是不知道为什么
还没有推送最新的33 sdk
issueTracker


实际上解决这个问题的方法有很多,


方法1: 这个接口的实现 我们不用kotlin写,用java写,即可 这个方案最简单,但是不太优雅


方法2: 魔改下android sdk 33版本的jar包,把注解去掉 这个方案也可以,但是有点麻烦


方法3: asm 字节码修改,把那个校验参数为null 就抛异常的代码删了就行了。 杀鸡焉用牛刀


方法4: 写一个delegate 即可,以后都用这个代理类去做监听, 这个方法我认为是最简单的,一劳永逸,而且成本极低



import android.content.Context;
import android.os.Handler;
import android.view.GestureDetector;
import android.view.MotionEvent;

import androidx.annotation.Nullable;

/**
* 在compile sdk 33 中 修复google的一个注解bug,该bug 会导致 要么kotlin代码编译失败
* 要么运行时crash,这里用代理模式 简单的规避此问题即可
*
*/
public class GestureDetectorDelegate extends GestureDetector {
/**
* @param listener
* @param handler
* @deprecated
*/
public GestureDetectorDelegate(OnGestureListener listener, Handler handler) {
super(listener, handler);
}

/**
* @param listener
* @deprecated
*/
public GestureDetectorDelegate(OnGestureListener listener) {
super(listener);
}

public GestureDetectorDelegate(Context context, OnGestureListenerDelegate listener) {
super(context, listener);
}

public GestureDetectorDelegate(Context context, OnGestureListener listener, Handler handler) {
super(context, listener, handler);
}

public GestureDetectorDelegate(Context context, OnGestureListener listener, Handler handler, boolean unused) {
super(context, listener, handler, unused);
}

/**
* 主要修改点就是在这里了,复写这些方法 标记这些参数为可空的即可
*/
public interface OnGestureListenerDelegate extends OnGestureListener {
boolean onDown(@Nullable MotionEvent e);

void onShowPress(@Nullable MotionEvent e);

boolean onSingleTapUp(@Nullable MotionEvent e);

boolean onScroll(@Nullable MotionEvent e1, @Nullable MotionEvent e2, float distanceX, float distanceY);

void onLongPress(@Nullable MotionEvent e);

boolean onFling(@Nullable MotionEvent e1, @Nullable MotionEvent e2, float velocityX, float velocityY);
}
}

方案5: 利用proguard混淆的配置规则


其实所谓的抛异常,就是kotlin在编译的时候 手动帮我们增加了判断是否null 然后抛异常的方法


image.png


那我们实际上最简单的方案就是 利用混淆的规则,在release包构建的时候 把这个代码去掉就可以了


-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void check*(...);
}

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

耗时一周,实现高仿微信渐变模糊效果——纯原生实现

最近一个需求要实现类似微信状态的模糊效果,还要求不能引入库,增加包的大小。网上搜了一圈,只有 Flutter 的实现。没办法只能自己开撸,实现效果如下,上面的图是我的实现效果,下面的是微信的实现效果。 实现原理 首先,我们观察一下下面的微信状态的实现效...
继续阅读 »

最近一个需求要实现类似微信状态的模糊效果,还要求不能引入库,增加包的大小。网上搜了一圈,只有 Flutter 的实现。没办法只能自己开撸,实现效果如下,上面的图是我的实现效果,下面的是微信的实现效果。



Screenshot_2022-11-12-14-46-40-11_2d4809a04714b92ad0ec5f736efb755b.jpg


956ac151237d8da1ad0bc9edebd6744a.jpg



实现原理


首先,我们观察一下下面的微信状态的实现效果。可以看出上部分是截取了头发部分进行了高斯模糊;而下面部分则是对围裙进行高斯模糊。


image.png


拿原图进行对比,我们可以发现,渐变高斯模糊的部分遮住了原图片,同时还有渐变的效果。最后,图片好像加了一层灰色的遮罩,整体偏灰。

接下来,我们要做的事情就清楚了。


第一步:选取原图片的上下两部分分别进行高斯模糊
第二步:自定义 OnDraw 方法,让高斯模糊的部分覆盖原图片的上下两部分
第三步:让高斯模糊的图片实现渐变效果


选取原图片的上下两部分分别进行高斯模糊


在开始高斯模糊前,我们需要先确定上下两部分的高度。需要注意的是,我们不能直接使用图片的高度,因为图片的宽不一定等于屏幕的宽度。因此,我们需要按照比例计算出图片缩放后的高度。代码如下:



//最后要求显示的图片宽度为屏幕宽度
int requireWidth = UIUtils.getScreenWidth(context);
int screenHeight = UIUtils.getScreenHeight(context);
//按照比例,计算出要求显示的图片高度
int requireHeight = requireWidth * source.getHeight() / source.getWidth();
int topOrBottomBlurImageHeight = (int) ((screenHeight - requireHeight) / 2 + requireHeight * 0.25f);

如下图所示,最后一步 (screenHeight - requireHeight) / 2 获取到缩放后的图片居中时的上下两部分的高度。但是,渐变高斯模糊的部分还需要增加 padding 来遮住原图片的部分内容,这里的 padding 取的是 requireHeight * 0.25f


企业微信截图_89ec2f08-1741-4789-9c7c-943be98e3f68.png


计算出高度后,我们还不能对图片直接进行高斯模糊,要先要对图片进行缩放。为什么要先进行压缩呢?有两点原因:



  1. 使用 RenderScript 进行高斯模糊,最大模糊半径是 25,模糊效果不理想

  2. 高斯模糊的半径超过 10 之后就有性能问题


为了解决上面的问题,我们需要先对图片进行缩放,再进行高斯模糊。核心代码如下,为了后面使用协程,这里是用 kotlin 实现的。



private val filter = PorterDuffColorFilter(Color.argb(140, 0, 0, 0), PorterDuff.Mode.SRC_ATOP)

private fun blurBitmap(
source: Bitmap,
radius: Int,
top: Boolean,
topOrBottomBlurImageHeight: Int,
screenHeight: Int,
context: Context?
): Bitmap? {

//第1部分
val cutImageHeight = topOrBottomBlurImageHeight * source.height / screenHeight
val sampling = 30

//第2部分
val outBitmap = Bitmap.createBitmap(source.width / sampling,
cutImageHeight / sampling, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBitmap)
canvas.scale(1 / sampling.toFloat(), 1 / sampling.toFloat())
val paint = Paint()
paint.flags = Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG
//过滤颜色值
paint.colorFilter = filter
val dstRect = Rect(0, 0, source.width, cutImageHeight)
val srcRect: Rect = if (top) {//截取顶部
Rect(0, 0, source.width, cutImageHeight)
} else {//截取底部
Rect(0, source.height - cutImageHeight, source.width, source.height)
}
canvas.drawBitmap(source, srcRect, dstRect, paint)

//高斯模糊
val result = realBlur(context, outBitmap, radius)

//创建指定大小的新 Bitmap,内部会对传入的原 Bitmap 进行拉伸
val scaled = Bitmap.createScaledBitmap(
result,
(source.width),
(cutImageHeight),
true)
return scaled
}

代码看不懂?没关系,下面会一一来讲解:


第1部分,这里定义了两个本地变量 cutImageHeightsamplingcutImageHeight 是要裁剪图片的高度,sampling 是缩放的比例。你可能会奇怪 cutImageHeight 的计算方式。如下图所示,cutImageHeight 是用 topOrBottomBlurImageHeight 占屏幕高度的比例计算的,目的是让不同的图片裁剪的高度不同,这也是微信状态模糊的效果。如果你想固定裁剪比例,完全可以修改 cutImageHeight 的计算方式。


image.png


第2部分,这里就做了一件事,就是截取原图的部分并压缩。这里比较难理解的就是为什么创建 Bitmap 时,它的宽高已经缩小了,但是还需要调用 canvas.scale。其实,canvas.scale 只会作用于 canvas.drawBitmap 里的原 Bitmap


高斯模糊这里可以采取你项目里之前使用的方式就行,如果之前没做过高斯模糊,可以看Android图像处理 - 高斯模糊的原理及实现。这里使用的是 Google 原生的方式,代码如下:


@Throws(RSRuntimeException::class)
private fun realBlur(context: Context?, bitmap: Bitmap, radius: Int): Bitmap {
var rs: RenderScript? = null
var input: Allocation? = null
var output: Allocation? = null
var blur: ScriptIntrinsicBlur? = null
try {
rs = RenderScript.create(context)
rs.messageHandler = RenderScript.RSMessageHandler()
input = Allocation.createFromBitmap(
rs, bitmap, Allocation.MipmapControl.MIPMAP_NONE,
Allocation.USAGE_SCRIPT
)
output = Allocation.createTyped(rs, input.type)
blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
blur.setInput(input)
blur.setRadius(radius.toFloat())
blur.forEach(output)
output.copyTo(bitmap)
} finally {
rs?.destroy()
input?.destroy()
output?.destroy()
blur?.destroy()
}
return bitmap
}

还有一点细节,由于我们给高斯模糊的图片加了 filter ,为了保持一致性。我们也需要给原 Bitmap 进行过滤。代码如下:


private fun blurSrc(bitmap: Bitmap): Bitmap? {
if (bitmap.isRecycled) {
return null
}
val outBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBitmap)
val paint = Paint()
paint.flags = Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG
paint.colorFilter = filter
canvas.drawBitmap(bitmap, 0f, 0f, paint)
return outBitmap
}

最后,我们可以使用协程来获取处理后的 Bitmap ,代码如下


fun wxBlurBitmap(source: Bitmap, topOrBottomBlurImageHeight: Int, screenHeight: Int, context: Context?, imageView: BlurImageView) {
if(source.isRecycled) {
return
}
GlobalScope.launch(Dispatchers.Default) {
val time = measureTimeMillis {
val filterBitmap = async {
blurSrc(source)
}
val topBitmap = async {
blurBitmap(source, 10, true, topOrBottomBlurImageHeight, screenHeight, context)
}
val bottomBitmap = async {
blurBitmap(source, 10, false, topOrBottomBlurImageHeight, screenHeight, context)
}
val src = filterBitmap.await()
val top = topBitmap.await()
val bottom = bottomBitmap.await()
launch(Dispatchers.Main) {
if(top == null || bottom == null) {
imageView.setImageBitmap(source)
} else {
imageView.setBlurBitmap(src, top, bottom, topOrBottomBlurImageHeight)

}

}
}
}
}

自定义 ImageView


上面的操作,我们获得了3个 Bitmap,要把它们正确的摆放就需要我们自定义一个 ImageView。如果对自定义 View 不了解的话,可以看看扔物线大佬的 Hencoder 的自定义View系列 教程。代码如下:


public class BlurImageView extends androidx.appcompat.widget.AppCompatImageView {

private Bitmap mSrcBitmap;
private Bitmap mTopBlurBitmap;
private Bitmap mBottomBlurBitmap;
private Matrix mDrawMatrix;
private Paint mPaint;
private Shader mTopShader;
private Shader mBottomShader;
private PorterDuffXfermode mSrcPorterDuffXfermode;
private PorterDuffXfermode mBlurPorterDuffXfermode;
private int mTopOrBottomBlurImageHeight;

public BlurImageView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}

/**
* 设置图片
* @param src 原图片的 Bitmap
* @param top 原图片top部分的 Bitmap
* @param bottom 原图片bottom部分的 Bitmap
* @param topOrBottomBlurImageHeight 模糊图片要求的高度
*/
public void setBlurBitmap(Bitmap src, Bitmap top, Bitmap bottom, int topOrBottomBlurImageHeight) {
this.mSrcBitmap = src;
this.mTopBlurBitmap = top;
this.mBottomBlurBitmap = bottom;
this.mTopOrBottomBlurImageHeight = topOrBottomBlurImageHeight;
invalidate();
}

private void init() {
mPaint = new Paint();
mDrawMatrix = new Matrix();
mPaint.setAntiAlias(true);
mSrcPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP);
mBlurPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP);
}

@Override
protected void onDraw(Canvas canvas) {
if(mSrcBitmap == null || mTopBlurBitmap == null || mBottomBlurBitmap == null) {
super.onDraw(canvas);
return;
}
if(mSrcBitmap.isRecycled() || mTopBlurBitmap.isRecycled() || mBottomBlurBitmap.isRecycled()) {
mSrcBitmap = null;
mTopBlurBitmap = null;
mBottomBlurBitmap = null;
return;
}

int save = canvas.saveLayer(null, null, Canvas.ALL_SAVE_FLAG);

//第1部分
final int srcWidth = mSrcBitmap.getWidth();
final int srcHeight = mSrcBitmap.getHeight();
final int topWidth = mTopBlurBitmap.getWidth();
final int topHeight = mTopBlurBitmap.getHeight();
final int contentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
final int contentHeight = getHeight() - getPaddingTop() - getPaddingBottom();
float scrBitmapScale = (float) contentWidth / (float) srcWidth;
float srcTopOrBottomPadding = (contentHeight - srcHeight * scrBitmapScale) * 0.5f;
int requireBlurHeight = mTopOrBottomBlurImageHeight;
float overSrcPadding = requireBlurHeight - srcTopOrBottomPadding;//要求的模糊图片的高度
float dx = 0;//缩放后的模糊图片的x方向的偏移
float dy = 0;//缩放后的模糊图片的y方向的偏移
float blurScale = 0;//高斯模糊图片的缩放比例
if(requireBlurHeight * topWidth >= topHeight * contentWidth) {
//按照高缩放
blurScale = (float) requireBlurHeight / (float) topHeight;
dx = (contentWidth - topWidth * blurScale) * 0.5f;
} else {
//按照宽缩放,因为按照高缩放时,当前Bitmap无法铺满
blurScale = (float) contentWidth / (float) topWidth;
dy = (requireBlurHeight - topHeight * blurScale) * 0.5f;
}

//第2部分
//绘制上面模糊处理后的图片,注意如果作为RecyclerView的Item,则不能复用mTopShader,
//需要每次 new 一个新的对象
if(mTopShader == null) {
mTopShader = new LinearGradient((float) contentWidth / 2, requireBlurHeight, (float) contentWidth / 2, srcTopOrBottomPadding, new int[]{
0x00FFFFFF,
0xFFFFFFFF
}, null, Shader.TileMode.CLAMP);
}
mPaint.setShader(mTopShader);
mDrawMatrix.setScale(blurScale, blurScale);
mDrawMatrix.postTranslate(Math.round(dx), Math.round(dy));
canvas.drawBitmap(mTopBlurBitmap, mDrawMatrix, null);
mPaint.setXfermode(mBlurPorterDuffXfermode);
canvas.drawRect(0, srcTopOrBottomPadding, contentWidth, requireBlurHeight, mPaint);
//绘制下面模糊处理后的图片
float padding = contentHeight - requireBlurHeight;
mDrawMatrix.setScale(blurScale, blurScale);
mDrawMatrix.postTranslate(Math.round(dx), Math.round(padding + dy));
canvas.drawBitmap(mBottomBlurBitmap, mDrawMatrix, null);
//注意如果作为RecyclerView的Item,则不能复用mBottomShader,
//需要每次 new 一个新的对象
if(mBottomShader == null) {
mBottomShader = new LinearGradient((float) contentWidth/2, padding + overSrcPadding, (float) contentWidth/2, padding, new int[]{
0xFFFFFFFF,
0x00FFFFFF
}, null, Shader.TileMode.CLAMP);
}
mPaint.setShader(null);
mPaint.setShader(mBottomShader);
canvas.drawRect(0, padding + overSrcPadding, contentWidth, padding, mPaint);

//绘制中间的原图
mPaint.setShader(null);
mPaint.setXfermode(mSrcPorterDuffXfermode);
float srcScale = (float) contentWidth / (float) srcWidth;
mDrawMatrix.setScale(srcScale, srcScale);
mDrawMatrix.postTranslate(0, Math.round(srcTopOrBottomPadding));
canvas.drawBitmap(mSrcBitmap, mDrawMatrix, mPaint);
canvas.restoreToCount(save);
}
}

BlurImageView 得核心代码在 onDraw 里面。我们按照上面注释的顺序,一个一个来分析:


第1部分,我们声明了几个变量,用来辅助计算。为了方便理解,我画了如下示意图:


image.png


srcTopOrBottomPadding: 是原图按照比例缩放、居中摆放时空白的高度
overSrcPadding: 是模糊图片遮罩原图片的高度,也就是渐变模糊图片的高度
dx: 按照高度缩放时,缩放后的模糊图片的x方向的偏移
dy: 按照宽缩放时,缩放后的模糊图片的y方向的偏移
blurScale: 图上没有标出,是高斯模糊图片的缩放比例。确保高斯模糊的图片能够铺满


第2部分,这里的作用是绘制上下两部分的模糊图片,并对图片的部分进行渐变处理。以上面部分的图片为例,第一步先绘制已经处理好的 mTopBlurBitmap,这里设置了 Matrix ,在绘制过程中会对图片进行缩放和移动,让图片的位置摆放正确。第二步就是对部分图片进行渐变处理,这里合成模式选择了 DST_ATOP


最后一步绘制中间的原图,就大功告成了,点击启动就能看到渐变模糊效果了。文章最后就求一个免费的赞吧🥺🥺


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

Kotlin系列之听说这个函数的入参也是函数?

整洁是kotlin语法的一大特性,它主要体现在扩展函数,中缀调用,运算符重载,约定,以及对lambda表达式的活用,其实在用java写Android的时候我们已经遇到过lambda了,比如当你在设置一个控件的点击监听事件的时候 我们通常会这样写,然后就发现在...
继续阅读 »

整洁是kotlin语法的一大特性,它主要体现在扩展函数,中缀调用,运算符重载,约定,以及对lambda表达式的活用,其实在用java写Android的时候我们已经遇到过lambda了,比如当你在设置一个控件的点击监听事件的时候


image.png
我们通常会这样写,然后就发现在点击事件的参数部分,代码变灰然后还有一条波浪线,提示说匿名类View.OnClickListener()可以被替换成lambda表达式,所以我们就按照提示将这个点击事件转换成lambda


bindingView.button.setOnClickListener(v -> {});

很简洁的一行代码就生成了,其中v是参数,后面箭头紧跟着一个花括号,花括号里面就是你要写的逻辑代码,相信这个大家都清楚,而在kotlin中,做了进一步简化,它可以将这个lambda表达式放在括号外面,并且可以将参数省略


bindingView.button.setOnClickListener {}

代码更加简洁了,而lambda在kotlin中的表现远远不止这些,还可以将整个lambda作为一个函数的参数,典型的例子就是在使用标准库中的filter,map函数,或者Flow里面的操作符,举个例子,在一个名字的集合中,我们要对这个集合做一个过滤的操作,首字母为s的才可以被输出,代码如下


listOf("shifang","zhaoerzhu","sundashen").filter { it.startsWith("s") }

在这个例子中filter函数就是接收了一个lambda参数,我们将整个lambda表达式显示出来就是这样


listOf("shifang","zhaoerzhu","sundashen").filter { it -> it.startsWith("s") }

所以在kotlin中,将类似于filter这样可以接受lambda或者函数引用作为参数的函数,或者返回值是lambda或者函数引用的函数,称之为高阶函数,这篇文章,会从以下几点慢慢介绍高阶函数



  • 什么是函数类型

  • 如何去调用一个高阶函数

  • 给函数类型设置默认值

  • 返回值为函数类型的高阶函数

  • 内联函数

  • inline,noinline和crossinline修饰符

  • 在lambda中使用return


函数类型


我们刚开始学敲代码的时候,基本都是从数据类型开始学的,什么整数类型,浮点数类型,布尔值类型,都很熟悉了已经,到了kotlin这边,又多出来了一个函数类型,这是啥?我们刚刚说到filter是高阶函数,而入参是函数的才能被叫做是高阶函数,所以我们看看filter这个函数里面长什么样子的


ac1.png
我们看到filter的参数部分,predicate是变量,而冒号后面就是跟的参数类型了,我们终于看到函数类型长啥样了,一个括号,里面跟一个泛型T,其实也就是函数的参数类型,后面一个箭头,箭头后面跟着返回值类型,所以我们声明一个函数类型的变量可以这样做


val findName : (String) -> Boolean
val sum : (Int,Int) -> Int

括号里面就是函数的参数类型跟参数数量,箭头后面是函数的返回值类型,这个时候我们在想一个问题,既然是函数类型,那肯定接受的就是一个函数,我们知道在kotlin中一个函数如果什么也不用返回,那么这个函数的返回值可以用Unit的来表示


fun showMessage():Unit {
println()
}

但通常我们都是省略Unit


fun showMessage() {
println()
}

那是不是函数类型里面,返回值如果是Unit,我们也可以省略呢?这样是不行的,函数类型中就算这个函数什么都不返回,我们也要显示的将返回类型Unit表示出来,同样的,如果函数没有参数,也要指定一个空的括号,表示这个函数无参


val showMessage:() -> Unit

到了这里,我们就已经清楚了为什么在lambda表达式里{x,y -> x+y},或者开头那个例子,filter函数中{ it -> it.startsWith("s"),变量的类型都省略了,那就是因为这些变量类型已经在函数类型的声明中被指定了


当然函数类型也是可以为空的,同其他数据类型一样,当你要声明一个可空的函数类型的时候,我们可以这样做


val sum : (Int,Int) -> Int?

上述代码其实犯了一个错误,它并不能表示一个可空的函数类型,它只能表示这个函数的返回值可以为空,那如何表示一个可空的函数类型呢?我们应该在整个函数类型外面加一个括号,然后在括号后面指定它是可以为空的,像这样


val sum : ((Int,Int) -> Int)?

调用高阶函数


知道了函数类型以后,我们就要开始去手写高阶函数了,比如现在有一个需求,要求编辑框内输入的内容里面只能包含字母以及空格,其他的都要过滤掉,那我们就给String添加一个扩展函数吧,这个函数接受一个函数类型的变量,这个函数类型的参数是一个字符,返回类型是一个布尔值,表示符合条件的字符才可以被输出,我们看下这个函数如何实现


fun String.findLetter(judge:(Char) -> Boolean):String{
val mBuilder = StringBuilder()
for(index in indices){
if(judge(get(index))){
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

内部实现就是这样,对输入的字符串逐个字符进行遍历,通过调用judge函数来判断每个字符,符合条件的就输出,不符合的就过滤掉,高阶函数有了,我们现在去调用它


println("what 8is4 ko4tli3n".findLetter { it in 'a'..'z' || it == ' ' })
复制代码

整个花括号里面就是一个函数,它作为一个参数传递给findLetter,我们看下运行结果


 I  what is kotlin

完全按照条件输出,这样做的好处就是,如果下次需求变了,要求空格也不能输出,那么我们完全不需要去更改findLetter的代码,只需要更改一下作为函数类型的函数就可以了,就像这样


println("what 8is4 ko4tli3n".findLetter { it in 'a'..'z' })

运行结果就变成了


I  whatiskotlin

我们再换个例子,刚刚是给String定义了一个类似于过滤作用的函数,现在去定义一个映射作用函数,比如给输入的内容每个字符之间都用逗号隔开,我们该怎么做呢


fun String.turn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices){
if(index != indices.last){
mBuilder.append(addSplit(get(index)))
}else{
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

代码与findLetter相似,稍微做了一点变化,我们看到这个高阶函数的入参类型变成了(Char)->String,表示输入一个字符,返回的是一个字符串,函数类型addSplit在这里就充当着一个字符串的角色,我们看下如何去调用这个高阶函数


println("abcdefg".turn { "${it}," })

我们看见turn后面的花括号里面就一个字符串,这个字符串是每个字符后面追加一个逗号,我们看下运行结果


 I  a,b,c,d,e,f,g

函数类型的默认值


对于映射函数turn,我们再改下需求,某些场景下,我们输入什么就希望输出什么,比如用户设置昵称,基本是没有任何条件限制的,我们改造下turn函数,让它可以接收空的函数类型,那这个我们在刚刚函数类型那部分讲过,只需要在整个函数类型外面加个括号,然后加上可空标识就好了,改造完之后turn函数就变成了这样


fun String.turn(addSplit: ((Char) -> String)?): String {
val mBuilder = StringBuilder()
for (index in indices){
if(index != indices.last){
mBuilder.append(addSplit?.let { it(get(index)) })
}else{
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

看起来没什么问题,但是当你去调用这个turn函数,不传入任何函数类型参数的时候,我们发现代码提示报错了


ac2.png
理由是addSplit这个参数一定要有个值,也就是说必须得传点啥吗?也不一定,我们知道kotlin函数中,参数是可以设置默认值的,那么函数类型的参数当然也可以设置默认值,就算什么也不传,它默认有一种实现方式,这样不就好了吗,我们再改下turn函数


fun String.turn(addSplit: ((Char) -> String)? = { it.toString() }): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit?.let { it(get(index)) })
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

这样就不报错了,默认输入啥就输出啥,我们看下运行结果


 I  abcdefg

函数类型作为返回值


刚刚我们举的例子是作为入参的函数类型,现在我们看下作为返回值的函数类型,这个其实我们平时开发当中也经常遇到,比如在一段代码中由于某个或者某几个条件,决定的不是一个值,而是会走到不同的逻辑代码中,这个时候我们脑补下如果这些代码都写在一起那是不是一个函数就显的比较臃肿了,可读性也变差了,所以我们就像return某一个值一样,将一段逻辑代码也return出去,这样代码逻辑就显的清晰很多,我们新增一个combine函数,返回值是函数类型


fun String.combine(): (String) -> String {
val mBuilder = StringBuilder()
return {
mBuilder.append(it)
for (index in indices) {
if (index != indices.last) {
mBuilder.append("${get(index)},")
} else {
mBuilder.append(get(index))
}
}
mBuilder.toString()
}
}

combine不接收任何参数了,返回值变成了(String) -> String,我们现在尝试着调用combine函数看看会输出什么呢


println("abcdefg".combine())

  I  Function1<java.lang.String, java.lang.String>

我们看到并没有输出期望的结果,这个是为什么呢?我再回到代码中看看


image.png
我们发现在返回值代码的边上,标明的这个返回值是一个lambda,并不是一个String,这个也就是函数类型作为返回值造成的结果,返回的是一个函数,函数你不去执行它,怎么可能会有结果呢,所以执行这个函数的方法就是调用invoke


println("abcdefg".combine().invoke("转换字符串:"))

invoke方法我们还是很熟悉的,在java里面去反射某一个类里面的方法的时候,最终去执行这个method就是用的invoke,而kotlin里面的invoke其实还是一个约定,当lambda要去调用invoke函数去执行lambda本身的函数体时,invoke可以省略,直接在lambda函数体后面加()以及参数,至于约定这里就不展开说了,我会另起一篇文章单独讲,所以上面的代码我们还可以这样写


println("abcdefg".combine()("转换字符串:"))

两种写法的运行结果都一样的,结果都是


 I  转换字符串:a,b,c,d,e,f,g

内联函数


lambda带来的性能开销


我们刚刚看到一个lambda的函数需要调用invoke方法才可以执行,那么这个invoke方法从哪里来的呢?凭什么调用它这个函数就可以执行了呢?我们将之前写的代码转换成java找找原因


public static final Function1 combine(@NotNull final String $this$combine) {
final StringBuilder mBuilder = new StringBuilder();
return (Function1)(new Function1() {
public Object invoke(Object var1) {
return this.invoke((String)var1);
}

@NotNull
public final String invoke(@NotNull String it) {
Intrinsics.checkNotNullParameter(it, "it");
mBuilder.append(it);
int index = 0;
for(int var3 = ((CharSequence)$this$combine).length(); index < var3; ++index) {
if (index != StringsKt.getIndices((CharSequence)$this$combine).getLast()) {
mBuilder.append("" + $this$combine.charAt(index) + ',');
} else {
mBuilder.append($this$combine.charAt(index));
}
}
String var10000 = mBuilder.toString();
return var10000;
}
});
}

通过反编译我们看到,原来这个lambda表达式就是定义了一个回调方法是invoke的匿名类Function1,Function后面跟着的1其实就是参数个数,我们点到Function1里面看看


public interface Function1<in P1, out R> : Function<R> {
/** Invokes the function with the specified argument. */
public operator fun invoke(p1: P1): R
}

现在我们知道刚刚没有调用invoke方法的时候,为什么会输出那一段信息了,其实那个就是把整个接口名称输出打印出来,只有调用了invoke这个回调方法,才会真正的去执行逻辑代码,把真正的结果输出,与此同时,我们注意到在反编译代码中,每一次调用turn函数,都会生成一个Function1的对象,如果被多次调用的话,很容易会造成一定的性能损耗,针对这种情况,我们应该怎么去避免呢


inline


针对lamnda带来的性能开销,kotlin里面会使用inline修饰符去解决,用法也很简单,只要在高阶函数的最前面用inline去修饰就好了,我们新增一个inlineturn函数,与turn函数相似,只是用inline去修饰


fun String.turn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

inline fun String.inlineturn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

两个函数的代码基本相似,只是inlineturn函数是用inline修饰的,在kotlin里面,对这种用inline修饰的高阶函数称之为内联函数,我们去调用下这两个函数,然后反编译看看有什么区别吧


kotlin代码
println("abcdefg".turn { "${it}," })
println("abcdefg".inlineturn { "${it}," })

反编译后的java代码
String $this$inlineturn$iv = StringKt.turn("abcdefg", (Function1)null.INSTANCE);
System.out.println($this$inlineturn$iv);

$this$inlineturn$iv = "abcdefg";
int $i$f$inlineturn = false;
StringBuilder mBuilder$iv = new StringBuilder();
int index$iv = 0;
for(int var6 = ((CharSequence)$this$inlineturn$iv).length(); index$iv < var6; ++index$iv) {
if (index$iv != StringsKt.getIndices((CharSequence)$this$inlineturn$iv).getLast()) {
char it = $this$inlineturn$iv.charAt(index$iv);
int var8 = false;
String var10 = "" + it + ',';
mBuilder$iv.append(var10);
} else {
mBuilder$iv.append($this$inlineturn$iv.charAt(index$iv));
}
}
String var10000 = mBuilder$iv.toString();
$this$inlineturn$iv = var10000;
System.out.println($this$inlineturn$iv);

我们看到turn方法不出所料,每次调用都会生成一个Function1的对象,而inlineturn函数反编译后我们发现,这不就是将invoke方法里面的代码复制出来放到外面来执行吗,所以现在我们知道内联函数的工作原理了,就是将函数体复制到调用处去执行,而此时,内联函数inlineturn的函数类型参数addSplit就不再是一个对象,而只是一个函数体了


noinline和crossinline


我们现在已经有了一个概念了,inline修饰符什么时候适合使用



  • 当函数是一个高阶函数

  • 由于编译器需要将内联函数体代码复制到调用处,所以函数体代码量比较小的时候适合用inline修饰


但有些场景下,即使函数是高阶函数,也是不推荐使用inline修饰符的,比如说你的函数类型参数需要当作对象传给其他普通函数


inline fun String.inlineturn(addSplit: (Char)->String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
turnAnother(addSplit)//这一行编译报错
return mBuilder.toString()
}

还有一种场景就是当你的函数类型参数是可空的


inline fun String.inlineturn(addSplit: ((Char)->String)?): String {//参数部分编译报错
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit?.let { it(get(index)) })
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

这两段代码都会编译报错,而报错的信息也基本一致,信息当中都会有这一句提示



Add 'noinline' modifier to the parameter declaration



到了这里我们遇到了一个新的修饰符noinline,从字面意思上并联系上下文,我们知道了这个noinline的作用,就是在内联函数中,使用noinline修饰的函数类型参数可以不参与内联,它依然是一个对象,反编译的时候它依然会被转成一个匿名类,尽管它是在一个内联函数中。
我们使用noinline修饰符更改一下inlineturn函数,然后再反编译看看java代码中的区别


String $this$inlineturn$iv = StringKt.turn("abcdefg", (Function1)null.INSTANCE);
System.out.println($this$inlineturn$iv);

$this$inlineturn$iv = "abcdefg";
Function1 addSplit$iv = (Function1)null.INSTANCE;
int $i$f$inlineturn = false;
StringBuilder mBuilder$iv = new StringBuilder();
int index$iv = 0;
for(int var7 = ((CharSequence)$this$inlineturn$iv).length(); index$iv < var7; ++index$iv) {
if (index$iv != StringsKt.getIndices((CharSequence)$this$inlineturn$iv).getLast()) { mBuilder$iv.append((String)addSplit$iv.invoke($this$inlineturn$iv.charAt(index$iv)));
} else {
mBuilder$iv.append($this$inlineturn$iv.charAt(index$iv));
}
}
StringKt.turnAnother(addSplit$iv);
String var10000 = mBuilder$iv.toString();
$this$inlineturn$iv = var10000;
System.out.println($this$inlineturn$iv);

我们看到原本是将函数体复制出来的地方,现在变成了生成一个Function1的对象了,说明addSplit对象已经不参与内联了,而这个时候我们注意到了,inlineturn函数前面的inline修饰符有了一个警告,提示说这个修饰符已经不需要了,建议去掉


image.png
对于这种警告我觉得还是不能去忽略的,因为我们已经在反编译的代码中看到了,尽管addSplit不参与内联,但还是会将函数体的代码复制出来,对于编译器来讲还是会有损耗的,所以这种情况下还是把inline和noinline修饰符去掉,让它变成一个普通的高阶函数


现在我们再换个场景,有时候一个函数类型的对象它执行起来比较耗时,我们不能让它在主线程运行,那就必须在将这个对象套在一个线程里面运行


inline fun String.inlineturn(addSplit: (Char)->String): String {
val mBuilder = StringBuilder()
Runnable{
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))//addSplit这边编译报错
} else {
mBuilder.append(get(index))
}
}
}
return mBuilder.toString()
}

我们发现这边又编译报错了,内联函数怎么回事啊?事儿这么多。。。我们看下这次报错提示是什么



Can't inline 'addSplit' here: it may contain non-local returns. Add 'crossinline' modifier to parameter declaration 'addSplit'



意思是不能对addSplit进行内联,原因是调用函数类型addSplit的地方与内联函数inlineturn属于不同的域,或者在inlineturn里面调用addSplit属于间接调用,所以在kotlin里面,如果内联函数中调用的函数类型,与内联函数本身属于间接调用的关系,那么函数类型前面需要加上crossinline修饰符,表示加强内联关系,我们修改一下inlineturn函数,给addSplit加上
crossinline修饰符,代码就变成了


inline fun String.inlineturn(crossinline addSplit: ((Char)->String)): String {
val mBuilder = StringBuilder()
Runnable {
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
}
return mBuilder.toString()
}

学到这里我相信不少人已经对高阶函数有了一个比较清晰的了解了,其实我们在学习Flow的时候已经接触过这些高阶函数和内联函数了,比如我们看下map操作符里面


image.png
map就是一个内联函数,而它里面的transform参数就是一个被crossinline修饰的函数类型的挂起函数,因为map里面的函数体必需要运行在一个协程域里面,而map又是运行在另一个协程域里面,map与transform之间属于间接调用的关系,这才用crossinline修饰


在lambda中使用return


现在给String再增加一个扩展函数,功能很简单,遍历String里面的每个字符,然后将字符在lambda的参数里面打印出来,同时要求如果遍历到字母,那么就停止打印。


fun String.filterAndPrint(filter:(Char) -> Unit){
for (index in indices) {
filter(get(index))
}
}

private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return
}
println(it)
}
println("outside of foreach")
}

代码大概就是这样去实现,但是我们发现写完代码后编译器在return的那个地方报错了,提示说这里不允许使用return



'return' is not allowed here



这个是什么原因呢,kotlin官方文档中有这么一段描述



要退出一个 lambda 表达式,我们必须使用一个标签,并且在 lambda 表达式内部禁止使用裸 return,因为 lambda 表达式不能使包含它的函数返回



kotlin为什么要这么设计呢?我们结合上面讲到的内联函数就清楚了,因为当我们在filterAndPrint函数里面return,退出的函数完全取决于它是不是内联函数,如果是,我们知道编译器会讲函数复制到外面调用处的位置,那么return的就是test函数,而如果不是内联,那么退出的就是filterAndPrint本身,所以对于这么一种可能会导致冲突的作法,kotlin就限制了在普通lambda表达式里面不能使用return,如果一定要用,必需加上标签,也就是在return后面加上@以及lambda所在的函数名,我们更改一下上面的test函数


private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return@filterAndPrint
}
println(it)
}
println("outside of foreach")
}

加上标签以后编译器不报错了,我们看下运行结果


 I  1
I 5
I 3
I 6
I 6
I 7
I outside of foreach

我们看到reutrn@filterAndPrint的时候并没有跳出test函数,只是跳过了a,继续循环打印后面的字符,这个就很想java里面continue的作法,但我们的需求不是这样描述的,我们希望遇到字母以后就不打印后面的字符了,也就是直接跳出test函数,没错,就是将filterAndPrint变成内联函数就好了


inline fun String.filterAndPrint(filter:(Char) -> Unit){
for (index in indices) {
filter(get(index))
}
}

private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return
}
println(it)
}
println("outside of foreach")
}

当lambda所在函数是内联函数的时候,lambda内部是可以return的,而且可以不用加标签,这个时候退出的函数就是调用内联函数所在的函数,也就是例子中的test(),我们把这种返回称为非局部返回,我们看下现在的运行结果


 I  1
I 5
I 3

现在这个才是我们想要的结果,现在回想一下当初刚开始学kotlin的时候,对没有break和continue关键字还有点不习惯,现在知道kotlin把这俩关键字去掉的原因了,因为完全不需要,一个return加上内联函数就够了,想在哪个地方退出循环就在哪个地方退出。


总结


这篇文章我们逐步从函数类型开始,慢慢的认识了高阶函数,会去写高阶函数,也掌握了inline,noinline,crossinline这些修饰符的作用以及使用场景,如果说之前你对高阶函数还很陌生的话,那么通过这篇文章,应该会对它熟悉一点了


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

从应用工程师的角度再谈车载 Android 系统

前言 根据中汽协数据显示,2022年8月中国汽车出口量达30.8万辆,同比增长65%,这也是历史上首次超过30万辆。从今年前八个月整体情况来看,我国汽车出口量已经超越德国,仅次于日本汽车出口量。其中,新能源汽车1-8月出口量同比增长超九成,贡献了重要的增量。...
继续阅读 »

前言



根据中汽协数据显示,2022年8月中国汽车出口量达30.8万辆,同比增长65%,这也是历史上首次超过30万辆。从今年前八个月整体情况来看,我国汽车出口量已经超越德国,仅次于日本汽车出口量。其中,新能源汽车1-8月出口量同比增长超九成,贡献了重要的增量。



众所周知,今年互联网行业发展的并不愉快,导致互联网行业就业形势不太理想,“开猿节流”的事情时有发生,于是不少Android开发萌生了转行做车载的想法,之前我其实写过一篇凑数用得 Android车载应用开发与分析(11)- 车载Android应用开发入门指南,这篇文章的初衷其实是劝Android开发的同学慎重转行搞车载!


不过还是有些和我一样是从手机应用转行做车载应用的同学读完后,希望我能再详细讲讲车载Android的学习,一些准备做车载的同学,也认为之前的博客写得太乱,于是决定从一个车载应用工程师的角度,重新来讲讲车载Android系统。


车载操作系统


汽车操作系统是从传统汽车电子不断演变而来的,传统汽车电子产品可分为两类:


一类是汽车电子控制装置,通过直接向执行机构(如电子阀门、继电器开关、执行马达)发送指令,以控 制车辆关键部件(如发动机、变速箱、动力电池)协同工作,这类系统一般统称为电子控制单元(ECU);


另一类是车载电子设备,如仪表、娱乐音响、导航系统、HUD等,这类系统不直接参与汽车行驶的控制 决策,不会对车辆行驶性能和安全产生影响,通常统称为车载信息娱乐系统(IVI)。这也是Android程序员主要负责的领域。


主流车载操作系统架构



当前国内主流车载操作系统的架构如上所示,左侧是汽车的中控、副驾屏幕,操作系统一般是Android,右侧是汽车的仪表屏幕,一般是QNX系统。



车载系统中还有一些Security、SOA、AutoSAR相关的模块,这些模块作为Android工程师属于知道了也插不上手,画出来也看不懂的东西,就全部省略了。



先来解释几个Android程序员可能不太熟悉的模块:


以太网


以太网(Ethernet),是一种计算机局域网技术,也是互联网从业者,天天打交道的东西。在汽车座舱中IVI硬件与其他硬件间通信有时需要借助以太网来实现,例如:MQTT、HTTP等。


CAN


控制器局域网 (Controller Area Network,简称CAN或者CAN bus) 是一种功能丰富的车用总线标准。被设计用于在不需要主机(Host)的情况下,允许网络上的单片机和仪器相互通信。 它基于消息传递协议,设计之初在车辆上采用复用通信线缆,以降低铜线使用量,后来也被其他行业所使用。


CAN 是车载领域很重要的一种通信总线,我们在中控屏上可以随时查看、设置车门、发动机、后备箱这些模块,其实就是借助CAN bus实现的,即使是Android程序员也经常要和它打交道,以后会详细讲讲这个东西。


MCU


微控制器单元,它负责着汽车很大一部分的功能,例如通过车载控制器对各项数据进行分析处理,以做出最优决策;负责对车辆的信息娱乐交互和运动控制等等。


总的来说,MCU可以应用于车辆的通讯、能源、存储、感知以及计算,对汽车行业有着重要的作用。


SOC


SoC的定义多种多样,由于其内涵丰富、应用范围广,很难给出准确定义。一般说来, SoC称为系统级芯片,也有称片上系统(System on Chip),意指它是一个产品,是一个有专用目标的集成电路,其中包含完整系统并有嵌入软件的全部内容。


车载Soc和常见的手机Soc非常类似,内部集成了CPU和GPU。目前最主流的车载Soc是高通的8155,它就是高通在手机Soc骁龙855的基础上发展而来的。


QNX


QNX是商业类Unix实时操作系统,主要针对嵌入式系统市场。QNX采取微核心架构,操作系统中的多数功能是以许多小型的task来执行,它们被称为server。这样的架构使得用户和开发者可以关闭不需要的功能,而不需要改变操作系统本身。


QNX的应用十分广泛,被广泛应用于汽车、轨道交通、航空航天等对安全性、实时性要求较高的领域,在汽车领域的市场占有率极高。



该产品开发于20世纪80年代初,后来改名为QNX软件系统公司,公司已被黑莓公司并购。



Hypervisor


一种运行在基础物理服务器和操作系统之间的中间软件层,可允许多个操作系统和应用共享硬件。也可叫做VMM( virtual machine monitor ),即虚拟机监视器。


目前国内主流的汽车座舱,都是在一个SOC上同时运行着两个不同特性的操作系统。对娱乐、应用生态有需求的中控、副驾一般由Android系统控制,而对稳定性、安全性要求较高的仪表盘,则由QNX系统直接控制,Android可以看做是一个运行在QNX上的虚拟系统,其底层技术原理就是Hypervisor。




其实以上说得这些都是从Android工程师角度看到的车载操作系统,实际上这只是车载操作系统的冰山一角,最底层的Other Hardware更能代表智能汽车操作系统的核心,它包含高级驾驶辅助系统、泊车辅助系统、自动驾驶系统、TCU、4G/5G网关、中央控制器等等。这些复杂的硬件与软件共同组成了一个智能汽车操作系统。


现代汽车的操作系统是如此的复杂,一些汽车的TCU、中央控制器甚至还额外运行着一套操作系统(例如linux),所以现在还没有哪一个汽车/主机厂商能够独立完成整套系统的开发,基本都需要依赖大量的第三方软、硬件供应商(笔者之前就是就职于一家汽车软件供应商,不过现在已经处于提桶状态了)。


好在作为Android程序员我们只需要关心Android系统的那部分。


车载 Android 系统


车载Android系统,又称Android Automotive,是对原始Android系统的一个功能扩充版本,在编译AOSP源码时可以看到相应的编译选项。



Android Automotive 编译后的原始界面如下所示,相信有过车载开发经验的同学对这个界面一定不陌生,我们正是在这个界面上把车载Android系统一点点搭建起来的。



Android Automotive


Android Automotive 是一个基于 Android 平台扩展后,适用于现代汽车的智能操作系统,可以直接运行为Android系统开发的应用。Android Automotive并非Android的分支或并行开发版本。它与手机和平板电脑等设备上搭载的Android使用相同的代码库,位于同一个存储区中。


Android Automotive与Android最大的区别在于,Android Automotive增加了对汽车特定要求、功能和技术的支持。



Google的官方文档:source.android.google.cn/docs/device…



Android Auto


除了Android Automotive,Google还推出了一个Android Auto。两者的命名方式可能有点让人迷惑不解。下面介绍了它们之间的区别:



  • Android Auto 是一个基于用户手机运行的平台,可通过 USB 连接将 Android Auto 用户体验投射到兼容的车载信息娱乐系统。Android Auto本质上就是一个运行在Android系统上的车载应用,与苹果的CarPlay,百度的CarLife类似。





  • Android Automotive 是一个可定制程度非常高的开源Android平台,它是一个完整的操作系统。




需要说明的是,使用Android Auto需要用户的手机支持Google服务框架,所以一般只在国内销售的汽车基本都不支持Android Auto,一些沿用了国外车机系统的合资车型可能会支持Android Auto。


车载 Android 应用


常见的车载应用


SystemUI


系统的UI。SystemUI是一个标准的android应用程序,它提供了系统UI的统一管理方案。
常见的状态栏、导航栏、消息中心、音量调节弹窗、蓝牙连接弹窗等一系列后台弹窗都是由SystemUI模块负责管理。


开发难度:SystemUI作为Android系统启动的第一个带有UI的应用程序,对启动性能和稳定性都有很高的要求。SystemUI需要管理的模块非常多,导致开发任务比较繁重,有的车载项目会要求SystemUI兼容原有的应用层API,那么开发难度还会上升。开发人员需要对Android原生的SystemUI源码有一定的了解。


Launcher


Android系统的桌面。


开发难度:Launcher是与用户交互最多的应用程序之一,同样对启动性能和稳定性都有很高的要求。Launcher开发难度主要集中在与3D车模的互动(如果有3D模型),可能需要支持Widget的显示(WidgetHost),各种应用的拖动和编辑等。开发人员最好对Android原生的Launcher源码有一定的了解。


Settings


系统设置,是车载Android系统中非常重要的一个系统级应用,是整个车载IVI系统的控制中心,整车的音效、无线通信、状态信息、安全信息等等都是需要通过系统设置来查看和控制。


开发难度:系统设置主要难度都集中在对Android Framework层API的理解上,例如蓝牙、Wi-Fi设置就需要开发人员对系统级API有一定的了解,这些内容往往都需要阅读Android原生应用的源码才能了解,所以系统设置也是一个开发难度比较大的车载应用。


CarService


车载Android系统的核心服务之一,所有应用都需要通过CarService来查询、控制整车的状态。例如:车辆的速度、档位、点火状态等等。


VehicleSettings


车辆设置,更常用的叫法是『车控车设』。负责管理整个车辆内外设置项的应用,主要与CarService进行数据交互。可设置项非常多。驾驶模式、方向盘助力、后视镜折叠、氛围灯、座舱监测、无线充电等等。


开发难度:主要难度集中在复杂多变的UI,有的主机厂商会在HMI中引入3D化的交互模型,就还需要考虑与3D模型间的通信,同时还需要熟练运用CAN工具来模拟汽车的CAN信号用于调试和开发。


HVAC


空调。负责管理整个车辆空调的应用,主要与CarService进行数据交互。


开发难度:和『车控车设』类似。


Map


地图,车载系统的核心功能之一,负责导航和语音提示等功能。不同的主机厂商有不同的开发方式。不外乎有三种:


1)选择使用百度、高德的地图SDK自行开发导航应用;


2)将导航模块外包给百度、高德,由地图供应商进行定制化开发;


3)直接集成地图供应商已有的车载版本的应用;


开发难度:主要集中在对地图SDK的运用和理解上,而且地图应用属于对性能要求较高的模块。


Multi-Media


多媒体应用。一般包含图片浏览、在线音视频播放器、USB音视频播放器、收音机等。




车载的应用远不止以上说得这些,根据不同的需求,还有非常多的Service需要做定制化开发,这里只列举了最常见的应用类型。


汽车上还会有一些第三方的应用,常见的有QQ音乐、微信、QQ、抖音、讯飞输入法等等,这些应用主机厂商不会获得源码,一般只会拿到一个apk,直接集成到Android系统中即可。


车载应用与移动应用的区别


夸张一点说,移动端的应用开发和车载应用开发,完全就不是一个技术思路。总结一下大致有以下几个区别:


1)应用级别不同


多数车载应用属于系统级应用,可以调用Android SDK内部隐藏的API,也不需要动态地向用户申请权限。移动应用是普通应用,系统对其限制很多,需要遵守Android应用的开发规范。


由于车载应用是系统级应用,所以移动端很多常用的技术比如热修复、插件化基本都不会采用,但是相对的进程保活、开机自启就变得非常简单了。


2)迭代方式不同


移动应用只要用户的手机接入了WiFi就可以进行在线升级,所以移动应用多采用小步快跑的形式,进行快速迭代。


车载系统级应用的更新只能以整车OTA的形式进行,而OTA可能会消耗宝贵的车机流量,所以车载应用在SOP(量产)前,就必须完成全部需求的开发,而且不能出现严重的bug。在正式交付用户前,车厂内部或4S店还会进行几次OTA升级用做最后的bug修复。(如果在交付用户后还有严重的bug或需求未完成,那么大概率项目经理、程序员都要祭天了)


3)技术路线不同


正是因为车载应用对稳定性的要求极高,所以车载应用在开发时,对待新型技术会非常的慎重,比如,目前只有少数主机厂商在使用Kotlin开发车载应用,毕竟Android Framework都还没有改成Kotlin,大部分厂商对Kotlin的积极性不高。而且车载应用也不允许随意使用开源框架,如果必须使用,务必注意框架的开源协议,以免给汽车厂商带来不必要的麻烦。


4)运行环境不同


车载应用的运行环境是经过高度定制化的Android系统,定制化也就意味着bug。移动端的应用出现bug时,我们的第一反应是应用的代码有缺陷。车载应用发现bug也要考虑到是不是系统本身出现了bug,这是一件非常有挑战性的事,应用开发与系统开发相互扯皮、泼脏水也属于家常便饭。


车载应用需要掌握的技能


除了一般Android开发需要学习的基础内容外,一名优秀的车载应用工程师还需要掌握以下的技能


1)MVVM架构


虽然如今一些移动端应用已经开始尝试MVI架构,但是就像前面说得,车载应用对待新技术都会持观望态度,目前主流的车载应用还是采用基于Jetpack组件的MVVM架构。


2)构建系统级应用


由于多数车载应用都属于系统级应用,所以必须了解如何构建一个系统级应用,这方面的内容可以看我之前写得Android车载应用开发与分析(11)- 车载Android应用开发入门指南,虽然写得比较乱凑活看吧。


还有一本比较老的书《Android深度探索:系统应用源代码分析与ROM定制》也可以看一看。


3)性能优化


应用的性能优化是个亘古不变的话题,掌握应用的各种性能优化方式,也是一个Android程序员必备的生存手段,汽车座舱的SOC性能比旗舰手机要差不少,如果优化好车载应用将是一个非常有挑战性的任务。


4)IPC通信


Android中最常用的跨进程通信手段是Binder,因为有大量的Service需要与应用进行交互,所以基于Binder的AIDL在车载应用开发中使用得非常广泛,学会使用AIDL也同样属于必备技能之一。


5)CAN仿真测试工具


CAN仿真测试工具包含了软件和硬件,在车载应用开发时我们需要借助这些工具来模拟发送CAN性能给到IVI来调试我们的应用,在实车调试阶段,也需要借助这些工具来捕获车辆的CAN信号来分析一些bug。常用的有CAN alyzer、CANoe、TS-Master等等,这些工具价格都极其昂贵,独自购买不现实,在车载应用开发务必把握学习和使用的机会。


6)系统应用源码


这一项是我认为最重要的,不少车载应用层项目都是反复定制各种SystemUI、Launcher、Settings等等,读懂Android系统应用源码对我们定制化开发这些应用有非常大的好处。




以上是一些我认为车载应用开发时需要掌握的技能,其他的一些诸如:adb调试指令、Linux操作系统的运用、AOSP源码编译也都需要额外学习,根据不同的需求,JNI、NDK等技术也有可能会用上。


车载应用开发者的未来


这篇文章的开头提到了一则新闻,中国今年的汽车出口量已经超越德国仅次于日本,这似乎是一个振奋人心的消息。汽车工业的高速发展,对我们这些车载程序员当然属于利好,但是最近的一则消息又让我改变了看法。



9月29日,零跑汽车正式赴港上市。成为众人意料之外继“蔚小理”后的又一新秀。但是零跑汽车的成绩似乎并没有得到资本市场的认可,在其上市首日,股价便遭遇大跌。根据数据显示,9月29日当日收盘,零跑汽车的股价为31.9港元/股票,相较发行价暴跌33.54%。而随后的半个月以来,零跑汽车的股价更是下跌56%,市值蒸发343亿港元。



一边是汽车出口量大增,另一边是新势力造车第二梯队的零跑上市即破发,并且两个交易日股价即腰斩,虽然有叠加疫情的影响,但这也说明了资本市场对造车企业的热情正在显著减弱,如果投资人赚不到丰厚的回报,那么以后的车企日后想要再从市场融资,恐怕不会是一件轻松的事。


以上说得都是大环境,但是作为技术人本职工作还是磨炼自己的技术为主。



回过头我们还要再看一下这张架构图,图中标蓝的部分是应用开发可以发挥的地方。不知道你有没有发现,应用实际上在车载操作系统中占据的比例很小,而且技术门槛也不高,这基本决定了在车载这个领域,单纯的Android应用开发前景并不广阔。


但是庞大的车载系统让应用开发者有了继续深入研究与实践的可能,那么是卷Framework还是Native或是HAL就需要你自己决定了!


最后总结一句,移动端的应用开发和车载应用开发,本质上走得两套技术路线,所以要慎重转行!如果已经决定,请务必趁早!


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

悄悄聘请多国承包商训练AI,野心藏住不住了!

OpenAI或在悄悄聘请国际承包商,以培训其软件工程方面的人工智能。本文将通过一场针对敏捷专家职位的ChatGPT工作面试指导中抽取的七个问题揭晓ChatGPT的真实水平。 外媒Semafor的一份报告显示,OpenAI已悄悄聘请了数百名国际承包商,以培训其软...
继续阅读 »

OpenAI或在悄悄聘请国际承包商,以培训其软件工程方面的人工智能。本文将通过一场针对敏捷专家职位的ChatGPT工作面试指导中抽取的七个问题揭晓ChatGPT的真实水平。

外媒Semafor的一份报告显示,OpenAI已悄悄聘请了数百名国际承包商,以培训其软件工程方面的人工智能。据报道,过去6个月从拉丁美洲和东欧等地聘请的一些承包商,负责创建数据,指导人工智能学习简单的软件工程任务。

虽然OpenAI已经有一款名为Codex的产品,可以将自然语言转换为工作代码,但同时该公司的招聘热潮表明,它正在寻求推进这项技术,有可能为一些人类程序员创造一个工作替代品。Semafor采访了一位南美洲的工程师,他为OpenAI的一个承包商角色进行了面试。作为这个过程的一部分,他负责发现AI代码中的错误,并为如何修复错误提供解释。这位工程师告诉Semafor,他认为该公司希望将训练数据输入其人工智能技术。

OpenAI也是时髦的AI聊天机器人ChatGPT的所有者。自去年年底机器人推出以来,它已经威胁到要破坏教育、新闻和法律。软件工程可能会被添加到下一个列表中。

OpenAI没有立即回应外媒的置评请求。

事实上,近日外媒最近报道称,一些亚马逊员工已经开始使用ChatGPT来帮助编码,尽管该公司警告员工不要与机器人分享机密的公司信息。

那么ChatGPT究竟在面试中会处于何种水平呢?它到底是如何被指导和训练的?真的能取代人类吗?这里为大家详细介绍一个ChatGPT应聘敏捷专家岗的几个问题的分析指导案例。

训练背景

一个不擅长任何敏捷(Scrum)领域知识的大型机器语言模型能否通过敏捷专家(Scrum Master)职位的筛选面试?在这次ChatGPT工作面试中,我对OpenAI最新生成的AI模型进行了测试。我从73个敏捷专家面试问题指南(见下文)中挑选了几个问题,涵盖了从宽泛的问题,到具体、针对性的问题。这里值得一提的是,一般只是拥有实际经验的受访者才有能力回答这些问题。

所以,请读者继续往下阅读,以便了解企业中的敏捷专家未来是否会很快被当下著名的ChatGPT所取代。


面试ChatGPT

本次ChatGPT工作面试的任务是创建一个大约30分钟的筛选型面试,以决定是否继续进行ChatGPT虚构的敏捷专家职位申请。

面试官选择了两天来运行ChatGPT面试环节,当ChatGPT没有提供明显的答案时,会使用“跟进问题”来探索此模型。

ChatGPT工作面试的开场问题

【问题】感谢您申请我们公司的敏捷专家岗位!为什么您觉得自己很适合这个职位呢?

【ChatGPT的回答】感谢您考虑我担任贵公司的敏捷专家岗位!我相信我很适合这个角色,因为我对Scrum和敏捷框架有很强的理解,而且我能够与团队成员和利益相关者进行有效的沟通和相互促进。我还能够处理冲突并促进讨论以找到解决方案,我擅长帮助团队坚持敏捷价值观及有关实践。此外,我还能够适应新的情况和环境,并致力于不断学习和改进。

【评论】赞同;我们只是忽略了缺乏工作经验方面的提问。


【跟进问题】为什么团队坚持敏捷的价值观和实践是有益的?

【ChatGPT的回答】坚持敏捷的价值观和实践可以为开发团队带来许多好处,包括:

  • 增加透明度:Scrum强调透明度和开放性,这可以帮助团队成员更好地了解他们的同事在做什么,以及他们的工作如何融入整个项目。这有助于改善团队内部的沟通和协作。

  • 改进的适应性:Scrum强调能够适应变化并在新信息可用时对其做出响应的重要性。这可以帮助团队更加灵活,对不断变化的环境或需求做出反应。

  • 增强的专注力:通过遵循Scrum框架,团队可以专注于以微小的增量交付价值,而不是试图一次完成所有工作。这可以帮助团队保持积极性,并更加一致地取得业务进展。

  • 增强责任感:Scrum要求团队成员对自己的工作负责,并对履行承诺负责。这有助于提高团队工作的整体质量,并确保团队实现其目标。

总之,坚持Scrum的价值观和实践可以帮助开发团队更有效地工作,并交付更好的结果。

【评论】我开始喜欢这个应聘人员了!

问题:敏捷专家作为问题解决者

【问题】敏捷专家应该代表敏捷团队解决问题吗?

【专家指导】敏捷专家不应该关心如何解决敏捷团队可以自己解决的问题,无论这个要求在招聘广告中被提及的频率有多高。如果敏捷专家像“Scrum家长”一样行事,他们的团队将永远不会成为自我管理的。

敏捷团队必须学会做出自己的决定。通常,当团队学习新东西时,这不可避免地会导致失败、死胡同和其他计划外的离题。因此,在一开始,团队将需要敏捷专家比平时更多的指导。然而,这样的建议不应成为保护性“育儿”的练习——必须允许团队从失败中吸取教训。

尽管如此,在一些情况下敏捷专家确实可以代表团队解决问题。当敏捷团队无法自己解决问题时,这位敏捷专家的角色即起作用——例如,因为某问题是一个组织问题。现在我们讨论的是“障碍”方面的问题;在这种情况下,敏捷专家应成为敏捷团队的障碍清除者。

【ChatGPT的回答】代表敏捷团队解决问题不是敏捷专家的责任。敏捷专家的主要职责是为敏捷团队服务,并保护其免受外部干扰和分心。不过,敏捷专家可以帮助团队找到解决问题的方法,并消除前进的障碍。

敏捷团队负责解决他们自己的问题,并找到改进他们的流程和工作的方法。敏捷专家可以通过指导团队、促进讨论以及根据需要提供支持和指导来提供帮助。然而,敏捷团队必须掌握自己的问题并共同寻找解决方案,而不是依靠敏捷专家代表他们解决问题。

【评论】ChatGPT的回答是对敏捷专家职责的合理解释,强调了帮助团队自我支持的重要性。团队必须拥有自己的问题和解决方案空间以及工作方式。

问题:克服利益相关者的阻力

【问题】您已经为产品的利益相关者提供了Scrum培训。然而,在执行敏捷管理方案的初始阶段之后,当遇到第一个障碍时,这些利益相关者中的一些人就开始抵制继续采用这种新的管理方案。您在处理这些情况时的策略和经验是什么?

【专家指导】这个问题旨在鼓励在组织内克服对Scrum的抵制时交流思想和经验教训。此外,熟悉许多组织常见的敏捷失败模式能够证明应聘者具有相关的经验。

您的应聘者还应该熟悉中层管理者在向敏捷实践过渡过程中面临的挑战。例如,从指挥和控制风格(即管理人员并告诉他们该做什么)转变为仆人式领导风格(从而放弃泰勒原则)并不适合所有人。

【ChatGPT的回答】作为一个语言模型,我没有处理利益相关者抵制继续采用Scrum的情况的个人经验。然而,这里有一些敏捷专家在面对这个挑战时可能会考虑的策略:

  • 确定抵制的根本原因:了解利益相关者抵制继续采用Scrum的原因很重要。他们是否不确定收益,或者是否担心对工作的影响?了解根本原因可以帮助敏捷专家更有效地解决问题。

  • 交流好处:利益相关者可能不完全理解Scrum的好处,或者他们可能对Scrum的价值持怀疑态度。以一种与利益相关者相关的方式交流Scrum的好处并解决他们的关注点对他们是很有帮助的。

  • 鼓励利益相关者的参与:让利益相关者参与Scrum过程可以帮助他们看到框架的价值,并能够为该框架的成功计划进行更多的投入。敏捷专家可以鼓励利益相关者参与Scrum事件,如冲刺评审会和回顾工作等。

  • 提供持续的支持和教育:提供持续支持和教育有助于帮助利益相关者了解Scrum过程及其如何使他们受益。这可能包括定期报到、培训课程或一对一辅导。

  • 灵活性:重要的是要记住每个团队和组织都是不同的,Scrum过程可能需要调整以适应团队的特定需求和挑战。敏捷专家应该愿意根据需要进行调整,以确保框架的持续成功。

【重新生成的ChatGPT回答】当利益相关者拒绝继续采用Scrum时,敏捷专家可以使用以下几种策略:

  • 让利益相关者了解Scrum的好处:有些利益相关者可能不完全理解Scrum的益处,或者它如何帮助团队和组织。在这些情况下,让利益相关者了解Scrum可以提高团队绩效并更快地实现价值的具体方式可能会有所帮助。

  • 传达团队的进展:展示Scrum价值的一种方式是定期向利益相关者传达团队的进度。通过向利益相关者展示团队取得的进展和交付的价值,您可以帮助他们看到Scrum的好处及其为组织带来的价值。

  • 解决关注点和挑战:如果利益相关者对Scrum有特定的关注点或挑战,那么倾听他们的关注点并与他们一起解决这些问题很重要。这可能涉及找到解决他们的问题的方法,或者调整团队使用Scrum的方式,以更好地满足他们的需求。

  • 强调遵循Scrum框架的重要性:最终,重要的是提醒利益相关者,Scrum是一个经过验证的框架,它已经帮助许多团队和组织取得了成功。通过遵循Scrum的价值观和实践,团队可以更有效地工作,为组织带来更好的结果。

  • 作为一个语言模型,我没有任何处理这类情况的个人经验。然而,我可以根据我对Scrum的理解以及有效沟通和冲突解决的原则提供一般指导。

【评论】这个答案是ChatGPT工作面试中令人兴奋的时刻!ChatGPT承认在这种情况下没有第一手经验。(至少可以说,这是令人惊讶的!)然而,ChatGPT也设法克服了尴尬的“我不知道”情况——方法是转移到元层次,并将一般沟通和冲突解决原则应用于敏捷团队的利益相关者关系。

问题:可行的冲刺目标

【问题】敏捷团队在冲刺计划中应该考虑哪些因素来确定可行的冲刺目标?

【专家指导】敏捷团队需要考虑的典型标准,通常包括例如下面一些方面:

  • 冲刺期间会有谁在场;有人休假或病假吗?

  • 团队中的员工是否需要在最后一刻进行知识转移,或者新的团队成员是否需要适当的入职培训?

  • 冲刺期间会有公共假期吗?

  • 我们是否拥有所有必要的工具以及我们是否熟悉这些工具?

  • 我们是否熟悉将要处理的应用程序部分?或者这是一个未知的领域?

  • 我们是否面临对其他团队的依赖?

  • 我们需要解决什么水平的技术债务?

  • 敏捷团队过去的表现如何?

因为任何敏捷团队都在玩无穷尽的游戏,所以没有赢家。最有可能的是,当敏捷团队在每一次冲刺中为客户和组织创造价值时,利益相关者会认为敏捷团队是成功的。

因此,从团队的角度来看,与利益相关者建立融洽关系和信任需要更多华尔街式的期望管理:利益相关者更看重可靠的交付,而不是偶尔的生产力爆发。这种理解应该指导敏捷团队确定可行的冲刺目标。

【ChatGPT的回答】在冲刺计划活动期间,敏捷团队在确定可行的冲刺目标时应该考虑许多因素。这些因素包括:

  • 团队的能力:团队应该根据他们的能力和可用资源,考虑他们在冲刺期间能够实际完成多少工作。鉴于团队的能力,设定一个具有挑战性但可实现的目标很重要。

  • 实现目标所需的工作:团队应考虑实现目标所需要的工作,包括任务的复杂性和所需的工作量。

  • 依赖性:团队应考虑可能影响实现目标能力的任何依赖性,例如需要其他团队完成的工作或可能影响团队进度的外部因素。

  • 团队之前的表现:团队应该考虑他们过去的表现,以及他们在之前的冲刺中通常能够完成的工作量。这可以帮助团队为当前的冲刺设定一个现实的目标。

  • 团队的长期目标:团队应考虑项目的长期目标,以及冲刺期间完成的工作将如何有助于实现这些目标。

通过考虑这些因素,团队可以设定一个具有挑战性但可实现的可行冲刺目标,这将帮助团队朝着项目的长期目标取得进展。


【跟进问题】技术债务和可用技能如何?

【ChatGPT的回答】在确定可行的冲刺目标时,技术债务和可用技能也是需要考虑的重要因素。

典型地,技术债务是指团队尚未解决的技术挑战和问题的累积。这些挑战会减缓团队的进度,使实现冲刺目标变得更加困难。当确定一个可行的冲刺目标时,团队应该考虑需要解决的技术债务的数量,以及他们是否有能力在冲刺中解决这个问题。

可用技能是另一个需要考虑的重要因素。团队应考虑每个团队成员的技能和专业知识,以及如何利用他们实现冲刺目标。如果团队缺乏完成工作所需的某些技能,他们可能需要调整目标或找到获取必要技能的方法。

通过考虑技术债务和可用技能,团队可以制定一个可行的冲刺目标,该目标考虑到可能影响其实现能力的挑战和限制。这可以帮助团队更有效地工作,最终为项目带来更好的结果。

【评论】好的,ChatGPT工作面试的这一部分需要一个跟进问题,以确定现有的技术债务和团队的技能集合,作为确定冲刺目标的决定因素。否则,直接找出八个因素中的五个也是不错的。

问题:“准备就绪的定义”

【问题】作为敏捷团队使用“准备就绪的定义”可以吗?

【专家指导】使用就绪定义(Definition of Ready)取决于敏捷团队的情况。例如,假设它是一个初级团队,仍然在Scrum的机制中挣扎。在这种情况下,这可能是一种暂时有效的方法,可以在产品积压管理、优化和冲刺计划期间减轻团队的压力。另一方面,一个高级敏捷团队能够找到它的魔力,而不需要依赖反复的训练。

然而,假设“准备就绪的定义”被武断地用作检查表——拒绝冲刺计划期间所有未被新标准100%覆盖的工作条目;那么,在这种情况下,您可能需要通过别扭的途径重新引入传统的瀑布式开发方案——这一次只有程序员们需要这么做。

更糟糕的是,组织使用“准备就绪定义”作为敏捷团队在敏捷产品开发中的“流畅度”的团队度量或指标。

【ChatGPT的回答】是的,敏捷团队通常可以使用“准备就绪定义”(DoR)来指导他们的工作。DoR是团队定义的一组标准,用于确定产品待办事项项何时准备好包含在冲刺中。

DoR的目的是确保团队对需要完成的工作有清晰的理解,并做好充分的准备来完成它。通过DoR,团队可以避免在未完全理解或未准备好完成的项目上开始工作,这可以帮助他们更高效地工作。

DoR应针对团队和项目的需求,并可能包括以下标准:

  • 团队明确定义并理解该项目

  • 该项目已定义所有必要的验收标准

  • 该项目的大小和估计都是正确的

  • 已确定所有必要的依赖关系

  • 所有必要的资源(如工具、数据等)都可用

使用DoR可以帮助敏捷团队确保他们在正确的时间处理正确的项目,并确保他们拥有完成工作所需的信息和资源。然而,需要注意的是,DoR应该是一个活文档,团队可以根据需要不断更新和调整,以满足项目不断变化的需求。

【评论】虽然我一般不支持应用DoR,因为这种反复训练很快就会变成恶意的东西,但ChatGPT提供了拥有DoR的积极方面。不过,在一次ChatGPT工作面试中,将DoR概念延伸到Scrum,这是种积极的暗示。

问题:不稳定的速度

【问题】您的敏捷团队始终未能达到预测和冲刺目标,而且速度不稳定。出现此问题的可能原因是什么?您将如何与团队一起解决此问题?

【专家指导】你的应聘者应该讨论“速度”作为衡量团队进展的行业最普遍的指标的恶名。他们应该能够进一步解释为什么速度完全是一个值得怀疑的敏捷度量,并指出量化度量并不适合于衡量团队在掌握Scrum方面的进展。

许多因素可导致敏捷团队的速度波动:

  • 新团队成员入职;

  • 经验丰富的成员离开团队;

  • 在未知领域工作的团队;

  • 处理遗留代码的团队,可能没有文档记录;

  • 团队陷入意外的技术债务;

  • 假期和病假降低了团队的能力;

  • 高管干预改变了冲刺的范围;

  • 该团队正在解决计划外的优先级错误。

敏捷团队始终未能达到预期的另一个常见原因是团队的产品积压项目准备不足,从而使团队难以理解工作项目。相反,交付给团队的项目可能会受到遗留代码记录不足、技术债务过多或代码过多的漏洞和编程本身就有错误的影响——所有这些都使评估成为一场赌博。

最后,您的候选人不应该认同这样一种谬论,即团队采用Scrum是有效的,因为敏捷团队的预测和速度是一致的。

【ChatGPT的回答】敏捷团队可能始终未能达到预测和冲刺目标,而且其速度可能不稳定,原因有很多。

此问题的一些可能原因包括:

  • 缺乏明确的目标:如果团队没有明确的目标或对他们试图实现的目标没有明确的理解,那么他们可能很难取得进展或准确预测他们的工作。

  • 糟糕的估计:如果团队没有准确估计其工作的规模和复杂性,他们可能无法准确预测其进度或设定现实的目标。

  • 范围蔓延:如果团队没有有效的管理范围,并且他们的工作范围不断变化,那么他们可能很难取得进展或准确预测他们的工作。

  • 协作和沟通不佳:如果团队没有有效地相互协作和沟通,他们可能很难协调工作并朝着目标取得进展。

为了解决团队中的这些问题,敏捷专家可以:

  • 帮助团队为每个冲刺定义清晰的目标,并确保团队中的每个人都了解他们想要实现的目标。

  • 与团队合作,提高他们的评估技能,帮助他们了解如何准确评估工作的规模和复杂性。

  • 帮助团队有效地管理工作范围,方法是设置清晰的界限,并建立管理工作范围变更的流程和优先顺序。

  • 鼓励团队提高协作和沟通能力,并促进讨论和解决问题的会议,以帮助团队更有效地工作。

通过解决这些问题并帮助团队开发必要的技能和流程,敏捷专家可以帮助团队朝着目标前进并提高速度。

【评论】ChatGPT提到了一些有助于提高可预测性的一般事项。然而,坚持“准确的预测”是有问题的,因为在一个复杂的环境中不存在这种情况。此外,还没有提到产品积压工作细化的重要性,以及团队成员在一般产品发现过程中的早期参与,所有这些都有助于改进预测。敏捷专家如何支持他们的团队的想法只是第一个列表的重复,因此对ChatGPT工作面试总体上没有帮助。

到目前为止,这是敏捷专家职位ChatGPT面试中最薄弱的答案。

ChatGPT工作面试总结

总体来看,ChatGPT工作面试好坏参半。主要体现在:问题越具体,与Scrum指南和Scrum的首要原则的距离越远,ChatGPT的回答就越趋于失败。其实,这一失败并不令人惊讶,因为我们并不知道该大型语言模型(LLM:Large Language Model)训练了什么内容。

因此,基于本文中介绍的这几个示例问题,面试没有通过。面试官不会在敏捷专家面试过程中采取下一步——邀请ChatGPT与几个敏捷团队成员进行全方位面试。

然而,这次ChatGPT工作面试也让我们得以窥见未来:如果我们可以自己训练ChatGPT 4.0呢?当然,我指的不是整个模型,而是指敏捷和Scrum相关的具体部分。要让ChatGPT达到一个能让我们面试官参与下一步模型的水平,需要多少训练?

此外,读者您会支持训练ChatGPT 为(取代)自己工作吗?可以留言与我们分享您的建议。

参考链接

https://www.businessinsider.com/openai-chatgpt-contractors-train-ai-software-engineering-autonomous-vehicles-report-2023-1

https://dzone.com/articles/a-chatgpt-job-interview-for-a-scrum-master-positio

作者 | 朱先忠、云昭

收起阅读 »

感受Vue3的魔法力量

近半年有幸参与了一个创新项目,由于没有任何历史包袱,所以选择了Vue3技术栈,总体来说感受如下:• setup语法糖• 可以通过Composition API(组合式API)封装可复用逻辑,将UI和逻辑分离,提高复用性,view层代码展示更清晰• 和Vue3更...
继续阅读 »

近半年有幸参与了一个创新项目,由于没有任何历史包袱,所以选择了Vue3技术栈,总体来说感受如下:

• setup语法糖

• 可以通过Composition API(组合式API)封装可复用逻辑,将UI和逻辑分离,提高复用性,view层代码展示更清晰

• 和Vue3更搭配的状态管理库Pinia,少去了很多配置,使用起来更便捷

• 构建工具Vite,基于ESM和Rollup,省去本地开发时的编译步骤,但是build打包时还是会编译(考虑到兼容性)

• 必备VSCode插件Volar,支持Vue3内置API的TS类型推断,但是不兼容Vue2,如果需要在Vue2和Vue3项目中切换,比较麻烦

当然也遇到一些问题,最典型的就是响应式相关的问题

响应式篇

本篇主要借助watch函数,理解ref、reactive等响应式数据/状态,有兴趣的同学可以查看Vue3源代码部分加深理解,

watch数据源可以是ref (包括计算属性)、响应式对象、getter 函数、或多个数据源组成的数组

import { ref, reactive, watch, nextTick } from 'vue'

//定义4种响应式数据/状态
//1、ref值为基本类型
const simplePerson = ref('张三')
//2、ref值为引用类型,等价于:person.value = reactive({ name: '张三' })
const person = ref({
   name: '张三'
})
//3、ref值包含嵌套的引用类型,等价于:complexPerson.value = reactive({ name: '张三', info: { age: 18 } })
const complexPerson = ref({ name: '张三', info: { age: 18 } })
//4、reactive
const reactivePerson = reactive({ name: '张三', info: { age: 18 } })

//改变属性,观察以下不同情景下的监听结果
nextTick(() => {
   simplePerson.value = '李四'
   person.value.name = '李四'
   complexPerson.value.info.age = 20
   reactivePerson.info.age = 22
})

//情景一:数据源为RefImpl
watch(simplePerson, (newVal) => {
   console.log(newVal) //输出:李四
})
//情景二:数据源为'张三'
watch(simplePerson.value, (newVal) => {
   console.log(newVal) //非法数据源,监听不到且控制台告警
})
//情景三:数据源为RefImpl,但是.value才是响应式对象,所以要加deep
watch(person, (newVal) => {
   console.log(newVal) //输出:{name: '李四'}
},{
   deep: true //必须设置,否则监听不到内部变化
})
//情景四:数据源为响应式对象
watch(person.value, (newVal) => {
   console.log(newVal) //输出:{name: '李四'}
})
//情景五:数据源为'张三'
watch(person.value.name, (newVal) => {
   console.log(newVal) //非法数据源,监听不到且控制台告警
})
//情景六:数据源为getter函数,返回基本类型
watch(
  () => person.value.name,
  (newVal) => {
       console.log(newVal) //输出:李四
  }
)
//情景七:数据源为响应式对象(在Vue3中状态都是默认深层响应式的)
watch(complexPerson.value.info, (newVal, oldVal) => {
   console.log(newVal) //输出:Proxy {age: 20}
   console.log(newVal === oldVal) //输出:true
})
//情景八:数据源为getter函数,返回响应式对象
watch(
  () => complexPerson.value.info,
  (newVal) => {
       console.log(newVal) //除非设置deep: true或info属性被整体替换,否则监听不到
  }
)
//情景九:数据源为响应式对象
watch(reactivePerson, (newVal) => {
   console.log(newVal) //不设置deep: true也可以监听到
})

总结:

  1. 在Vue3中状态都是默认深层响应式的(情景七),嵌套的引用类型在取值(get)时一定是返回Proxy响应式对象

  2. watch数据源为响应式对象时(情景四、七、九),会隐式的创建一个深层侦听器,不需要再显示设置deep: true

  3. 情景三和情景八两种情况下,必须显示设置deep: true,强制转换为深层侦听器

  4. 情景五和情景七对比下,虽然写法完全相同,但是如果属性值为基本类型时是监听不到的,尤其是ts类型声明为any时,ide也不会提示告警,导致排查问题比较费力

  5. 所以精确的ts类型声明很重要,否则经常会出现莫名其妙的watch不生效的问题

  6. ref值为基本类型时通过get\set拦截实现响应式;ref值为引用类型时通过将.value属性转换为reactive响应式对象实现;

  7. deep会影响性能,而reactive会隐式的设置deep: true,所以只有明确状态数据结构比较简单且数据量不大时使用reactive,其他一律使用ref

Props篇

设置默认值

type Props = {
 placeholder?: string
 modelValue: string
 multiple?: boolean
}
const props = withDefaults(defineProps<Props>(), {
 placeholder: '请选择',
 multiple: false,
})

双向绑定(多个值)

• 自定义组件

//FieldSelector.vue
type Props = {
businessTableUuid: string
businessTableFieldUuid?: string
}
const props = defineProps<Props>()
const emits = defineEmits([
'update:businessTableUuid',
'update:businessTableFieldUuid',
])
const businessTableUuid = ref('')
const businessTableFieldUuid = ref('')
// props.businessTableUuid、props.businessTableFieldUuid转为本地状态,此处省略
//表切换
const tableChange = (businessTableUuid: string) => {
emits('update:businessTableUuid', businessTableUuid)
emits('update:businessTableFieldUuid', '')
businessTableFieldUuid.value = ''
}
//字段切换
const fieldChange = (businessTableFieldUuid: string) => {
emits('update:businessTableFieldUuid', businessTableFieldUuid)
}

• 使用组件

<template>
 <FieldSelector
   v-model:business-table-uuid="stringFilter.businessTableUuid"
   v-model:business-table-field-uuid="stringFilter.businessTableFieldUuid"
 />
</template>
<script setup lang="ts">
import { reactive } from 'vue'
const stringFilter = reactive({
businessTableUuid: '',
businessTableFieldUuid: ''
})
</script>

单向数据流

  1. 大部分情况下应该遵循【单向数据流】原则,禁止子组件直接修改props,否则复杂应用下的数据流将变得混乱,极易出现bug且难排查

  2. 直接修改props会有告警,但是如果props是引用类型,修改props内部值将不会有告警提示,因此应该有团队约定(第5条除外)

  3. 如果props为引用类型,赋值到子组件状态时,需要解除引用(第5条除外)

  4. 复杂的逻辑,可以将状态以及修改状态的方法,封装成自定义hooks或者提升到store内部,避免props的层层传递与修改

  5. 一些父子组件本就紧密耦合的场景下,可以允许修改props内部的值,可以减少很多复杂度和工作量(需要团队约定固定场景)

逻辑/UI解耦篇

利用Vue3的Composition/组合式API,将某种逻辑涉及到的状态,以及修改状态的方法封装成一个自定义hook,将组件中的逻辑解耦,这样即使UI有不同的形态或者调整,只要逻辑不变,就可以复用逻辑。下面是本项目中涉及的一个真实案例-逻辑树组件,UI有2种形态且可以相互转化。


• hooks部分的代码:useDynamicTree.ts

import { ref } from 'vue'
import { nanoid } from 'nanoid'
export type TreeNode = {
id?: string
pid: string
nodeUuid?: string
partentUuid?: string
nodeType: string
nodeValue?: any
logicValue?: any
children: TreeNode[]
level?: number
}
export const useDynamicTree = (root?: TreeNode) => {
const tree = ref<TreeNode[]>(root ? [root] : [])
const level = ref(0)
//添加节点
const add = (node: TreeNode, pid: string = 'root'): boolean => {
  //添加根节点
  if (pid === '') {
    tree.value = [node]
    return true
  }
  level.value = 0
  const pNode = find(tree.value, pid)
  if (!pNode) return false
  //嵌套关系不能超过3层
  if (pNode.level && pNode.level > 2) return false
  if (!node.id) {
    node.id = nanoid()
  }
  if (pNode.nodeType === 'operator') {
    pNode.children.push(node)
  } else {
    //如果父节点不是关系节点,则构建新的关系节点
    const current = JSON.parse(JSON.stringify(pNode))
    current.pid = pid
    current.id = nanoid()
    Object.assign(pNode, {
      nodeType: 'operator',
      nodeValue: 'and',
      // 重置回显信息
      logicValue: undefined,
      nodeUuid: undefined,
      parentUuid: undefined,
      children: [current, node],
    })
  }
  return true
}
//删除节点
const remove = (id: string) => {
  const node = find(tree.value, id)
  if (!node) return
  //根节点处理
  if (node.pid === '') {
    tree.value = []
    return
  }
  const pNode = find(tree.value, node.pid)
  if (!pNode) return
  const index = pNode.children.findIndex((item) => item.id === id)
  if (index === -1) return
  pNode.children.splice(index, 1)
  if (pNode.children.length === 1) {
    //如果只剩下一个节点,则替换父节点(关系节点)
    const [one] = pNode.children
    Object.assign(
      pNode,
      {
        ...one,
      },
      {
        pid: pNode.pid,
      },
    )
    if (pNode.pid === '') {
      pNode.id = 'root'
    }
  }
}
//切换逻辑关系:且/或
const toggleOperator = (id: string) => {
  const node = find(tree.value, id)
  if (!node) return
  if (node.nodeType !== 'operator') return
  node.nodeValue = node.nodeValue === 'and' ? 'or' : 'and'
}
//查找节点
const find = (node: TreeNode[], id: string): TreeNode | undefined => {
  // console.log(node, id)
  for (let i = 0; i < node.length; i++) {
    if (node[i].id === id) {
      Object.assign(node[i], {
        level: level.value,
      })
      return node[i]
    }
    if (node[i].children?.length > 0) {
      level.value += 1
      const result = find(node[i].children, id)
      if (result) {
        return result
      }
      level.value -= 1
    }
  }
  return undefined
}
//提供遍历节点方法,支持回调
const dfs = (node: TreeNode[], callback: (node: TreeNode) => void) => {
  for (let i = 0; i < node.length; i++) {
    callback(node[i])
    if (node[i].children?.length > 0) {
      dfs(node[i].children, callback)
    }
  }
}
return {
  tree,
  add,
  remove,
  toggleOperator,
  dfs,
}
}

• 在不同组件中使用(UI1/UI2组件为递归组件,内部实现不再展开)

//组件1

<template>
<UI1
  :logic="logic"
  :on-add="handleAdd"
  :on-remove="handleRemove"
  :toggle-operator="toggleOperator"  
</UI1>
</template>
<script setup lang="ts">
import { useDynamicTree } from '@/hooks/useDynamicTree'
const { add, remove, toggleOperator, tree: logic, dfs } = useDynamicTree()
const handleAdd = () => {
  //添加条件
}
const handleRemove = () => {
  //删除条件
}
const toggleOperator = () => {
  //切换逻辑关系:且、或  
  }
</script>

//组件2

<template> 
<UI2 :logic="logic"
  :on-add="handleAdd"
  :on-remove="handleRemove"
  :toggle-operator="toggleOperator"
</UI2>
</template>
<script setup lang="ts">
import { useDynamicTree } from '@/hooks/useDynamicTree'
const { add, remove, toggleOperator, tree: logic, dfs } = useDynamicTree()
const handleAdd = () => { //添加条件 }
const handleRemove = () => { //删除条件 }
const toggleOperator = () => { //切换逻辑关系:且、或 }
</script>

Pinia状态管理篇

将复杂逻辑的状态以及修改状态的方法提升到store内部管理,可以避免props的层层传递,减少props复杂度,状态管理更清晰

• 定义一个store(非声明式):User.ts

import { computed, reactive } from 'vue'
import { defineStore } from 'pinia'
type UserInfo = {
 userName: string
 realName: string
 headImg: string
 organizationFullName: string
}
export const useUserStore = defineStore('user', () => {
 const userInfo = reactive<UserInfo>({
   userName: '',
   realName: '',
   headImg: '',
   organizationFullName: ''
})
 const fullName = computed(() => {
   return `${userInfo.userName}[${userInfo.realName}]`
})
 const setUserInfo = (info: UserInfo) => {
   Object.assgin(userInfo, {...info})
}
 return {
   userInfo,
   fullName,
   setUserInfo
}
})

• 在组件中使用

<template>
 <div class="welcome" font-JDLangZheng>
   <el-space>
     <el-avatar :size="60" :src="userInfo.headImg ? userInfo.headImg : avatar"> </el-avatar>
     <div>
       <p>你好,{{ userInfo.realName }},欢迎回来</p>
       <p style="font-size: 14px">{{ userInfo.organizationFullName }}</p>
     </div>
   </el-space>
 </div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import avatar from '@/assets/avatar.png'
const { userInfo } = useUserStore()
</script>

作者:京东云开发者
来源:juejin.cn/post/7193538517480243258

收起阅读 »

费解,遇到的一个 JDK 序列化 Bug !

1、背景 最近查看应用的崩溃记录的时候遇到了一个跟 Java 序列化相关的崩溃, 从崩溃的堆栈来看,整个调用堆栈里没有我们自己的代码信息。崩溃的起点是 Android 系统自动存储 Fragment 的状态,也就是将数据序列化并写入 Bundle 时。最终出...
继续阅读 »

1、背景


最近查看应用的崩溃记录的时候遇到了一个跟 Java 序列化相关的崩溃,


截屏2023-01-26 19.09.08.png


从崩溃的堆栈来看,整个调用堆栈里没有我们自己的代码信息。崩溃的起点是 Android 系统自动存储 Fragment 的状态,也就是将数据序列化并写入 Bundle 时。最终出现问题的代码则位于 ArrayList 的 writeObject() 方法。


这里顺带说明一下,一般我们在使用序列化的时候只需要让自己的类实现 Serializable 接口即可,最多就是为自己的类增加一个名为 SerialVersionUID 的静态字段以标志序列化的版本号。但是,实际上序列化的过程是可以自定义的,也就是通过 writeObject()readObject() 实现。这两个方法看上去可能比较古怪,因为他们既不存在于 Object 类,也不存在于 Serializable 接口。所以,对它们没有覆写一说,并且还是 private 的。从上述堆栈也可以看出,调用这两个方法是通过反射的形式调用的。


2、分析


从堆栈看出来是序列化过程中报错,并且是因为 Fragment 状态自动保存过程中报错,报错的位置不在我们的代码中,无法也不应该使用 hook 的方式解决。


再从报错信息看,是多线程修改导致的,也就是因为 ArrayList 并不是线程安全的,所以,如果在调用序列化的过程中其他线程对 ArrayList 做了修改,那么此时就会抛出 ConcurrentModificationException 异常。


但是! 再进一步看,为了解决 ArrayList 在多线程环境中不安全的问题,我这里是用了同步容器进行包装。从堆栈也可以看出,堆栈中包含如下一行代码,


Collections$SynchronizedCollection.writeObject(Collections.java:2125)

这说明,整个序列化的操作是在同步代码块中执行的。而就在执行过程中,其他线程完成了对 ArrayList 的修改。


再看一下报错的 ArrayList 的代码,


private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
// Write out element count, and any hidden stuff
int expectedModCount = modCount; // 1
s.defaultWriteObject();

// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);

// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}

if (modCount != expectedModCount) { // 2
throw new ConcurrentModificationException();
}
}

也就是说,在 writeObject 这个方法执行 1 和 2 之间的代码的时候,容器被修改了。


但是,该方法的调用是位于同步容器的同步代码块中的,这里出现同步错误,我首先想到的是如下几个原因:



  1. 同步容器的同步锁没有覆盖所有的方法:基本不可能,标准 JDK 应该还是严谨的 ...

  2. 外部通过反射直接调用了同步容器内的真实数据:一般不会有这种骚操作

  3. 执行序列化过程的过程跳过了锁:虽然是反射调用,但是代码逻辑的执行是在代码块内部的

  4. 执行序列化方法的过程中释放了锁


3、复现


带着上述问题,首先还是先复现该问题。


该异常还是比较容易复现,


private static final int TOTAL_TEST_LOOP = 100;
private static final int TOTAL_THREAD_COUNT = 20;

private static volatile int writeTaskNo = 0;

private static final List<String> list = Collections.synchronizedList(new ArrayList<>());

private static final Executor executor = Executors.newFixedThreadPool(TOTAL_THREAD_COUNT);

public static void main(String...args) throws IOException {
for (int i = 0; i < TOTAL_TEST_LOOP; i++) {
executor.execute(new WriteListTask());
for (int j=0; j<TOTAL_THREAD_COUNT-1; j++) {
executor.execute(new ChangeListTask());
}
}
}

private static final class ChangeListTask implements Runnable {

@Override
public void run() {
list.add("hello");
System.out.println("change list job done");
}
}

private static final class WriteListTask implements Runnable {

@Override
public void run() {
File file = new File("temp");
OutputStream os = null;
ObjectOutputStream oos = null;
try {
os = new FileOutputStream(file);
oos = new ObjectOutputStream(os);
oos.writeObject(list);
oos.flush();
os.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
oos.close();
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println(String.format("write [%d] list job done", ++writeTaskNo));
}
}

这里创建了一个容量为 20 的线程池,遍历 100 次循环,每次往线程池添加一个序列化的任务以及 19 个修改列表的操作。


按照上述操作,基本 100% 复现这个问题。


4、解决


如果只是从堆栈看,这个问题非常“诡异”,它看上去是在执行序列化的过程中把线程的锁匙放了。所以,为了找到问题的原因我做了几个测试。


当然,我首先想到的是为了解决并发修改的问题,除了使用同步容器,另外一种方式是使用并发容器。ArrayList 对应的并发容器是 CopyOnWriteArrayList换了该容器之后可以修复这个问题。


此外,我用自定义同步锁的形式在序列化操作的外部对整个序列化过程进行同步,这种方式也可以解决上述问题


不过,虽然解决了这个问题,此时还存在一个疑问就是序列化过程中锁是如何“丢”了的。为了更好地分析问题,我 Copy 了一份 JDK 的 SynchronizedList 的源码,并使用 Copy 的代码复现上述问题,试了很多次也没有出现。所以,这成了“看上去一样的代码,但是执行起来结果不同” 😓


最后,我把这个问题放到了 StackOverflow 上面。国外的一个开发者解答了这个问题,


截屏2023-01-26 22.14.56.png


就是说,


这是 JDK 的一个 bug,并且到 OpenJDK 19.0.2 还没有解决的一个问题。bug 单位于,


bugs.openjdk.org/browse/JDK-…


这是因为当我们使用 Collections 的方法 synchronizedList 获取同步容器的时候,


public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}

它会根据被包装的容器是否实现了 RandomAccess 接口来判断使用 SynchronizedRandomAccessList 还是 SynchronizedList 进行包装。RandomAccess 的意思是是否可以在任意位置访问列表的元素,显然 ArrayList 实现了这个接口。所以,当我们使用同步容器进行包装的时候,返回的是 SynchronizedRandomAccessList 这个类而不是 SynchronizedList 的实例.


SynchronizedRandomAccessList,它有一个 writeReplace() 方法


private Object writeReplace() {
return new SynchronizedList<>(list);
}

这个方法是用来兼容 1.4 之前版本的序列化的,所以,当对 SynchronizedRandomAccessList 执行序列化的时候会先调用 writeReplace() 方法,并将被包装的 list 对象传入,然后使用该方法返回的对象进行序列化而不是原始对象。


对于 SynchronizedRandomAccessList,它是 SynchronizedList 的子类,它们对私有锁的实现机制是相同的,即,两者都是对自身的实例 (也就是 this)进行加锁。所以,两者持有的 ArrayList 是同一实例,但是加锁的却是不同的对象。也就是说,序列化过程中加锁的对象是 writeReplace() 方法创建的 SynchronizedList 的实例,其他线程修改数据时加锁的是 SynchronizedRandomAccessList 对象。


验证的方式比较简单,在 writeObject() 出打断点获取 this 对象和最初的同步容器返回结果做一个对比即可。


总结


一个略坑的问题,问题解决比较简单,但是分析过程有些曲折,主要是被“锁在序列化过程被释放了”这个想法误导。而实际上之所以出现这个问题是因为加锁的是不同的对象。此外,还有一个原因是,序列化过程许多操作是反射执行的,比如 writeReplace()writeObject() 这些方法。


从这个例子中可以得出的另一个结论就是,同步容器和并发容器实现逻辑不同,看来在有些情形下两者起到的效果还是有区别的。序列化可能是一个极端的例子,但是下次序列化一个列表的时候是否应该考虑到 JDK 的这个 bug 呢?


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

@JvmDefaultWithCompatibility优化小技巧,了解一下~

今天写这篇文章主要是有两个原因: 不是为了卷,只是为了希望能在明天过年前升到5级,拿个优秀创作者的称号,提前祝大家新年快乐; 最近项目kotlin插件升级到了1.6.21,咬着牙把官方英文文档看了下,发现一些有用的知识分享给大家; 本篇文章主要是介...
继续阅读 »

今天写这篇文章主要是有两个原因:




  1. 不是为了卷,只是为了希望能在明天过年前升到5级,拿个优秀创作者的称号,提前祝大家新年快乐;




  2. 最近项目kotlin插件升级到了1.6.21,咬着牙把官方英文文档看了下,发现一些有用的知识分享给大家;




本篇文章主要是介绍下1.6.20提供了的一个新特性-Xjvm-default=all和搭配使用的@JvmDefaultWithCompatibility注解:


image.png


不过在讲解这个之前,我们需要一些准备知识。


前置知识-Kotlin接口默认方法实现机制


大家应该都知道Kotlin接口的方法是可以默认实现的:


interface ICallback {

fun execute() {
println("execute...")
}
}

看着确实是对接口方法实现了默认重写,但真的是表面上这样的吗?子类真的不需要实现方法了吗?


下面我们简单证明下:搞一个java类实现这个接口,不重写任何方法,看看会不会报错


image.png


很明显报错了,提示我们子类需要重写接口的execute()方法,所以我们可以得出一个结论:Kotlin接口的方法的默认实现是伪实现


那kotlin的这个伪实现的实现原理是啥呢,这里我们反编译下java代码看一看:


image.png


很明显的看到,ICallback接口的方法还是个抽象方法,并没有默认实现(这就是为什么java直接实现这个接口会报错的原因)。其次还生成了一个DefaultImpls中间类,这个中间类提供了真正默认实现的execute()方法逻辑。


当我们kotlin子类实现这个接口时:


class ICallbackChild2 : ICallback

这样写并不会发生任何报错,我们反编译成java代码看下:


image.png


可以看到,编译器会默认帮助我们实现接口的execute()方法,并调用了DefaultImpls类中的execute()完成了默认实现。


以上就是kotlin接口方法默认实现的原理,真正的实现逻辑通过一个默认生成的DefaultImpls类去完成。


现在我们思考下,为什么kotlin要这么实现呢,直接借助java的default关键字不可以吗,上面这种实现还多了一个类的开销?


Kotlin官方当然也发现了这个问题,所以在kotlin1.6.20提供了-Xjvm-default=all这个compile option来进行优化,接下来听我一一介绍。


-Xjvm-default=all登场


想要使用这个,需要在Android Studio中build.gradle增加下面配置:


kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += "-Xjvm-default=all"
}

这个完成之后,我们还是拿上面的接口作为例子讲解:


interface ICallback {

fun execute() {
println("execute...")
}
}

我们再次反编译成java代码看下:


image.png


可以看到,借助了default关键字完成了接口方法的默认实现,并且没有生成上面的DefaultImpls中间类,算是一个很不错的优化。


如果我们项目中之前定义了很多的kotlin接口默认方法实现,那这个编译优化可以帮助你减少很多中间类的生成。


这里我们再次思考一下,我们突然增加了这个compile option消除了DefaultImpls类,但是假如之前的代码有使用到这个类怎么办呢?我们不太可能挨个每个地方的去调整原来的业务代码,这样工作量就非常大了。


所以kotlin官方贴心的提供了@JvmDefaultWithCompatibility注解做了一个兼容处理,接下来听我一一介绍。


@JvmDefaultWithCompatibility做个兼容


先上一张官方图,最需要注意的就是第一行和最后一行:


image.png


在我们增加了上面的-Xjvm-default=all之后,借助default消除了DefaultImpls这个帮助类后,我们还可以通过@JvmDefaultWithCompatibility这个注解指定哪个接口保留这个DefaultImpls类,因为其他地方可能需要显示调用这个类


这里我们还是以上面的ICallback接口为例:


@JvmDefaultWithCompatibility
interface ICallback {

fun execute() {
println("execute...")
}
}

我们反编译成java代码看下:


image.png


可以看到,使用default实现了默认方法,并且DefaultImpls类依然存在,这就对过去kotlin接口的方法默认实现保持了兼容,尽量避免对业务逻辑的影响。


总结


其实kotlin之前有提供-Xjvm-default=all-compatibility和注解@JvmDefaultWithoutCompatibility搭配,不过这样对于业务开发不太友好,比如新增接口容易漏掉注解添加,再比如可能会对业务逻辑非public部分代码入侵过深等。


所以这里官方又提供了-Xjvm-default=all@JvmDefaultCompatibility搭配使用。希望本篇文章对你有所帮助。


新年快乐



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

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileServ...
继续阅读 »

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileService的方案实现。   

TileService继承自Service,所以它也是Android的四大组件之一,不过它是一个特殊的组件,开发者不需要手动开启调用,系统可以自动识别并完成调用,系统会通过绑定服务(bindService)的方式调用。

创建使用:

快捷开关是Android 7(target 24)的新能力,因此在使用该能力前必须先判断版本大小(大于等于target 24)。

1、自定义一个TileService类。

class MyQSTileService: TileService() {
  override fun onTileAdded() {    
super.onTileAdded()
}

  override fun onStartListening() {    
super.onStartListening()
}

  override fun onStopListening() {    
super.onStopListening()
}

  override fun onClick() {    
super.onClick()
}

  override fun onTileRemoved() {    
super.onTileRemoved()
}
}

TileService是通过绑定服务(bindService)的方式被调用的,因此,绑定服务生命周期包含的四种典型的回调方法(onCreate()、onBind()、onUnbind()和 onDestroy())都会被调用。但是,TileService也包含了以下特殊的生命周期回调方法:

  • onTileAdded():当用户从编辑栏添加快捷开关到通知栏的快速设置中会调用。
  • onTileRemoved():当用户从通知栏的快速设置移除快捷开关时调用。
  • onClick():当用户点击快捷开关时调用。
  • onStartListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileAdded添加之后会调用一次。
  • onStopListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileRemoved移除之前会调用一次。

2、在应用程序的清单文件中声明TileService

<service
android:name=".MyQSTileService"
android:label="@string/my_default_tile_label"
android:icon="@drawable/my_default_icon_label"
android:exported="true"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

  • name:自定义的TileService的类名。
  • label:快捷开关在通知栏上显示的名称。
  • icon:快捷开关在通知栏上显示的图标。
  • exported:该服务能否被外部应用调用。该属性必须为true。如果为false,那么快捷开关的功能将失效,原因是exported="false"时,TileService将不支持外部应用调起,手机系统自然不能再和该快捷开关交互。必须配置。
  • permission:需要给service配置的权限,BIND_QUICK_SETTINGS_TILE即允许应用程序绑定到第三方快速设置。必须配置。
  • intent-filter:意图过滤器,只有匹配内部的action,才能调起该service。必须配置。

监听模式

TileService的监听模式(或理解为启动模式)有两种,一种是主动模式,另一种是标准模式。

  • 主动模式

在主动模式下,TileService被请求时该服务会被绑定,并且TileService的onStartListening也会被调用。该模式需要在AndroidManifeast清单文件中声明:

<service ...>
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
...
</service>

通过TileService.requestListeningState()这一静态方法,就可以实现对TileService的请求,示例如下:

      TileService.requestListeningState(
applicationContext, ComponentName(
BuildConfig.APPLICATION_ID,
MyQSTileService::class.java.name
)
)

主动模式下值得注意的是:

  • 用户在通知栏快速设置的地方点击快捷开关时,TileService会自动完成绑定、TileService的onStartListening会被调用。
  • TileService无论是通过点击被绑定还是通过requestListeningState请求被绑定,TileService所在的进程都会被调起。

标准模式

     在标准模式下,TileService可见时(即用户下拉通知栏看见快捷开关)该服务会被绑定,并且TileService的onStartListening也会被调用。标准模式不需要在AndroidManifeast清单文件中进行额外的声明,默认就是标准模式。

标准模式下值得注意的是:

  • 和主动模式相同,TileService被绑定时,TileService所在的进程就会被调起。
  • 而和主动模式不同的是,标准模式绑定TileService是通过用户下拉通知栏实现的,这意味着TileService所在的进程会被多次调起。因此为了避免主进程被频繁调起、避免DAU等数据统计受到影响,我们还需要为TileService指定一个特定的子进程,在Androidmanifest清单文件中设置:
      <service
......
android:process="自定义子进程的名称">
......
</service>

更新快捷开关

如果需要对快捷开关的数据进行更新,可以通过getQsTile()获取快捷开关的对象,然后通过setIcon(更新icon)、setLable(更新名称)、setState(更新状态,包括STATE_ACTIVE——表示开启或启用状态、STATE_INACTIVE——表示关闭或暂停状态、STATE_UNAVAILABLE:表示暂时不可用状态,在此状态下,用户无法与您的磁贴交互)等方法设置快捷开关新的数据,最后调用updateTile()方法实现。

  override fun onStartListening() {
super.onStartListening()
if (qsTile.state === Tile.STATE_ACTIVE) {
qsTile.label = "inactive"
qsTile.icon = Icon.createWithResource(context, R.drawable.inactive)
qsTile.state = Tile.STATE_INACTIVE
} else {
qsTile.label = "active"
qsTile.icon = Icon.createWithResource(context, R.drawable.active)
qsTile.state = Tile.STATE_ACTIVE
}
qsTile.updateTile()
}

操作快捷开关

  • 如果想要实现点击快捷开关时、关闭通知栏并跳转到某个页面,可以调用以下方法:
startActivityAndCollapse(Intent intent)

  • 如果想要在点击快捷开关时弹出对话框进行交互,可以调用以下方法:
override fun onClick() {
super.onClick()
if(!isLocked()) {
showDialog()
}
}

因为快捷开关有可能在用户锁屏时出现,所以必须加上isLocked()的判断。只有非锁屏的情况下,对话框才会出现。

  • 如果快捷开关含有敏感信息,需要使用isSecure()进行设备安全性判断,当设备安全时,才能执行快捷开关相关的逻辑(如点击的逻辑)。当设备不安全时(手机处于锁屏状态时),可调用unlockAndRun(Runnable runnable),提示用户解锁屏幕并执行自定义的runnable操作。
收起阅读 »

Flutter图片与文件选择器

春节已过,今天是开工的第一天。我已经一个多星期没碰过电脑了,今日上班,打开电脑的第一件事就是想着写点什么。反正大家都还沉浸在节后的喜悦中,还没进入工作状态,与其浪费时间,不如做些更有意义的事情。 今天就跟大家简单分享一下Flutter开发过程中经常会用到的图片...
继续阅读 »

春节已过,今天是开工的第一天。我已经一个多星期没碰过电脑了,今日上班,打开电脑的第一件事就是想着写点什么。反正大家都还沉浸在节后的喜悦中,还没进入工作状态,与其浪费时间,不如做些更有意义的事情。


今天就跟大家简单分享一下Flutter开发过程中经常会用到的图片和文件选择器。


一、image_picker


一个适用于iOS和Android的Flutter插件,能够从图像库中选取图片、视频,还能够调用相机拍摄新的照片。


该插件由Flutter官方提供,github的Star高达16.7k,算是比较成熟且流行的插件了。


1、安装


flutter pub add image_picker

或者


/// pubspec.yaml文件添加依赖,并在执行flutter pub get命令
dependencies
image_picker: ^0.8.6+1

2、使用


import 'package:image_picker/image_picker.dart';

/// 图片选取
Future<void> getImage() async {
final XFile? file = await ImagePicker().pickImage(
source: ImageSource.gallery, // 图库选择
maxWidth: 1000.0, // 设置图片最大宽度,间接压缩了图片的体积
);

/// 选取图片失败file为null,要注意判断下。
/// 获取图片路径后可以上传到服务器上
print('${file?.path}');
}

/// 视频选取
Future<void> getImage() async {
final XFile? file = await ImagePicker().pickVideo(
source: ImageSource.camera, // 调用相机拍摄
);

print('${file?.path}');
}

在项目中,调用getImage方法就会打开图片选择器。


image.png


image.png


3、属性



  • source


图片来源,ImageSource.gallery图片库中选择,ImageSource.camera调用相机拍摄新图片。



  • maxWidth


图片的最大宽度,source为ImageSource.camera时有用,等于间接的压缩了图片的体积。如果不设置,以目前手机的相机性能,动不动就拍出了4、5M的照片,对于app来说,图片上传到服务端,将会很慢,建议设置此属性。


4、注意


iOS端如果出现闪退并且控制台报出:



The app's Info.plist must contain an NSPhotoLibraryAddUsageDescription key with a string value explaining to the user how the app uses this data.



那么,需要打开Xcode在Info.plist配置隐私提示语。


image.png


二、flutter_document_picker


文档选择器,image_picker只能选择图片和视频,如果要选择PDF,word文档、excel表格等就没办法了。这个时候可以使用flutter_document_picker插件,直接选取手机中的文件。


1、安装


flutter pub add flutter_document_picker

或者


/// pubspec.yaml文件添加依赖,并在执行flutter pub get命令
dependencies
flutter_document_picker: ^5.1.0

2、使用


import 'package:image_picker/image_picker.dart';

/// 图片选取
Future<void> getDocument() async {
FlutterDocumentPickerParams? params = FlutterDocumentPickerParams(
// 允许选取的文件拓展类型,不加此属性则默认支持所有类型
allowedFileExtensions: ['pdf', 'xls', 'xlsx', 'jpg', 'png', 'jpeg'],
);

String? path = await FlutterDocumentPicker.openDocument(
params: params,
);

print('$path');
}

image.png


总结


image_picker插件用于图片选取,而flutter_document_picker则用于文件选择,在日常开发中都是很常用的。在iOS中使用要注意隐私权限的配置,不然就会闪退。如果想了解更多的参数属性,可以查看官方文档:


image_picker document


flutter_document_picker document


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

作为一个学会智能合约的前端开发者,该如何找到 Web3 工作?

最近有个朋友咨询我一个问题。他做前端大概有五年了,技术比较一般。从去年开始就一直非常看好 web3 方向。但在 web2 的公司做久了,有一种思维定性,没有下定决心学习 web3 的技术,也不知道从何而起。后来他看到我开始布道 web3,纠结了一段时间,前段时...
继续阅读 »

最近有个朋友咨询我一个问题。

他做前端大概有五年了,技术比较一般。从去年开始就一直非常看好 web3 方向。但在 web2 的公司做久了,有一种思维定性,没有下定决心学习 web3 的技术,也不知道从何而起。

后来他看到我开始布道 web3,纠结了一段时间,前段时间终于下定决心,开始跟着我学起了他犹豫了一年的 web3 技术。

从 solidity 开始,到 truffle、ethersjs 等等,把常用的技术全学了一遍。也根据我的 github 项目做了三四个入门级别的 DApp,整个开发流程也相对熟悉。

这是 Github 项目地址:github.com/luzhenqian/…

但现在仍然很迷茫,因为现在的他需要一个真正转型的契机,也就是寻找一份 web3 工作。

这种迷茫,在我的理解中,应该是一类通用问题,也就是:作为一个前端开发者,在学会了智能合约后如何加入到 Web3 开发的公司或者团队。

我相信存在这类问题的人不在少数,所以在我帮朋友解惑后,顺便把这个过程中的一些思考整理出来,帮助更多存在同类问题的小伙伴答疑。

要完成这个任务,我认为需要做四个阶段的准备:确认自身技术栈、确认自身领域、确认目标公司、自我准备。

首先第一个就是整理好你的定位与技术栈。找工作这件事,其实就是营销。你需要把自己包装成产品,然后要对产品进行定位与美化。

如果仍然锚定做开发者,那么定位主要分两个:

  1. 仍然主做前端。

  2. 全栈。做合约开发,同时也做前端。

第二个就是确定好自己切入的细分领域,你可以理解为主要业务范畴。

Web3 目前的领域非常多,比如:

  • 交易所。

  • 公链。

  • NFT 交易所。

  • 钱包。

  • 链游。

  • DID。

第三个就是想好自己要加入的公司类型,这个时候公司就是你的用户,你需要给公司画用户画像。

以 base 为维度进行划分,基本上有三类:

  • remote

  • 国外

  • 国内

以规模进行划分,大概也有几类:

  • 顶级大厂,比如头部交易所和公链。

  • 独角兽公司,拿到千万级别美金的知名公司。

  • 商业化公司,拿到千万级别人民币的知名项目。

  • 增长阶段的公司,有一些用户,拿到了一些资本投资,处于增长阶段的公司。

  • 创业公司,还没有什么钱,也没有什么用户的刚起步的公司。

最后一个,你需要编写自己的简历。

如果你没有经验,那就自己给自己增加一些经验,比如想办法参加一些黑客松,参加一些社区活动,也可以在 Github 上做一些小项目,不一定功能很庞大很复杂,但一定要有创意和创新,小而美。

产品定位

先来看产品定位,也就是自身的问题。

技术栈我推荐你这么选:

  • next.js:最流行的前端框架,没有备选。

  • tailwindcss:最流行的 CSS 框架,没有备选。

  • solidity:最流行的智能合约编程语言,没有备选。

  • ethersjs:最流行的智能合约交互库,备选是 web3.js。

  • wagmi:最流行的 React Hook 智能合约交互库,没有备选。

  • hardhat:最流行的智能合约开发框架,备选是 truffle。

  • open zeppelin:最流行的智能合约库。

业务领域没有局限,都可以选择。但一定要有自己最擅长的那一个,也就是专精领域。在你的专精领域中,你就是专家,你可以一直侃侃而谈,但一定不能被问得哑口无言。比如选择 NFT。你需要把 NFT 相关的协议全部搞得非常明白,比如 ERC721、ERC1155 等。同时还需要知道白名单是怎么做的、荷兰拍卖该怎么去做、NFT 交易所怎么做,手续费如何抽取、NFT 的一些种类,PFP 背后的逻辑等等、一些蓝筹项目是如何运作起来的、NFT 创作网站的流程及实现方案等等。千万不要什么都懂,但什么都不精通。

用户画像

接下来就是用户画像,也就是公司的定位。

我推荐的公司画像是这样:remote、国外。规模的话自然是越大越好,因为薪资报酬相对都更稳定。当然一些小而美的公司也不错。在正式投递简历之前,一定要对公司进行全方位了解。比如公司的主营业务是什么,不存在实际价值的公司一定不要去。所谓实际价值,就是能够解决某一个或一类真实存在并有意义的实际问题。同时也要看一下团队背景和创始人背景。

简历阶段,我建议根据意向岗位来定制自己的简历。提高通过率,节省时间成本。首先在简历上把自己的技术体系写清楚,尽量契合目标岗位。在商业化以上的公司里面,更注重个人领域的细分,他们通常更看重你的技术深度,因为他们把你招进去是要做螺丝钉的。所以你需要在这方面多加准备。如果是创业阶段或者增长阶段的公司,他们通常更看重你的想法,因为在这个阶段中,他们的产品也是在得到用户反馈后迅速变化的,并没有形成固有的形态,他们需要你去和他们一起 building 产品。

另外在简历上也有一些小技巧。比如尽量把头像放上去,很多欧美的公司都需要通过头像知道你的肤色和你是哪国人。同时简历中不要使用很糟糕的英文。有找一些优质的简历模板进行参照。总之要从一些细节之处来展现你的专业度。

最后祝你早日加入理想的公司。

作者:代码与野兽
来源:juejin.cn/post/7193206064962404411

收起阅读 »

清华硕士被字节2W月薪录取后愤然发布"字节之恶"讨伐檄文火遍全网!

一清华硕士毕业生被字节2W月薪录取,该毕业生认为被严重侮辱,血泪控诉字节之恶,引发打工人对于清华高材生的薪水以及字节的广泛关注,在网上引起轩然大波 一开始看到他文案末尾的两个坏笑表情,还以为他是在凡尔赛,随口吐吐槽。该同学的字节录取通知书如下:后端开发工程师-...
继续阅读 »

一清华硕士毕业生被字节2W月薪录取,该毕业生认为被严重侮辱,血泪控诉字节之恶,引发打工人对于清华高材生的薪水以及字节的广泛关注,在网上引起轩然大波


一开始看到他文案末尾的两个坏笑表情,还以为他是在凡尔赛,随口吐吐槽。

该同学的字节录取通知书如下:后端开发工程师-基础架构,工作地点北京。

字节OFFER信息如下


谁知道该清华高材生收到字节2W月薪的OFFER后,愤愤不平并锲而不舍的攻击字节,认为2W月薪不如本科生,硕士白读了,感觉侮辱了自己和清华:


总结就是:

1、透露出公司效益不好,劝投资者抛售期权、员工年终别用现金换期权,做空丫的!

2、个人努力被否认了,很难受很悲哀,要将此痛双倍奉还!

并发布了标题为“字节之恶”讨伐檄文


对堂堂清华硕士毕业生开出2W月薪,严重的侮辱了该清华高材生的人格,字字控诉、声声血泪,令人闻之落泪。

中国互联网最牛逼的企业之一字节被它贬的一无是处,并列举了字节三大罪状!

疫情三年,百业调零,而高校每年的毕业生却屡创新高,僧多肉少,毕业后2W的薪水是否侮辱了该清华毕业生呢?


国家统计局出版的《中国统计年鉴2022》显示,北京城镇非私营单位就业人员的全年平均工资为194651元,按12个月计算,月薪就是16221元。

该清华硕士拿到的2万offer,显然高于普通北漂的工资水平,还没算年终奖、各种补贴。

国家统计局出版的《中国统计年鉴2022》显示,北京城镇非私营单位就业人员的全年平均工资为194651元,按12个月计算,月薪就是16221元


该清华学生的2W月薪显然是没有包含年终奖部分,字节年终奖一般为1-6个月,按照字节的公开薪酬资料该高材生年薪肯定是超过30W的


字节跳动的职级一共10级,从1-1到5-2,每一个大职级下分有1~2个小职级,1-1是初级工程师,1-2是研发工程师,2-1和2-1为资深研发,3-1和3-2为team领导层,4-1和4-2是部门领导层,5-1和5-2是公司领导层。

1-1和1-2主要由刚毕业或工作经验尚浅的员工组成,校招应届生目前一般是 1-2级,作为项目组成员;平均月薪24k,年终奖大概为3个月,比其他互联网大公司入门级别的月薪20k左右要高出20%左右。

而2-2可对标阿里P7、腾讯9、10级,将会有股票激励。关于期权,回购价格为市场价 8 折,已归属的期权员工可以带走。3-1和3-2就是公司的中层了。

接下来我们来看看2022年字节跳动员工工资标准一览表:


字节跳动不同序列间月薪差异较大,技术岗整体薪资偏高。字节跳动的现金薪酬要比其他大厂高出 25%-40%。

从表中可知,在字节,1-2级对应中位数薪资大概为31.5w,2-1级中位数薪资大概为42.3w,2-2级中位数薪资大概为57.9w,3-1级中位数薪资大概为101.1w,3-2级中位数薪资大概为134.1w,达到3-1就可以有年薪百万了。

年终奖一般 0-6 个月,会在每年 3 月发放。一般中等大多数是 15 薪,如果年中绩效超预期,则可以多拿 2 个月年终奖。

对于该该清华高材生对互联网巨头的血泪控诉,你怎么看呢?

来源:桔子聊球

收起阅读 »

[Flutter] 如何替换so文件来动态替换Flutter代码

一、Flutter代码的启动起点 我们在多数的业务场景下,使用的都是FlutterActivity、FlutterFragment。在在背后,我们知道有着FlutterEnigine、DartExecutor等等多个部件在支持它们的工作。我们所要探究的,就是,...
继续阅读 »

一、Flutter代码的启动起点


我们在多数的业务场景下,使用的都是FlutterActivityFlutterFragment。在在背后,我们知道有着FlutterEnigineDartExecutor等等多个部件在支持它们的工作。我们所要探究的,就是,它们是如何启动的,Dart代码是从何而来的,以实现动态替换libapp.so


以官方的计数器Demo为例,默认的Activity宿主,是实现了FlutterActivity的子类,对于一个Activity,我们最应该关心的就是它的onCreate方法:



  • FlutterActivity# onCreate


protected void onCreate(@Nullable Bundle savedInstanceState) {
switchLaunchThemeForNormalTheme();

super.onCreate(savedInstanceState);

delegate = new FlutterActivityAndFragmentDelegate(this);
delegate.onAttach(this);
delegate.onRestoreInstanceState(savedInstanceState);

lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE);

configureWindowForTransparency();
setContentView(createFlutterView());
configureStatusBarForFullscreenFlutterExperience();
}

其实过程很简单,FlutterActivity在这里做了一些主题的设置,因为毕竟FlutterActivity也是一个常规的Activity,它就必须按照Android的Activity的一些规范来进行设置。


第三行代码开始,就创建了一个我们所说的**FlutterActivityAndFragmentDelegate**对象,FlutterActivity将绝大多数的Flutter初始化相关逻辑委托给了它,而自身则专注于设置主题、窗口、StatusBar等等。


我们对delegate.onAttach(this);这一行代码的跟踪,最终能走到如下的一个创建流程:


FlutterActivity->
FlutterActivityAndFragmentDelegate->
onAttach()->
setupFlutterEngine->
1.尝试去Cache中获取Engine
2.尝试从Host中获取Engine
3.都没有的话创建一个新的Engine->
Engine #Constructor->
1. 会对Assets、DartExecutor、各种Channel、FlutterJNI做处理
2. 还会对FlutterLoader做处理->
startInitialization方法做初始化
-> 1. 必须在主线程初始化Flutter
-> 2. 先检查settings变量;
-> 3. 获取全局的ApplicationContext防止内存泄漏
-> 4. VsyncWaiter对象的初始化
-> 5. 最后会生成一个initTask交给线程池去执行

1.1 initTask对象


initTask是一个Callable对象,和Runnable类似的,我们可以将它理解成一个任务,也就是一段代码,他最终会被交给线程池去执行:


initResultFuture = Executors.newSingleThreadExecutor().submit(initTask);

initTask的代码如下


 // Use a background thread for initialization tasks that require disk access.
Callable<InitResult> initTask =
new Callable<InitResult>() {
@Override
public InitResult call() {
ResourceExtractor resourceExtractor = initResources(appContext);

flutterJNI.loadLibrary();

// Prefetch the default font manager as soon as possible on a background thread.
// It helps to reduce time cost of engine setup that blocks the platform thread.
Executors.newSingleThreadExecutor()
.execute(
new Runnable () {
@Override
public void run () {
flutterJNI.prefetchDefaultFontManager();
}
}
);

if (resourceExtractor != null) {
resourceExtractor.waitForCompletion();
}

return new InitResult(
PathUtils.getFilesDir(appContext),
PathUtils.getCacheDirectory(appContext),
PathUtils.getDataDirectory(appContext)
);
}
};

我们可以抓一下其中的关键字:



  • ResourceExtractor




  • FlutterJNI.loadLibrary




  • FlutterJNI.prefetchDefaultFontManager




  • PathUtils


不难发现,主要是在做一些资源的预取。


ResourceExtractor主要是针对在DEBUG或者是JIT模式下,针对安装包内资源的提取逻辑。


在DEBUG或者JIT模式下,需要提取Assets目录下的资源文件到存储中,Assets本质上还是Zip压缩包的一部分,没有自己的物理路径,所以需要提取,并返回真真实的物理路径。在DEBUG和JIT模式下,FlutterSDK和业务代码将被构建成Kernel格式的二进制文件,Engine将通过文件内存映射的方式进行加载。



详见:「三、libflutter.so和libapp.so」



1.2 ResourceExtractor



libflutter.so和libapp.so



在DEBUG | JIT模式下,我们是没有libapp.so的,而在release模式下,是有libapp.so文件的,我们分别解包两个不同的Apk文件,可以很清楚地看到这一点:



我们知道,libflutter.so是存放flutter的一些基础类库的so文件,而libapp.so则是存放我们业务代码的so文件,那如果在DEBUG|JIT模式下,没有libapp.so,那么我们的业务代码存储在哪里呢?


此时,我们就要看看ResourceExtractor的initResources方法,究竟干了些什么:


 /** Extract assets out of the APK that need to be cached as uncompressed files on disk. */
private ResourceExtractor initResources(@NonNull Context applicationContext) {
ResourceExtractor resourceExtractor = null;
if (BuildConfig.DEBUG || BuildConfig.JIT_RELEASE) {
final String dataDirPath = PathUtils.getDataDirectory(applicationContext);
final String packageName = applicationContext.getPackageName();
final PackageManager packageManager = applicationContext.getPackageManager();
final AssetManager assetManager = applicationContext.getResources().getAssets();
resourceExtractor =
new ResourceExtractor(dataDirPath, packageName, packageManager, assetManager);

// In debug/JIT mode these assets will be written to disk and then
// mapped into memory so they can be provided to the Dart VM.
resourceExtractor
.addResource(fullAssetPathFrom(flutterApplicationInfo.vmSnapshotData))
.addResource(fullAssetPathFrom(flutterApplicationInfo.isolateSnapshotData))
.addResource(fullAssetPathFrom(DEFAULT_KERNEL_BLOB));

resourceExtractor.start();
}
return resourceExtractor;
}

其中的addResource方法,分别提供了VM的快照数据、iSolate的快照数据DEFAULT_KERNEL_BLOB的数据。因为Flutter本身支持热重载的特性,保存状态和快照(Snapshot)之间必然是不可分割的。


而DEFAULT_KERNEL_BLOB是一个字符串常量: "kernel_blob.bin",结合前面的内容:



FlutterSDK和业务代码将被构建成Kernel格式的二进制文件



我们有理由猜测, "kernel_blob.bin" ,就是我们的业务代码,Flutter是支持逻辑代码热重载的,所以这个字面量加载的资源同样可能会被重新加载。



这也是为什么,如果我们在State中,新增了某个变量作为Widget的某个状态,在initState中调用了,然后使用热重载之后,会导致State中找不到这个变量,因为initState在初次启动时就被调用过了,后续的热重载只会将之前的Snapshot恢复回来,而不会走initState的逻辑。



我们可以在app-debug.apk的assets中,找到"kernel_blob.bin"文件,同样也可以找到isolate_snapshot_data、vm_snapshot_data文件,所以ResourceExtractor加载的,基本上都是这个文件夹中的文件。



但是,在非DEBUG|JIT模式下,就不需要通过ResourceExtractor来进行加载了。


回到initTask方法,只在resourceExtractor != null时,会去等待它的完成。


ResourceExtractor resourceExtractor = initResources(appContext);

flutterJNI.loadLibrary();

// Prefetch the default font manager as soon as possible on a background thread.
// It helps to reduce time cost of engine setup that blocks the platform thread.
Executors.newSingleThreadExecutor()
.execute(
new Runnable() {
@Override
public void run() {
flutterJNI.prefetchDefaultFontManager();
}
});

if (resourceExtractor != null) {
resourceExtractor.waitForCompletion();
}

1.3 FlutterJNI#loadLibrary


public void loadLibrary() {
if (FlutterJNI.loadLibraryCalled) {
Log.w(TAG, "FlutterJNI.loadLibrary called more than once" );
}

System.loadLibrary( "flutter" );
FlutterJNI.loadLibraryCalled = true;
}

代码比较简单,无非就是调用System.loadLibrary去加载Library文件。需要注意的是,表面上找到是flutter,但是在Native(C++)层中,会为它拼接上前缀和后缀:lib和.so,所以,实际上load行为查找的是位于apk包下的lib目录下的对应架构文件夹下的libflutter.so


initTask任务提交给线程池之后,就相当于startInitialization走完了。



你会发现有个问题,在Debug模式下,我们加载业务代码是从二进制文件:"kernel_blob.bin"中加载的,而Release模式下,实在libapp.so中加载的,上面已经出现了加载"kernel_blob.bin"和libflutter.so ,那么在release模式下,另一个Library文件:libapp.so是什么时候加载的呢?


所以,就要进入我们的第二个关键方法:ensureInitializationComplete



二、ensureInitializationComplete


实际上,ensureInitializationComplete和startInitialization在FlutterEngine的初始化代码中


flutterLoader.startInitialization(context.getApplicationContext());
flutterLoader.ensureInitializationComplete(context, dartVmArgs);

代码一百多行,但是大多都是一些配置性的代码:


public void ensureInitializationComplete(
@NonNull Context applicationContext, @Nullable String[] args) {
if (initialized) {
return;
}
if (Looper.myLooper() != Looper.getMainLooper()) {
throw new IllegalStateException(
"ensureInitializationComplete must be called on the main thread" );
}
if (settings == null) {
throw new IllegalStateException(
"ensureInitializationComplete must be called after startInitialization" );
}
try {
InitResult result = initResultFuture.get();

List<String> shellArgs = new ArrayList<>();
shellArgs.add( "--icu-symbol-prefix=_binary_icudtl_dat" );

shellArgs.add(
"--icu-native-lib-path="
+ flutterApplicationInfo.nativeLibraryDir
+ File.separator
+ DEFAULT_LIBRARY);
if (args != null) {
Collections.addAll(shellArgs, args);
}

String kernelPath = null;
if (BuildConfig.DEBUG || BuildConfig.JIT_RELEASE) {
String snapshotAssetPath =
result.dataDirPath + File.separator + flutterApplicationInfo.flutterAssetsDir;
kernelPath = snapshotAssetPath + File.separator + DEFAULT_KERNEL_BLOB;
shellArgs.add( "--" + SNAPSHOT_ASSET_PATH_KEY + "=" + snapshotAssetPath);
shellArgs.add( "--" + VM_SNAPSHOT_DATA_KEY + "=" + flutterApplicationInfo.vmSnapshotData);
shellArgs.add(
"--" + ISOLATE_SNAPSHOT_DATA_KEY + "=" + flutterApplicationInfo.isolateSnapshotData);
} else {
shellArgs.add(
"--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.aotSharedLibraryName);

// Most devices can load the AOT shared library based on the library name
// with no directory path. Provide a fully qualified path to the library
// as a workaround for devices where that fails.
shellArgs.add(
"--"
+ AOT_SHARED_LIBRARY_NAME
+ "="
+ flutterApplicationInfo.nativeLibraryDir
+ File.separator
+ flutterApplicationInfo.aotSharedLibraryName);
}

shellArgs.add( "--cache-dir-path=" + result.engineCachesPath);
if (!flutterApplicationInfo.clearTextPermitted) {
shellArgs.add( "--disallow-insecure-connections" );
}
if (flutterApplicationInfo.domainNetworkPolicy != null) {
shellArgs.add( "--domain-network-policy=" + flutterApplicationInfo.domainNetworkPolicy);
}
if (settings.getLogTag() != null) {
shellArgs.add( "--log-tag=" + settings.getLogTag());
}

ApplicationInfo applicationInfo =
applicationContext
.getPackageManager()
.getApplicationInfo(
applicationContext.getPackageName(), PackageManager.GET_META_DATA);
Bundle metaData = applicationInfo.metaData;
int oldGenHeapSizeMegaBytes =
metaData != null ? metaData.getInt(OLD_GEN_HEAP_SIZE_META_DATA_KEY) : 0;
if (oldGenHeapSizeMegaBytes == 0) {
// default to half of total memory.
ActivityManager activityManager =
(ActivityManager) applicationContext.getSystemService(Context.ACTIVITY_SERVICE);
ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo();
activityManager.getMemoryInfo(memInfo);
oldGenHeapSizeMegaBytes = (int) (memInfo.totalMem / 1e6 / 2);
}

shellArgs.add( "--old-gen-heap-size=" + oldGenHeapSizeMegaBytes);

if (metaData != null && metaData.getBoolean(ENABLE_SKPARAGRAPH_META_DATA_KEY)) {
shellArgs.add( "--enable-skparagraph" );
}

long initTimeMillis = SystemClock.uptimeMillis() - initStartTimestampMillis;

flutterJNI.init(
applicationContext,
shellArgs.toArray(new String[0]),
kernelPath,
result.appStoragePath,
result.engineCachesPath,
initTimeMillis);

initialized = true;
} catch (Exception e) {
Log.e(TAG, "Flutter initialization failed." , e);
throw new RuntimeException(e);
}
}

显然,ensureInitializationComplete也必须在主线程中进行调用,并且必须在startInitialization之后进行调用。此外,我们要注意另外一个东西:shellArgs。


2.1 ShellArgs


Shell是什么大家并不陌生,在计算机中,Shell通常作为系统调用用户操作之间的那么个东西,它存在的形式在Linux/Mac中一般就是一个Shell软件,通常运行在终端当中(你可以粗略地就将Shell 和终端划等号 )。


所以,Flutter的Shell自然而然地旨在设置Flutter运行的一个「基底」,ShellArgs,则是我们使用这么个「基底」的参数。


和之前提到的ResourceExtractor在JIT|DEBUG模式下主动去加载VM和Isoalte快照数据类似地,ShellArgs会在DEBUG和JIT模式下,去设置VM快照数据、Isolate快照数据和Kernel的地址。



别忘了,Kernel即上述的“kernel_blob.bin”二进制文件,是在Debug阶段我们的业务代码,和libapp.so是相对的。



而在除上述之外的条件下,Flutter设置了一个AOT_SHARED_LIBRARY_NAME的路径:


shellArgs.add(
"--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.aotSharedLibraryName);

shellArgs.add(
"--"
+ AOT_SHARED_LIBRARY_NAME
+ "="
+ flutterApplicationInfo.nativeLibraryDir
+ File.separator
+ flutterApplicationInfo.aotSharedLibraryName);

在运行时,这个向shareArgs这个List中添加内容的两个字符串的内容,大致上就是指定了装载在系统的Apk安装包中的so文件的路径。


--aot-shared-library-name=libapp.so
--aot-shared-library-name=/data/app/~~RjRJYnLhVBHYW8pHHPeX2g==/com.example.untitled1-wcMTYW1VkfGA2LxW62gUFA==/lib/arm64/libapp.so


因为Tinker本身是支持二进制SO库的动态化的,之前尝试过去动态修改aotSharedLibraryName的值和路径,希望FlutterLoader从该地址去加载libapp.so,以实现Android侧借助Tinker热修复Flutter代码,但是并没有细看源码,打了N个Debug包去测试,结果现在发现这逻辑压根没走。




除了上述的两个libapp.so的名称和路径之外,在DEBUG | JIT模式下的ShellArgs的全家福大致如下:



其实你仔细看看,上述的Kernel的Path并没有在这里面,因为它作为参数,传递给了flutterJNI.init函数。


三、实践:自定义libapp.so的加载


至此,我们今天最开始的一个话题:Embdder和代码Dart代码从何而来, 便有了结果 。结合上述的内容,我们可以做一个小小的实践,我们通过传入ShellArgs,来加载指定的 libapp.so 文件。


回到我们最初的流程:


FlutterActivity->
FlutterActivityAndFragmentDelegate->
onAttach()->
setupFlutterEngine->
……
startInitialization
ensureInitializationComplete // alpha

我们需要在上述的过程的alpha之前,完成对***AOT_SHARED_LIBRARY_NAME*** 对应的路径(一模一样,也是 AOT_SHARED_LIBRARY_NAME )这两个字符串的内容替换,比如:


--aot-shared-library-name=libapp.so
--aot-shared-library-name= /data/app/~~RjRJYnLhVBHYW8pHHPeX2g==/com.example.untitled1-wcMTYW1VkfGA2LxW62gUFA==/lib/arm64/libapp.so

我们希望替换成:


--aot-shared-library-name=libfixedapp.so
--aot-shared-library-name= /temp/lib/arm64/libfixedapp.so

3.1 flutterApplicationInfo和FlutterActivity#getShellArgs()


这是FlutterLoader的一个实例对象,它在startInitialization阶段被赋值:


public void startInitialization(@NonNull Context applicationContext, @NonNull Settings settings) {
// ……
try {
final Context appContext = applicationContext.getApplicationContext();
// ……
flutterApplicationInfo = ApplicationInfoLoader.load(appContext);

……

所以,我们只需要在合适的时机去修改这个值即可。


但是并没有合适的时机,因为Flutter并没有为我们提供可以侵入去反射设置它的时机,如果在startInitialization,我们唯一可以侵入的时机是attach()函数,但是会让我们反射设置的值被覆盖掉。


但是,我们关注一下,在setupFlutterEngine时,我们new FlutterEngine的参数:


flutterEngine =
new FlutterEngine(
host.getContext(),
host.getFlutterShellArgs().toArray(),
/*automaticallyRegisterPlugins=*/ false,
/*willProvideRestorationData=*/ host.shouldRestoreAndSaveState());

此处的host,就是我们的FlutterActivity,因为FlutterActivity本身就是FlutterActivityAndFragmentDelegate.Host接口的实现类,而这个host.getFlutterShellArgs().toArray(),最终会作为我们在FlutterActivity预设的参数,在所其他系统预设参数被加入之前被加入到我们的shellArgs数组中。


所以,我们在FlutterActivity的子类,也就是MainActivity下,重写getFlutterShellArgs()方法:


class MainActivity: FlutterActivity() {
override fun getFlutterShellArgs(): FlutterShellArgs {
return super.getFlutterShellArgs().apply {
this.add( "--aot-shared-library-name=libfixedapp.so" )
this.add( "--aot-shared-library-name=/data/data/com.example.untitled1/libfixedapp.so" )
}
}
}

我们可以在debug模式下debug,看看有没有效果:



显然,是有效果的。



因为只能从几个特定的目录中去加载so库文件,我们必须将补丁SO文件放在/data/data/com.example.untitled1对应的目录之下。



接下来,我们先写一个有bug的Flutter代码,我们把标题改成:This is Counter Title with bug 并且新增一个 _decrementCounter() 并把计数器的加法按钮对应的增加按钮,改成减少调用。


然后在Flutter项目根目录使用安装Release包:


flutter build apk --release
adb install build/app/outputs/flutter-apk/app-release.apk

然后我们修复Bug,将代码恢复到最开始的默认状态,然后:


flutter build apk --release
open build/app/outputs/flutter-apk/

解压apk,然后把对应的so文件移出来,放到对应的文件夹下: /data/data/com.example.untitled1/libfixedapp.so 。完成之后,重新启动程序,即可从新的、我们指定的路径加载新的 libapp.so 了:



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

因为买不到烟花,所以我想用Compose来放烟花

再过几天就要过大年了,最近周围也是一到晚上就是到处都在放烟花,看得我十分眼馋也想搞点烟花来放放,可惜周围实在是买不到,一打听全是托人在外省买的,算了太麻烦了,那真的烟花放不了,我就干脆决定写个烟花出来吧,应应景,也烘托点年味儿出来~刚好最近学了点Compose...
继续阅读 »

再过几天就要过大年了,最近周围也是一到晚上就是到处都在放烟花,看得我十分眼馋也想搞点烟花来放放,可惜周围实在是买不到,一打听全是托人在外省买的,算了太麻烦了,那真的烟花放不了,我就干脆决定写个烟花出来吧,应应景,也烘托点年味儿出来~刚好最近学了点Compose的动画,所以这个烟花就拿Compose来写,先上个最终效果图


afire.gif


不好意思...放错效果图了...这个才是


afire9.gif


gif有点卡,真实效果还要流畅点,这些我们先不说,先来看看这个动画我们需要做些什么



  1. 一闪一闪(对..在闪)的小星星

  2. 逐渐上升的烟花火苗

  3. 烟花炸开的效果

  4. 炸开后闪光效果


开始开发


闪烁的星星


首先我们放烟花肯定是在晚上放烟花的,所以整体画布首先背景色就是黑色,模拟一个夜空的场景


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
) {

}

确定好了画布以后,我们先来想想如何画星星,夜空中的星星其实就是在画布上画几个小圆点,然后小圆点的颜色是白色的,最后星星看起来都是有大有小的,因为距离我们的距离不一样,所以我们的小圆点也要看起来大小不一样,也就是圆点的半径不一样,知道这些以后我们开始设计代码,先确定好需要的变量,比如画布的中心点xy坐标,星星的xy坐标,以及星星的颜色


val drawColor = colorResource(id = R.color.white)

val centerX = screenWidth() / 2
val centerY = screenHeight() / 2
val starXList = listOf(
screenWidth() / 12, screenWidth() / 6, screenWidth() / 4,
screenWidth() / 3, screenWidth() * 5 / 12, screenWidth() / 2, screenWidth() * 7 / 12,
screenWidth() * 2 / 3, screenWidth() * 3 / 4, screenWidth() * 5 / 6, screenWidth() * 11 / 12
)
val starYList = listOf(
centerY / 12, centerY / 6, centerY / 4,
centerY / 3, centerY * 5 / 12, centerY / 2, centerY * 7 / 12,
centerY * 2 / 3, centerY * 3 / 4, centerY * 5 / 6, centerY * 11 / 12
)

starXList放星星的横坐标,横坐标就是把画布宽十二等分,starYList放星星的纵坐标,纵坐标就是把画布高的二分之一再十二等分,这样作法的目的就是最终画圆点的时候,两个List可以随机选取下标值,达到星星随机散布在夜空的效果


drawCircle(drawColor, 5f, Offset(starXList[0], starYList[10]))
drawCircle(drawColor, 4f, Offset(starXList[1], starYList[9]))
drawCircle(drawColor, 3f, Offset(starXList[2], starYList[4]))
drawCircle(drawColor, 5f, Offset(starXList[3], starYList[6]))
drawCircle(drawColor, 6f, Offset(starXList[4], starYList[3]))
drawCircle(drawColor, 5f, Offset(starXList[5], starYList[7]))
drawCircle(drawColor, 6f, Offset(starXList[6], starYList[2]))
drawCircle(drawColor, 2f, Offset(starXList[7], starYList[1]))
drawCircle(drawColor, 5f, Offset(starXList[8], starYList[0]))
drawCircle(drawColor, 2f, Offset(starXList[9], starYList[5]))
drawCircle(drawColor, 2f, Offset(starXList[10], starYList[8]))

然后一闪一闪的效果怎么做呢,一闪一闪也就是圆点的半径循环在变大变小,所以我们需要用到Compose的循环动画rememberInfiniteTransition,这个函数可以通过它的animateXXX函数来创建循环动画,它里面有三个这样的函数


image.png


我们这里就使用animateFloat来创建可以变化的半径


val startRadius by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing))
)

这个函数返回的是一个Float类型的值,前两个参数很好理解,初始值跟最终值,第三个参数是一个
InfiniteRepeatableSpec的对象,它决定这个循环动画的一些参数,duration决定动画持续时间,delayMillis延迟执行的时间,easing决定动画执行的速度



  • LinearEasing 匀速执行

  • FastOutLinearInEasing 逐渐加速

  • FastOutSlowInEasing 先加速后减速

  • LinearOutSlowInEasing 逐渐减速


这里的星星的动画就选择匀速执行就好,我们把starRadius设置到星星的绘制流程里面去


drawCircle(drawColor, 5f + startRadius, Offset(starXList[0], starYList[10]))
drawCircle(drawColor, 4f + startRadius, Offset(starXList[1], starYList[9]))
drawCircle(drawColor, 3f + startRadius, Offset(starXList[2], starYList[4]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[3], starYList[6]))
drawCircle(drawColor, 6f + startRadius, Offset(starXList[4], starYList[3]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[5], starYList[7]))
drawCircle(drawColor, 6f + startRadius, Offset(starXList[6], starYList[2]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[7], starYList[1]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[8], starYList[0]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[9], starYList[5]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[10], starYList[8]))

效果就是这样的


afire3.gif


烟花火苗


现在开始绘制烟花部分,首先是上升的火苗,火苗也是个小圆点,它的起始坐标跟终点坐标很好确定,横坐标都是centerX即画布的一半,纵坐标开始位置是在画布高度位置,结束是在centerY即画布一半高度位置,而一次放烟花的过程中,烟花炸开的次数有很多次,伴随着火苗上升次数也很多次,所以这个也是个循环动画,整个过程代码实现如下


val fireDuration = 3000
val shootHeight by transition.animateFloat(
screenHeight(),
screenHeight() / 2,
animationSpec = InfiniteRepeatableSpec(tween(fireDuration,
easing = FastOutSlowInEasing))
)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}

由于火苗上升会随着重力逐渐减速,所以这里选择的是先快后慢的动画效果,效果如下


afire4.gif


烟花炸开


这一部分难度开始增加了,因为烟花炸开这个效果是要等到火苗上升到最高点的位置然后在炸开的,这两个动画有个先后关系,用惯了Androi属性动画的我刚开始还不以为然,认为肯定会有个动画回调或者监听器之类的东西,然而看了下循环动画的源码发现并没有找到想要的监听器


image.png
那只能换个思路了,刚刚说到炸开的动画是在火苗上升到最高点的时候才开始的,那这个最高点就是个开关,当火苗到达最高点的时候,让火苗的动画“暂停”,然后开始炸开的动画,现在问题的关键是,如何让火苗的动画“暂停”,我们知道火苗的动画是一个循环动画,循环动画是从初始值到最终值循环变化的过程,那么我们是不是只要让这两个值都为同一个,让它们没有变化的空间,是不是就等于让这个动画“暂停”了呢,我们开始设计这个过程


var turnOn by remember { mutableStateOf(false) }
val distance = remember { Animatable(screenHeight().dp, Dp.VectorConverter) }
LaunchedEffect(turnOn) {
distance.snapTo(if (turnOn) screenHeight().dp else 0.dp)
}

turnOn是个开关,true的时候表示火苗动画开始,false的时候表示火苗动画已经到达最高点,distance是一个Animatable的对象,Animatable是啥呢,从字面上就能理解它也是个动画,但与我们刚刚接触的循环动画不一样,它只有从初始值到最终值的单向变化,而后面的LaunchedEffect是啥呢,我们点到里面去看下它的源码


fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
)
{
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

这里我们看到key1是任何值,被remember保存了起来,block是个挂起的函数类型对象,也就是block是运行在协程里面的,我们再去LaunchedEffectImpl里面看看


image.png


我们看到了这个协程是在被remember的值发生改变以后才去执行的,那现在清楚了,每次改变turnOn的值,distance就会来回从screenHeight()和0之间切换,而切换的条件就是火苗上升高度到达了画布的一半,我们改一下刚刚火苗的动画,让shootHeight随着distance变化而变化,另外我们给画布添加个点击事件,每次点击让turnOn的值发生改变,目的让动画多进行几次


val shootHeight by transition.animateFloat(
distance.value.value,
distance.value.value / 2,
animationSpec = InfiniteRepeatableSpec(tween(fireDuration,
easing = FastOutSlowInEasing))
)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
turnOn = false
}
}

我们看下效果是不是我们想要的


afire5.gif


So far so good~动画已经分离开来了,现在就要开始炸开效果的开发,我们先脑补下炸开是什么样子的,是由火苗开始,向四周延伸出去若干条烟火,或者换句话说就是以火苗为圆心,向四周画线条,这样说我们思路有了,这是一个由圆心开始向外drawLine的过程,drawLine这个api大家很熟悉了,最主要的就是确定开始跟结束两处的坐标,但是无论开始还是结束,这两个坐标都是分布在一个圆周上的,所以我们第一步先要确定在哪几个角度上面画线


val anglist = listOf(30, 75, 120, 165, 210, 255, 300, 345)

知道了角度以后,就要去计算xy坐标了,这个就要用到正弦余弦公式


private fun calculateX(centerX: Float, fl: Int, endCor: Boolean): Float {
val angle = Math.toRadians(fl.toDouble())
return centerX - cos(angle).toFloat() * (if (endCor) screenWidth() / 2 else screenWidth() / 12)
}

private fun calculateY(centerY: Float, fl: Int, endCor: Boolean): Float {
val angle = Math.toRadians(fl.toDouble())
return centerY - sin(angle).toFloat() * (if (endCor) screenWidth() / 2 else screenWidth() / 12)
}

其中endColor是true就是画终点的坐标,false就是起点的坐标,我们先画一条线,剩下的线的代码都相同


val startfireOneX = calculateX(centerX, anglist[0], false)
val startfireOneY = calculateY(centerY, anglist[0], false)
val endfireOneX = calculateX(centerX, anglist[0], true)
val endfireOneY = calculateY(centerY, anglist[0], true)

var fireColor = colorResource(id = R.color.color_03DAC5)
var fireOn by remember { mutableStateOf(false) }
val fireOneXValue = remember { Animatable(startfireOneX, Float.VectorConverter) }
val fireOneYValue = remember { Animatable(startfireOneY, Float.VectorConverter) }
val fireStroke = remember { Animatable(0f, Float.VectorConverter) }

LaunchedEffect(fireOn){
fireStroke.snapTo(if(fireOn) 20f else 0f)
fireOneXValue.snapTo(if(fireOn) endfireOneX else startfireOneX)
fireOneYValue.snapTo(if(fireOn) endfireOneY else startfireOneY)
}

fireOneXValue是第一条线横坐标的变化动画,fireOneYValue是纵坐标的变化动画,它们的改变都有fireOn去控制,fireOn打开的时机就是火苗上升到最高点的时候,同时我们也增加了fireStroke,表示线条粗细的变化动画,也随着fireOn的改变而改变,我们现在去创建横坐标,纵坐标以及线条粗细的循环动画


val fireOneX by transition.animateFloat(
startfireOneX, fireOneXValue.value,
infiniteRepeatable(tween(fireDuration, easing = FastOutSlowInEasing))
)
val fireOneY by transition.animateFloat(
startfireOneY, fireOneYValue.value,
infiniteRepeatable(tween(fireDuration, easing = FastOutSlowInEasing))
)

val strokeW by transition.animateFloat(
initialValue = fireStroke.value/20,
targetValue = fireStroke.value,
animationSpec = infiniteRepeatable(tween(fireDuration,
easing = FastOutSlowInEasing))
)

我们现在可以去绘制第一根线了,在Canvas里面增加第一个drawLine


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
turnOn = false
fireOn = true
}
drawLine(
fireColor, Offset(startfireOneX, startfireOneY),
Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
)
}

到了这一步,我们应该考虑的是,如何让动画衔接起来,也就是炸开动画完成以后,继续执行火苗动画,那么我们就要找出炸开动画结束的那个点,这里总共有三个值,我们选择strokeW,当线条粗细到达最大值的时候,将fireOn关闭,将turnOn打开,我们在drawLine后面加上这段逻辑


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
turnOn = false
fireOn = true
}
drawLine(
fireColor, Offset(startfireOneX, startfireOneY),
Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
)
if(strokeW == 19){
fireOn = false
turnOn = true
}
}

这个时候,两个动画就连起来了,我们运行下看看效果


afire6.gif


一条线完成了,那么其余几根线道理也是一样的,代码有点多篇幅关系就不贴出来了,直接看效果图吧


afire7.gif


基本的样子已经出来了,现在给这个烟花优化一下,我们知道放烟花时候,每次炸开的样子都是不一样的,红橙黄绿啥颜色都有,我们这边也让每次炸开时候,颜色都不一样,那首先我们要弄一个颜色的集合


val colorList = listOf(
colorResource(id = R.color.color_03DAC5), colorResource(id = R.color.color_BB86FC),
colorResource(id = R.color.color_E6A639), colorResource(id = R.color.color_01B9FF),
colorResource(id = R.color.color_FF966B), colorResource(id = R.color.color_FFEBE7),
colorResource(id = R.color.color_FF4252), colorResource(id = R.color.color_EC4126)
)

并且让fireColor在每次炸开之前,更换一次颜色,随机换也行,按照下标顺序替换也行,这边我选择顺序换了,位置就是炸开动画开始的地方


var colorIndex by remember { mutableStateOf(0) }

Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
if (strokeW.toInt() == 0) {
colorIndex += 1
if (colorIndex > 7) colorIndex = 0
fireColor = colorList[colorIndex]
}
turnOn = false
fireOn = true
}
drawLine(
fireColor, Offset(startfireOneX, startfireOneY),
Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
)
if(strokeW == 19){
fireOn = false
turnOn = true
}
}

我们再想想看,烟花结束以后是不是还会有一些闪光,有的烟花的闪光还会有声音,声音我们弄不出来,但是闪光还是可以的,还记得我们星星怎么画的吗,不就是几个圆圈在那里不断绘制,然后一闪一闪的效果就是不断改变圆圈的半径,那我们烟花的闪光效果也可以这么做,首先我们先确定好需要绘制圆圈的坐标


val endXAnimList = listOf(
calculatePointX(centerX, anglist[0]),
calculatePointX(centerX, anglist[1]),
calculatePointX(centerX, anglist[2]),
calculatePointX(centerX, anglist[3]),
calculatePointX(centerX, anglist[4]),
calculatePointX(centerX, anglist[5]),
calculatePointX(centerX, anglist[6]),
calculatePointX(centerX, anglist[7])
)
val endYAnimList = listOf(
calculatePointY(centerY, anglist[0]),
calculatePointY(centerY, anglist[1]),
calculatePointY(centerY, anglist[2]),
calculatePointY(centerY, anglist[3]),
calculatePointY(centerY, anglist[4]),
calculatePointY(centerY, anglist[5]),
calculatePointY(centerY, anglist[6]),
calculatePointY(centerY, anglist[7])
)

然后烟花放完以后会有个逐渐暗淡的过程,在这里我们就让圆圈的半径也有个逐渐变小的过程,那我们就创建个变小的动画


val pointDuration = 3000
val firePointRadius = remember{ Animatable(0f, Float.VectorConverter) }
val pointRadius by transition.animateFloat(
initialValue = firePointRadius.value,
targetValue = firePointRadius.value / 6,
animationSpec = infiniteRepeatable(tween(pointDuration,
easing = FastOutLinearInEasing))
)

有了这个闪光的动画以后,接下去就要让它跟炸开的动画衔接起来了,这边也跟其他动画一样,增加一个开关去控制,当开关打开之后,firePointRadius设置成最大,开启这个闪光动画,当开关关闭以后,就让firePointRadius设置为0,也就是关闭闪光动画,代码如下


var pointOn by remember { mutableStateOf(false) }
LaunchedEffect(pointOn) {
firePointRadius.snapTo(if (pointOn) 12f else 0f)
}

参数都设置好了以后,我们可以去绘制闪光的圆圈了,这边我们让闪光的开关在炸开动画完毕之后打开,原本要开启的火苗动画我们暂时先不打开,而闪光动画的颜色我们让它跟炸开的动画颜色一致,让整个过程看上去像是烟花自己炸开然后变成小颗粒的样子


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
....此处省略前面两个烟花动画的绘制过程.....

if(strokeW == 19){
fireOn = false
pointOn = true
}
if(pointOn){
repeat(endXAnimList.size) {
drawCircle(
colorList[colorIndex], pointRadius,
Offset(endXAnimList[it], endYAnimList[it])
)
}
}
}

到了这里感觉好像漏了点什么,没错,之前我们暂时把火苗开关打开的时机取消了,那这个开关得打开呀,不然我们的烟花没办法连在一起放,现在就是要找到这个临界值,我们发现这个绘制圆圈的过程,只有圆圈的半径在随着时间的递进逐渐变小的,它的最小值是当pointOn开关打开之后,targetValue的值也就是2,所以我们可以判断当pointRadius变成2的时候,将闪光动画关闭,火苗动画打开,我们将这个判断加到绘制圆圈的后面


if(pointOn){
repeat(endXAnimList.size) {
drawCircle(
colorList[colorIndex], pointRadius,
Offset(endXAnimList[it], endYAnimList[it])
)
}
if (pointRadius.toInt() == 2) {
pointOn = false
turnOn = true
}
}

现在动画已经都衔接起来了,我们看下效果吧


afire8.gif


额~~感觉怪怪的,说好的闪光呢,但就动画而言圆圈的确是完成了半径逐渐变小的绘制过程,那么问题出在哪里呢?我们回到代码中再检查一遍,发现了这一处代码


Offset(endXAnimList[it], endYAnimList[it])
复制代码

这个圆点绘制的位置是均匀分布在一个圆周上的,也就是只绘制了八个圆点,但是真实效果里面的圆点有很多个,那我们是不是只要将endXAnimList,endYAnimList这两个数组里面的坐标打乱随机组成一个圆点不就好了,这样一来最多会绘制出64个圆点,再配合动画不就能达到闪光的效果了吗,所以我们先写一个随机函数


private fun randomCor(): Int {
return (Math.random() * 8).toInt()
}

然后将原来绘制圆点的坐标的下标改成随机数


if(pointOn){
repeat(endXAnimList.size) {
drawCircle(
colorList[colorIndex], pointRadius,
Offset(endXAnimList[randomCor()], endYAnimList[randomCor()])
)
}
if (pointRadius.toInt() == 2) {
pointOn = false
turnOn = true
}
}

现在我们再来看看效果如何


afire9.gif


总结


完整的动画效果已经出来了,整个开发过程还是相对来讲比较吃力的,我想这应该是刚开始接触Compose动画这一部分吧,后面再多开发几个动画应该会得心应手一些,但还是有点收获的,比如



  • 循环动画如果中途需要暂停,然后过段时间再打开的话,不能直接对它的initValue跟targetValue设置值,这样是无效的,必须搭配着Animatable动画一起使用才行

  • LaunchedEffect虽说是在Compose里面是提供给协程运行的函数,不看源码的话以为它里面只能做一件事情,其他事情会被堵塞,其实LaunchedEffect已经封装好了,它的block就是一个协程,所以无论在LaunchedEffect做几件事情,它们都只是运行在一个协程里面


也有一些遗憾与不足



  • 动画衔接的地方都是判断一个值有没有到达一个具体值,然后用开关去控制,感觉应该有更好的方式,比如可以配合着delayMillis,让动画延迟一会再开始

  • 烟花本身其实可以用曲线来代替直线,比如贝塞尔,这个是在开发过程中才想到的,我先去试试看,等龙年再画个更好的烟花~~

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

快速排序和归并排序的时间复杂度分析——通俗易懂

快速排序和归并排序的时间复杂度分析——通俗易懂 一、前言   今天面试的时候,被问到归并排序的时间复杂度,这个大家都知道是O(nlogn),但是面试官又继续问,怎么推导出来的。这我就有点懵了,因为之前确实没有去真正理解这个时间复杂度是如何得出的,于是就随便答了...
继续阅读 »

快速排序和归并排序的时间复杂度分析——通俗易懂


一、前言


  今天面试的时候,被问到归并排序的时间复杂度,这个大家都知道是O(nlogn),但是面试官又继续问,怎么推导出来的。这我就有点懵了,因为之前确实没有去真正理解这个时间复杂度是如何得出的,于是就随便答了一波(理解了之后,发现面试的时候答错了......)。


  归并排序和快速排序,是算法中,非常重要的两个知识点,同时也是在面试中被问的非常频繁的内容,我明知如此,却没有彻底理解,真是太不应该了。所以,今天这篇博客就来分析一下这两种排序算法的时间复杂度是如何得出的。我查了许多篇博客,很多都是通过公式进行分析,十分难理解,下面我就结合自己的理解,使用通俗易懂的方式进行描述(为了好理解,可能会有些啰嗦)。


二、正文


2.1 归并排序的时间复杂度分析


  了解归并排序的应该都知道,归并排序的时间复杂度是O(nlogn),且这个时间复杂度是稳定的,不随需要排序的序列不同而产生波动。那这个时间复杂度是如何得来的呢?我们可以这样分析,假设我们需要对一个包含n个数的序列使用归并排序,并且使用的是递归的实现方式,那么过程如下:



  • 递归的第一层,将n个数划分为2个子区间,每个子区间的数字个数为n/2

  • 递归的第二层,将n个数划分为4个子区间,每个子区间的数字个数为n/4

  • 递归的第三层,将n个数划分为8个子区间,每个子区间的数字个数为n/8;


  ......



  • 递归的第logn层,将n个数划分为n个子区间,每个子区间的数字个数为1


  我们知道,归并排序的过程中,需要对当前区间进行对半划分,直到区间的长度为1。也就是说,每一层的子区间,长度都是上一层的1/2这也就意味着,当划分到第logn层的时候,子区间的长度就是1了。而归并排序的merge操作,则是从最底层开始(子区间为1的层),对相邻的两个子区间进行合并,过程如下:



  • 在第logn层(最底层),每个子区间的长度为1,共n个子区间,每相邻两个子区间进行合并,总共合并n/2次。n个数字都会被遍历一次,所有这一层的总时间复杂度为O(n)


  ......



  • 在第二层,每个子区间长度为n/4,总共有4个子区间,每相邻两个子区间进行合并,总共合并2次。n个数字都会被遍历一次,所以这一层的总时间复杂度为O(n)

  • 在第一层,每个子区间长度为n/2,总共有2个子区间,只需要合并一次。n个数字都会被遍历一次,所以这一层的总时间复杂度为O(n)


  通过上面的过程我们可以发现,对于每一层来说,在合并所有子区间的过程中,n个元素都会被操作一次,所以每一层的时间复杂度都是O(n)。而之前我们说过,归并排序划分子区间,将子区间划分为只剩1个元素,需要划分logn次。每一层的时间复杂度为O(n),共有logn层,所以归并排序的时间复杂度就是O(nlogn)


  上面的描述算是非常详细了,应该不会太难理解。如果上面的过程还是不太理解,那么我们通过另外一种更直观的方式进行分析。上面描述的是递归的过程,下面我们通过非递归(迭代)方式实现的归并排序,再来分析一波,这种方式更加直观(为什么不直接通过非递归的方式描述,而是先通过递归的方式分析,是因为上面的过程也可以用来分析快速排序)。下面是通过非递归方式实现的归并排序代码,其中有两处分析时间复杂度的关键点,我标注出来了(重点关注注释):


**


/**
* 此方法用来定义子区间大小,子区间大小从1->2->4->8 ... ->n/2
* 可以近似地认为进行了logn次
*/
public static void merge(int[] arr) {
// 关键点1:划分子区间,每一次的子区间长度是上一次的两倍,所以这个循环需要执行logn次
for(int i = 1;i<arr.length;i *= 2){
// 关键点2:此方法每次执行的时间复杂度为O(n),具体看下方
mergeSort(arr,i);
}
}


/**
* 以下方法,每次执行的时间复杂度都是O(n),
* 因为需要将arr数组的每gap个数子,作为一个子区间,
* 然后对相邻的两个子区间执行归并排序的merge操作,
* 所以在这个方法中,arr数组中的每一个数都会在merge操作中,
* 被处理一次,所以下面这个方法的时间复杂度为O(n)
*/
public static void mergeSort(int[] arr, int gap) {
int[] tmp = new int[arr.length];
int index = 0;
int start1 = 0;
int end1 = start1 + gap - 1;
int start2 = end1 + 1;
int end2 = (start2 + gap - 1)>=arr.length?arr.length-1:start2+gap-1;
while(start2<arr.length){
while(start1<=end1&&start2<=end2){
if(arr[start1]<arr[start2]){
tmp[index++] = arr[start1++];
}else{
tmp[index++] = arr[start2++];
}
}
while(start1<=end1){
tmp[index++] = arr[start1++];
}
while(start2<=end2){
tmp[index++] = arr[start2++];
}
start1 = end2+1;
end1 = start1 + gap - 1;
start2 = end1 + 1;
end2 = (start2 + gap - 1)>=arr.length?arr.length-1:start2+gap-1;
}
while(start1<arr.length){
tmp[index++] = arr[start1++];
}
for(int j = 0;j<tmp.length;j++){
arr[j] = tmp[j];
}

}

  上面的代码,merge方法中的循环需要循环logn次,每次循环都调用一次mergeSort方法,mergeSort方法的时间复杂度为O(n),所以很容易得出归并排序的时间复杂度为O(nlogn)


2.2 快速排序的时间复杂度


  了解快速排序的应该知道,快速排序的时间复杂度在O(nlogn)~ O(n^2)之间,下面我就来分别分析这两种情况:


(一)快速排序的最好情况O(nlogn)


  这种情况下,其实和上面通过递归分析的归并排序很类似,理解了归并排序的时间复杂度分析,那这里应该也很好理解。快速排序的实现方式,就是在当前区间中选择一个轴,区间中所有比轴小的数都需要放到轴的左边,而比轴大的数则放到轴的右边。在理想的情况下,我们选取的轴刚好就是这个区间的中位数。也就是说,在操作之后,正好将区间分成了数字个数相等的左右两个子区间。此时就和归并排序基本一致了:



  • 递归的第一层,n个数被划分为2个子区间,每个子区间的数字个数为n/2

  • 递归的第二层,n个数被划分为4个子区间,每个子区间的数字个数为n/4

  • 递归的第三层,n个数被划分为8个子区间,每个子区间的数字个数为n/8;


  ......



  • 递归的第logn层,n个数被划分为n个子区间,每个子区间的数字个数为1


  以上过程与归并排序基本一致,而区别就是,归并排序是从最后一层开始进行merge操作,自底向上;而快速排序则是从第一层开始,交换区间中数字的位置,也就是自顶向下。但是,merge操作和快速排序的调换位置操作,时间复杂度是一样的,对于每一个区间,处理的时候,都需要遍历一次区间中的每一个元素。这也就意味着,快速排序和归并排序一样,每一层的总时间复杂度都是O(n),因为需要对每一个元素遍历一次。而且在最好的情况下,同样也是有logn层,所以快速排序最好的时间复杂度为O(nlogn)


(二)快速排序的最坏情况O(n^2)


  下面我们再来说一说快速排序的最坏情况,这种情况就比较好理解了。什么是快速排序的最坏情况,那就是,对于每一个区间,我们在处理的时候,选取的轴刚好就是这个区间的最大值或者最小值。比如我们需要对n个数排序,而每一次进行处理的时候,选取的轴刚好都是区间的最小值。于是第一次操作,在经过调换元素顺序的操作后,最小值被放在了第一个位置,剩余n-1个数占据了2到n个位置;第二次操作,处理剩下的n-1个元素,又将这个子区间的最小值放在了当前区间的第1个位置,以此类推......每次操作,都只能将最小值放到第一个位置,而剩下的元素,则没有任何变化。所以对于n个数来说,需要操作n次,才能为n个数排好序。而每一次操作都需要遍历一次剩下的所有元素,这个操作的时间复杂度是O(n),所以总时间复杂度为O(n^2)


  其实上面的过程,我们可以换一个角度理解:每次操作,找出最小值放到剩余区间的第一个位置,这不就是选择排序的实现方式吗?而选择排序的时间复杂度就是O(n^2),所以上面的过程也就O(n^2)


三、总结


  以上内容,就是我基于自己的理解,对快速排序和归并排序时间复杂度的分析。为了更好理解,我的描述都尽可能的详细,所以可能会有点啰嗦,但是我认为还是很通俗易懂的。希望这篇博客能够为之前对这两种排序算法理解不是特别清晰的人提供帮助,同时,若上面的内容存在错误或不足,欢迎指正或补充。


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

Android SplashScreen API使用

在Android 12 出现了一个SplashScreen新功能,它为所有应用添加了新的应用启动动画,可以通过SplashScreen API来定制专属应用启动动画。 默认情况下,新的应用启动动画为白色背景,中心为应用图标。 接下去将一一介绍如何使用Spla...
继续阅读 »

在Android 12 出现了一个SplashScreen新功能,它为所有应用添加了新的应用启动动画,可以通过SplashScreen API来定制专属应用启动动画。


默认情况下,新的应用启动动画为白色背景,中心为应用图标。


默认应用启动动画


接下去将一一介绍如何使用SplashScreen API来定制专属应用启动动画。


由于这是Android 12新增功能,所以所有相关API都要求api 31才能使用,因此需要额外创建一个values-v31,并将themes.xml拷贝一份放入其中。


values-v31


背景颜色


默认情况下,应用启动动画背景为白色。


在应用所使用的主题中设置以下代码,可以定制应用启动动画的背景颜色。


<item name="android:windowSplashScreenBackground">@color/splash_screen_background</item>

<color name="splash_screen_background">#7B5AB6</color>

设置背景颜色
需要注意一点,目前使用android:windowSplashScreenBackground设置的颜色不能带透明度,必须为6位或者是8位且透明度为FF,如果使用了带透明度的颜色将不生效。


启动图标


默认情况下,应用启动动画的中心为应用图标。


在应用所使用的主题中设置以下代码,可以定制应用启动动画的中心图标。


<item name="android:windowSplashScreenAnimatedIcon">@drawable/cat</item>

启动图标


这是原始图片:


原始图片



  • 可以发现启动图标需要保留一定的内边距,因为会被部分裁剪。

  • 除了设置静态图片,也可以设置动画形式,配置使用android:windowSplashScreenAnimationDuration设置动画时长。

  • 如果设置的图标是透明背景的,可以另外设置android:windowSplashScreenIconBackgroundColor来定制中心图标的背景颜色。


底部图片(Google不推荐使用)


使用android:windowSplashScreenBrandingImage可以设置底部图片,图片尺寸比例需要为2.5:1。


延缓启动时间


使用android:windowSplashScreenAnimationDuration可以设置启动动画时长,但是最长只能设置1000毫秒。


很多时候需要在启动的时候拉取一些应用配置,需要有更长时间的启动效果。


可以在代码中实现,通过ViewTreeObserver.OnPreDrawListener:


class MainActivity : AppCompatActivity() {
private var isAppReady = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val content: View = findViewById(android.R.id.content)
content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (isAppReady) {
content.viewTreeObserver.removeOnPreDrawListener(this)
}
return isAppReady
}
})
delayBootTime()
}

private fun delayBootTime() {
lifecycleScope.launch {
delay(3000)
isAppReady = true
}
}
}

当应用配置已准备好,onPreDraw返回true,并且移除监听。这里使用delay3秒来模拟拉取应用配置的耗时操作。


需要注意,一定要在准备好后onPreDraw返回true,否则会一直卡在启动页上。


启动退出动画


Android 12 SplashScreen新功能提供了setOnExitAnimationListener方法可以定制启动退出时的动画效果,该API只能在版本12及以上使用:


        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { splashScreenView ->
val slideUp = ObjectAnimator.ofFloat(
splashScreenView,
View.TRANSLATION_Y,
0f,
-splashScreenView.height.toFloat()
)
slideUp.duration = 2000
// 在自定义动画结束时调用splashScreenView.remove()
slideUp.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
splashScreenView.remove()
}
})
slideUp.start()
}
}

低版本兼容


在Android 12以下版本没有SplashScreen启动动画,显示的空白背景页面,这在用户体验上很不好。因此,Google在AndroidX中提供了一个向下兼容的SplashScreen库。


配置


implementation 'androidx.core:core-splashscreen:1.0.0'
复制代码

设置主题


定义一个新的主题并给应用使用:


    <style name="SplashScreenTheme" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_screen_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/cat</item>
<item name="postSplashScreenTheme">@style/Theme.SplashScreenDemo</item>
</style>

需要注意几点:



  • 必须以R.style.Theme_SplashScreen 为父级

  • 启动图标动画形式失效

  • windowSplashScreenBackground、windowSplashScreenAnimatedIcon前面都没有 android:

  • postSplashScreenTheme指定应用原来的主题,这样,当SplashScreen结束时,应用主题能够被恢复


在启动Activity中设置


一定要在setContentView方法之前调用installSplashScreen方法


        super.onCreate(savedInstanceState)
installSplashScreen()
setContentView(R.layout.activity_main)

至此,在低版本上也能有同样效果的SplashScreen动画了,当然一些启动退出动画这些Android 12特有的API仍然是无法使用的。


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

学之前“flow?狗都不学”学之后“狗不学正好我学”

标题皮一下,个人项目引入Kotlin Flow一段时间了,这篇文章想写写个人理解到的一点皮毛,有错欢迎在评论区指出。 Flow基础知识 Flow可理解为数据流,使用起来比较简单,看几个demo就可以直接上手了,除了提几个点之外也不再赘述。 Flow为冷流。在...
继续阅读 »

标题皮一下,个人项目引入Kotlin Flow一段时间了,这篇文章想写写个人理解到的一点皮毛,有错欢迎在评论区指出。


Flow基础知识


Flow可理解为数据流,使用起来比较简单,看几个demo就可以直接上手了,除了提几个点之外也不再赘述。



  • Flow为冷流。在Flow知识体系中,生产(获取)数据的可称为生产者(producer),消费(使用)数据的可称为消费者(consumer),冷流即有消费者消费数据,生产者才会生产数据。

  • Flow中生产者与消费者为一对一的关系,即消费者不share(共享)同一个Flow,新加一个消费者,就会新创建一个Flow。


上面两个点可以通过个简单的demo进行验证。


val timerFlow = flow {
   val start = 0
   var current = start
   while (true) {
       emit(current)
       current++
       delay(1000)
  }
}

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       var firstTimer by mutableStateOf(0)
       var secondTimer by mutableStateOf(0)
       var thirdTimer by mutableStateOf(0)
       val fontSize: TextUnit = 30.sp
       lifecycleScope.launch {
           while (true) {
               delay(1000)
               firstTimer++
          }
      }
       setContent {
           var secondTimerIsVisible by remember {
               mutableStateOf(false)
          }
           var thirdTimerIsVisible by remember {
               mutableStateOf(false)
          }
           Column(
               modifier = Modifier.fillMaxSize(),
               horizontalAlignment = Alignment.CenterHorizontally,
               verticalArrangement = Arrangement.Center
          ) {
               Text(
                   text = "屏幕启动时间为${firstTimer}秒",
                   textAlign = TextAlign.Center, fontSize = fontSize
              )
               if (secondTimerIsVisible) {
                   Text(
                       "第一个自定义计时器的时间为${secondTimer}秒。",
                       textAlign = TextAlign.Center,
                       fontSize = fontSize
                  )
              } else {
                   Button(
                       onClick = {
                           lifecycleScope.launch {
                               repeatOnLifecycle(Lifecycle.State.STARTED) {
                                   timerFlow.collect {
                                       secondTimer = it
                                  }
                              }
                          }
                           secondTimerIsVisible = true

                      },
                  ) {
                       Text(
                           text = "启动第一个自定义计时器",
                           textAlign = TextAlign.Center,
                           fontSize = fontSize
                      )
                  }
              }
               if (thirdTimerIsVisible) {
                   Text(
                       "第二个自定义计时器的时间为${thirdTimer}秒。",
                       textAlign = TextAlign.Center,
                       fontSize = fontSize
                  )
              } else {
                   Button(
                       modifier = Modifier.padding(10.dp),
                       onClick = {
                           lifecycleScope.launch {
                               repeatOnLifecycle(Lifecycle.State.STARTED) {
                                   timerFlow.collect {
                                       thirdTimer = it
                                  }
                              }
                          }
                           thirdTimerIsVisible = true

                      },
                  ) {
                       Text(
                           text = "启动第二个自定义计时器",
                           textAlign = TextAlign.Center,
                           fontSize = fontSize
                      )
                  }
              }
          }
      }
  }
}

运行一下。





在上面的demo中,创建了三个计时器,第一个计时器用协程来实现,来计时屏幕的启动时间,第二,第三个计时器用flow来实现,为自定义计时器,需要手动启动。



  • 在屏幕启动几秒后,才启动第二个计时器,该计时器是从0秒开始启动的,这说明flow并不是屏幕一启动就产生数据,而是有消费者消费数据,才会产生数据。

  • 第二个计时器和第三个计时器的时间不一样,说明它们尽管用了同一个timerFlow变量,却不是共享同一个flow,新加一个消费者,就会新创建一个Flow。


SharedFlow


稍微了解设计模式的读者应该知道,Flow其实是用了观察者模式,生产者对应subject(被观察者),消费者对应observer(观察者),只是flow中每个subject只允许有一个observer,但在实际项目中,一个subject有多个observer的情况再正常不过,于是乎就有了SharedFlow。


SharedFlow是共享流,它的特性与flow刚好反着来。



  • SharedFlow是热流,即使没有消费者也会一直产生数据,该产生数据的策略是可变的,后面会详细讲。

  • 多个消费者会共享同一个Flow。


对上面代码进行修改,将Flow转换为SharedFlow,并将其移动到新建的MainViewModel中。


class MainViewModel : ViewModel() {

   val timerFlow = flow {
       val start = 0
       var current = start
       while (true) {
           emit(current)
           current++
           delay(1000)
      }
  }.shareIn(viewModelScope, SharingStarted.Eagerly,0)

}

修改MainActivity的代码,添加viewModel的实例化代码private val viewModel: MainViewModel = MainViewModel() ,并timerFlow.collect改成viewModel.timerFlow.collect,改动较少,就不放出全部源码了,需要注意的是,将MainViewModel直接实例化的做法是错误的,理由是当Activity由于某种原因,如屏幕旋转而销毁时,MainViewModel会重新实例化,这样就达不到ViewModel数据持久化的目的了,本文是为了方便演示SharedFlow是热流的特性才直接实例化。


运行一下。





效果图有两个点是比较关键的。



  • 自定义计时器的时间与屏幕启动时间是一样的,说明SharedFlow不管有没有消费者,都会产生数据。

  • 两个自定义计时器的时间是一样的,说明两个计时器共享了同一个SharedFlow。


先看看shareIn()方法的源码。


public fun <T> Flow<T>.shareIn(
   scope: CoroutineScope,
   started: SharingStarted,
   replay: Int = 0
): SharedFlow<T>



  • scope参数为指定SharedFlow在哪个协程域启动。




  • replay参数指定当有新的消费者出现时,发送多少个之前的数据给该消费者。




  • started为启动策略。


    有三个启动策略可选。




    • SharingStarted.Eagerly 。SharedFlow会立即产生数据,即使连第一个消费者还没出现,demo中使用的就是该启动策略。




    • SharingStarted.Lazily。SharedFlow只有在第一个消费者消费数据后才产生数据。




    • WhileSubscribed。WhileSubscribed的源码如下所示。


      public fun SharingStarted.Companion.WhileSubscribed(
         stopTimeout: Duration = Duration.ZERO,
         replayExpiration: Duration = Duration.INFINITE
      )


      • stopTimeOut。当SharedFlow一个消费者也没有的时候,等待多久才停止流。

      • replayExpiration。用来指定replay个数量的缓存在等待多少时间后无效,当你不想用户看到较旧的数据时,可使用这个参数。






此外,SharedFlow也可以直接创建。


class MainViewModel : ViewModel() {

   val timerFlow = MutableSharedFlow<Int>()

   init {
       viewModelScope.launch {
           val start = 0
           var current = start
           while (true) {
               timerFlow.emit(current)
               current++
               delay(1000)
          }
      }
  }
}

StateFlow


StateFlow是SharedFlow的一个特殊变种,其特性有:



  • 始终有值且值唯一。

  • 可以有多个消费者。

  • 永远只把最新的值给到消费者。


第二,第三特性比较好理解,就是replay参数为1的SharedFlow,那第一个特性需要结合demo才更好理解。


先将flow转化为StateFlow。


class MainViewModel : ViewModel() {

   val timerFlow = flow {
       val start = 0
       var current = start
       while (true) {
           emit(current)
           current++
           delay(1000)
      }
  }.stateIn(viewModelScope, SharingStarted.Eagerly,0)

}

sharedIn()的源码如下所示。


public fun <T> Flow<T>.stateIn(
   scope: CoroutineScope,
   started: SharingStarted,
   initialValue: T//初始值
): StateFlow<T>{

}

运行一下。






与SharedFlow比较,最大的不同就是SharedFlow demo中的自定义计时器是从0开始的,之后才和屏幕启动时间一致,而这个StateFlow demo中的自定义计时器是一启动就和屏幕启动时间一致,出现这种情况的原因是:



  • SharedFlow并不存储值,MainActivity只有在 SharedFlow emit()出最新值的时候,才能collect()到值。

  • 根据StateFlow的第一点特性,其始终有值且值唯一,在MainActivity一订阅StateFlow的时候,就立马就将最新的值给到了MainActivity,所以StateFlow demo中的计时器没有经历0的阶段。


可以看到,StateFlow与之前的LiveData比较相似的。


StateFlow还有另一种在实际项目中更常用的使用方式,修改MainViewModel的代码。


class MainViewModel : ViewModel() {

   private val _timerFlow: MutableStateFlow<Int> = MutableStateFlow(0)
   val timerFlow: StateFlow<Int> = _timerFlow.asStateFlow()

   init {
       viewModelScope.launch {
           val start = 0
           var current = start
           while (true) {
               _timerFlow.value = current
               current++
               delay(1000)
          }
      }
  }

}

代码中先创建私有MutableStateFlow实例_timerFlow,再将其转化为公共StateFlow实例timerFlow,因为timerFlow只可读,不能修改,暴露给Main Activity使用更符合规范。


collect Flow的规范做法


官方推荐我们用lifeCycle.repeatOnLifecycle()去collect flow。


lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.timerFlow.collect {
...
}
}
}

Activity会在onStart()开始收集数据,在onStop()结束数据的收集。


如下图所示,如果直接使用lifecycleScope.launch去collect flow,那么在应用进入后台后,也会持续进行数据的收集,这样将造成资源的浪费。






要是嫌上述代码繁琐,也可以添加以下依赖。


implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha01"

然后将collect代码改成下述代码也能达到同样的效果,不过该方法只适用于StateFlow。


viewModel.timerFlow.collectAsStateWithLifecycle()

该方法的源码如下所示。


fun <T> StateFlow<T>.collectAsStateWithLifecycle(
   lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
   minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
   context: CoroutineContext = EmptyCoroutineContext
): State<T>

从第二个参数可以知道默认是从onStart()开始收集数据。


项目真的需要引入Flow吗?


谷歌对Flow的推崇力度很大,Android官网中除了Flow相关的文章之外,很多代码示例也多多少少用了Flow,大有一种Flow放之四海而皆准的态势,但使用一段时间后,我发现Flow的应用场景其实也是有一定局限的。


以我个人项目中的之前Repository类中某段代码为例。


override suspend fun getCategory(): Flow<List<Category>?> {
       return flow {
           when (val response = freeApi.getCategoryList()) {
               is ApiSuccess -> {
                   val categories = response.data
                   withContext(Dispatchers.IO) {
                       Timber.v("cache categories in db")
                       categoryDao.insertCategoryList(categories)
                  }
                   emit(categories)//1
              }
               else -> {
                   Timber.d(response.toString())
                   val cacheCategories = withContext(Dispatchers.IO) {
                       categoryDao.getCategoryList()
                  }
                   if (cacheCategories.isNotEmpty()) {
                       Timber.d("load categories from db")
                       emit(cacheCategories)//2
                  } else {
                       Timber.d("fail to load category from db")
                       emit(null)//3
                  }
              }
          }
      }
  }

其实上面代码并不适合用Flow,因为尽管代码1,2,3处都有emit,但最终getCategory()只会emit一次值,Flow是数据流,但一个数据并不能流(Flow)起来,这样无法体现出Flow的好处,徒增资源的消耗。


除此之外,在一个屏幕需要获取从多个api获取数据的时候,如果强行用Flow就会出现繁琐重复的代码,像下面的代码会有好几处。


getXXX().catch{
//进行异常处理
}.collect{
//得到数据
}

我也去查阅了相关的资料,发现确实如此,具体可见参考资料1和2。


参考资料


本文主要参考了资料4,与资料4在排版,内容有较多相似地方。



  1. Kotlin Flow: Best Practices

  2. one-shot operation with Flow in Android

  3. Complete guide to LiveData and Flow: Answering — Why, Where, When, and Which.

  4. 官方推荐 Flow 取代 LiveData,有必要吗?

  5. Recommended Ways To Create ViewModel or AndroidViewModel

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

阿里iconfont审核很慢?自己搭建一个,直接从figma上传

web
iconfont我们前端都认为挺好用的,但设计师经常说:“这玩意真不好,上传个图标审核半天,审核通过了还不会自动上传😡”不应该呀,我一看,原来是阿里iconfont管理平台的问题;那简单,我不用它不就行了😎原来的工作流程“宁花机器10分钟,不花人工1分钟”,在...
继续阅读 »


iconfont我们前端都认为挺好用的,但设计师经常说:“这玩意真不好,上传个图标审核半天,审核通过了还不会自动上传😡”

不应该呀,我一看,原来是阿里iconfont管理平台的问题;那简单,我不用它不就行了😎

原来的工作流程


“宁花机器10分钟,不花人工1分钟”,在旧流程中,我们可以看到,人工操作的环节足足有6个(听说阿里icon审核也是人工的)。很显然,这是相当低效的流程。看来,除了阿里第三方的审核问题,我们内部原有的图标交付流程也出现了问题😀

怎么解决

先看看有没有可以直接用的方案

  1. 转转的方案 和我们想到一块去了,不使用阿里的iconfont管理平台,而是魔改了YIcon;可惜转转暂时没有开源他们方案的想法,但也给予我们一些思路,我们能不能也学着魔改。

  2. 除了YIcon,还有一个开源的iconfont管理平台Nicon。他们的优点都是具有完善的管理&鉴权机制,但缺点是代码过于老旧,长时间没人维护。这也意味着要花费较大的人力要魔改,这对于我们是不能接受的。

  3. 很幸运的是,figma社区有较为成熟的Figma图标交付方案figma-icon-automation,看到了Shoppee得物等公司都参考figma-icon-automation来实现了自己一套的图标交付流程,看起来figma插件是目前最优的选择。可是,我们还是希望保留iconfont的使用方式,不然的话改用SVG组件,这个改变成本也是无法接受的。

    因此,我们决定修改figma-icon-automation的流程,实现适合我们的iconfont交付方案🚀

新的iconfont交付方案

魔改1: github 改为 gitlab

  1. 出于保护设计师的资产

我们存储icons到内网部署的gitlab上,保护了设计资产的同时,也自然不会有第三方来审核图标。

  1. 出于iconont权限管理

gitlab可对不同项目分配不同权限,我们不再需要一个iconfont平台来管理权限;同时解决了,可能会没有及时回收离职员工的阿里iconfont平台权限所带来的风险。

  1. 出于iconfont项目管理

这个与普通的gitlab项目没什么区别,你可以创建多个iconfont项目对应不同前端项目,每个项目都是独立的。

  1. 出于iconfont版本管理

得益于git的强大,我们可以还拥有了版本管理,这是阿里iconfont平台没有给我们带来的;我们可以清楚地追溯到是谁修改/删除了图标,或者及时地回滚iconfont版本。

魔改2: 更加好用的figma插件

基于gitlab官方Figma插件,我们对其进行改造(主要因为可以节省查阅Figma API文档的时间),实现了一款更加适合我们的设计师使用的Figma插件————UpCon,主要功能如下:

  1. 支持配置自己公司gitlab域名

使用自定义gitlab域名作为请求的BaseUrl来调用gitlab开放的api,默认为v4版api,支持最新版gitlab。

  1. 支持配置project-id,支持存储多个id

通过project-id来管理不同项目,并且通过本地storage存储多个project-id,方便用户快速切换项目

  1. 支持配置gitlab access token

通过access token来登录gitlab,同时识别该用户是否具有该project对应的开发权限(无权限用户无法跳转到上传页)

  1. 支持自定义选择多个Icons,并实时预览

通过在figma中选择要上传的icon(支持frame与component类型),填写本次提交的信息,即可触发上传。

  1. 支持去除颜色上传

我们保留了阿里iconfont平台的去除颜色上传功能,其原理是通过正则修改SVG代码中的color属性为currentcolor
选择去除颜色后,当前选择的所有icon都会去除颜色,并可实时预览去除颜色后的效果。

  1. 校验icon命名

我们会对已上传的icons名称与当前选择icons名称进行对比,重名的icon,会给予橙色边框与tooltip提示。如果你执意要上传,则会覆盖原先的图标。
与之同时,我们对icon命名进行了强制规范,名称中如含有/\:*?"<>|.字符,会给予红色边框提示,并不允许上传


魔改3: 触发Gitlab CI脚本

在figma插件触发上传后,会生成一次commit记录并同时触发Gitlab CI操作。可以通过clone 我们开源的iconfont-build-tools来实现自定义Gitlab CI操作,iconfont-build-tools的主要功能如下:

  1. 处理转换SVG代码为iconfont

我们会读取当前项目下的icons/路径下的所有svg文件(此路径暂不支持修改),将svg代码转换为iconfont.js代码,详细实现代码可查看iconfont-build-tools

  1. 转换svg名称为拼音

我们保留了阿里iconfont平台中,把中文名称自动转为拼音的功能,这一功能大大降低了设计师们的icon命名带来的困扰。

  1. 自动生成tag信息并发布新版本

我们还自动把本次git commit的Hash值作为版本tag,并自动发布新版本,这是实现版本管理关键的一步。

  1. 自动上传iconfont到CDN

生成的iconfont.js文件可以通过自定义配置来自动上传到自己的CDN,返回的url会自动携带在release信息中,具体的数据格式可查看iconfont-build-tools


方案开源&计划

目前,我们的方案已经开源了,欢迎大家积极尝试并提出宝贵的建议👍

未来,我们计划给gitlab ci流程中接入微信机器人通知,大家可以持续关注或者star我们的项目😊

Figma UpCon iconfont-build-tools

总结

相比于旧的图标交付流程,新的流程直接把步骤缩减到两步,这大大地提高了我们的效率。而且我们保留许多旧流程的习惯,如依旧使用iconfont方案,upcon中去除颜色功能,build-tools的中文转拼音功能,这些功能的迁移让我们几乎不用花费额外的成本去使用新的流程。

同时我们也希望有更多的用户给予我们正向的反馈,完善此流程,让图标交付变得更简单。


参考链接

figma plugin juejin.cn/post/706816…

得物 IconBot juejin.cn/post/704398…

Shopee IconBot juejin.cn/post/690372…

svgicons2svgfont juejin.cn/post/713711…

iconfont预览 segmentfault.com/a/119000002…

gitlab figma gitlab.com/gitlab-org/…

作者:BlackGoldRoad
来源:juejin.cn/post/7184324458063069245

收起阅读 »

舍弃传统文档阅读吧!~新一代代码文档神器code-hike

web
最终效果如图。起因相信不少小伙伴阅读过一篇文章:build-your-own-react这是一篇通俗易懂的从头开始讲述如何创建一个react过程的文章,其中最吸引我的就是这个文章的代码排版方式。将所有代码放置在左侧,随着文档的滚动,左侧代码不断发生变化,不断提...
继续阅读 »


最终效果如图。

起因

相信不少小伙伴阅读过一篇文章:build-your-own-react

这是一篇通俗易懂的从头开始讲述如何创建一个react过程的文章,其中最吸引我的就是这个文章的代码排版方式。将所有代码放置在左侧,随着文档的滚动,左侧代码不断发生变化,不断提示我哪些代码发生了变动。这样的文档方式,是我之前没体验过的船新版本。去作者的gayhub看到正好有开源工具,于是自己搭建了demo,马上惊为天人。所以在这里我要做一个违背祖宗的决定,将其分享给大家。

code-hike简介

codehike.org/ code-hike是一个 mdx的插件,专注于文档写作的组件库。专注于代码的展示,具有以下几个功能:

代码块的展示


支持134种不同的编程语言,基本涵盖了目前市面上的编程语言。

批注和对代码的交互体验


可以看到在code-hike中可以对部分代码进行高亮显示,这部分主要通过force和mark来操作。同时它还允许你在代码块中进行链接,可以点击跳转到页面的其他位置。也可以自定义自己的样式显示。

一系列的code组件

一系列帮你优化code展示的组件,在本文中,将主要使用CH-scrollycoding作为展示。

安装

我们这里以Docusaurus为例作为展示,当然你也可以使用React,vite或其他任意模版或者docs框架作为开始。

我们首先安装docusaurus

npx create-docusaurus@latest my-website classic

然后安装hike的相关依赖

cd my-website  

npm i @mdx-js/react@2 docusaurus-theme-mdx-v2 @code-hike/mdx

配置

首先配置docusaurus.config.js,插入mdx-v2主题

// docusaurus.config.js

const config = {
   ...
   themes: ["mdx-v2"],
   ...
}

然后插入code-hike插件

// docusaurus.config.js
const theme = require("shiki/themes/nord.json")
const {
 remarkCodeHike,
} = require("@code-hike/mdx")

const config = {
   presets: [
    [
       "classic",
      {
         docs: {
           beforeDefaultRemarkPlugins: [
            [remarkCodeHike, { theme }],
          ],
           sidebarPath: require.resolve("./sidebars.js"),
        },
      },
    ],
  ],
   ...
}

再设置下style

// docusaurus.config.js
...
const config={

   theme: {
       customCss: [
         require.resolve("@code-hike/mdx/styles.css"),
      ],
    },
  }
}

至此所有配置完成,我的完整配置如下:

// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion

const lightCodeTheme = require("prism-react-renderer/themes/github");
const darkCodeTheme = require("prism-react-renderer/themes/dracula");

const theme = require("shiki/themes/nord.json");
const { remarkCodeHike } = require("@code-hike/mdx");

/** @type {import('@docusaurus/types').Config} */
const config = {
 title: "css and js",
 tagline: "read everyday",
 url: "https://your-docusaurus-test-site.com",
 baseUrl: "/",
 onBrokenLinks: "throw",
 onBrokenMarkdownLinks: "warn",
 favicon: "img/favicon.ico",

 // GitHub pages deployment config.
 // If you aren't using GitHub pages, you don't need these.
 organizationName: "facebook", // Usually your GitHub org/user name.
 projectName: "docusaurus", // Usually your repo name.

 // Even if you don't use internalization, you can use this field to set useful
 // metadata like html lang. For example, if your site is Chinese, you may want
 // to replace "en" with "zh-Hans".
 i18n: {
   defaultLocale: "en",
   locales: ["en"],
},

 presets: [
  [
     "classic",
     /** @type {import('@docusaurus/preset-classic').Options} */
    ({
       
       docs: {
         beforeDefaultRemarkPlugins: [[remarkCodeHike, { theme }]],
         sidebarPath: require.resolve("./sidebars.js"),
         // Please change this to your repo.
         // Remove this to remove the "edit this page" links.
         editUrl:
           "https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/",
      },
       blog: {
         showReadingTime: true,
         // Please change this to your repo.
         // Remove this to remove the "edit this page" links.
         editUrl:
           "https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/",
      },
       theme: {
         customCss: [
           require.resolve("@code-hike/mdx/styles.css"),
           require.resolve("./src/css/custom.css"),
        ],
      },
    }),
  ],
],
 themes: ["mdx-v2"],

 themeConfig:
   /** @type {import('@docusaurus/preset-classic').ThemeConfig} */
  ({
     navbar: {
       title: "My Site",
       logo: {
         alt: "My Site Logo",
         src: "img/logo.svg",
      },
       items: [
        {
           type: "doc",
           docId: "intro",
           position: "left",
           label: "阅读",
        },
         
        { to: "/blog", label: "Blog", position: "left" },
        {
           href: "https://github.com/facebook/docusaurus",
           label: "GitHub",
           position: "right",
        },
      ],
    },
     footer: {
       style: "dark",
       links: [
        {
           title: "Docs",
           items: [
            {
               label: "Tutorial",
               to: "/docs/intro",
            },
             
          ],
        },
        {
           title: "Community",
           items: [
            {
               label: "Stack Overflow",
               href: "https://stackoverflow.com/questions/tagged/docusaurus",
            },
            {
               label: "Discord",
               href: "https://discordapp.com/invite/docusaurus",
            },
            {
               label: "Twitter",
               href: "https://twitter.com/docusaurus",
            },
          ],
        },
        {
           title: "More",
           items: [
            {
               label: "Blog",
               to: "/blog",
            },
            {
               label: "GitHub",
               href: "https://github.com/facebook/docusaurus",
            },
          ],
        },
      ],
       copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`,
    },
     prism: {
       theme: lightCodeTheme,
       darkTheme: darkCodeTheme,
    },
  }),
};

module.exports = config;

特别注意

因为code hike使用的是mdx2,在此版本有一个破坏性更新,如果遇到以下问题


请找到<!--truncate-->,将其删除

开始自己的demo

将docs下的一个md文件,改为如下的数据


为啥不在掘金贴代码块呢,因为跟掘金的markdown冲突了。。。

你得到了如下的效果



这就是一个简单的如我开头展示的效果。

代码标记

这里主要有两个点需要注意 : 第一个是

---

三条横线,作为每段展示文档的分割,所以你可以看到我们的每一段都有这个标记


第二个是 focus


这个标记表明了你有哪些代码需要高亮,在demo中使用的是行高亮。一共有两种用法:

你可以写在文件开头,例如```js statement.js focus=6:8,这表示将从文件的开头进行计算,第6-8行 你也可以写在文件内,例如

// focus(1,1)
  const result=[]

这表示,从标记位置开始的后面的1-1行,也就是第一行

除了这种按照行进行标记,你也可以标记列,例如

  // focus[7:12]
    result = 40000;

它表示从下一行的第7-12个字符。 效果为


作者:im天元
来源:juejin.cn/post/7175000675523887159

收起阅读 »

一张码如何实现多渠道(微信、支付宝、云闪付...)收款

大家好,我是小悟今天是正月初五,天气超级好,也是迎财神的日子,祝大家顺风顺水,财源滚滚,钱兔似锦。既然要发财,那自然少不了收款咯。如果你是一个商家,肯定是想收款的方式越方便越好,但支付渠道有那么多种,也就意味着顾客的支付选择也是多种。那总不能把所有的渠道收款码...
继续阅读 »


大家好,我是小悟

今天是正月初五,天气超级好,也是迎财神的日子,祝大家顺风顺水,财源滚滚,钱兔似锦。


既然要发财,那自然少不了收款咯。如果你是一个商家,肯定是想收款的方式越方便越好,但支付渠道有那么多种,也就意味着顾客的支付选择也是多种。

那总不能把所有的渠道收款码都贴上吧,那会非常的乱,对顾客来说也极其不方便,一个码能解决的事情,就不要搞复杂化了。那这个是怎么实现的呢?


要实现一码多渠道收款其实也不难,毋庸置疑,现在主流的支付方式就是微信和支付宝,而在微信和支付宝申请的商户相同点是都支持余额、银行卡和信用卡支付,不同点是微信支持云闪付支付,支付宝支持花呗支付。所以只要对接了微信和支付宝,那基本上就够用了。

值得一提的是,随着微信支付生态的发展,现在想实现这样的功能是越来越方便了。借助微信扫普通链接二维码打开小程序的功能,无需判断前端是微信还是支付宝或者其他APP扫码,可以减少很多工作量。


所以重点来了,我们都知道,微信和支付宝根据前端不同而有多种支付方式,比如APP支付,H5支付,小程序支付等。

为了实现更全和更简单的功能,支付宝需要对接H5支付,而微信需要对接的却是小程序支付。说到这里你可能就有疑问了,为啥不都是H5支付或都是小程序支付?


首先对接支付宝H5支付的话,当你使用其他APP比如抖音、快手打开的时候也可以跳转到支付宝完成支付,一劳永逸。再者因为微信小程序支付支持云闪付支付,所以微信对接的是小程序支付。

说到这里不知道你已经想到实现思路了吗?是的,前端需要开发一个简单的页面,但是再简单,起码可以输入金额吧。然后简单做下金额正则校验,因为涉及到H5和小程序,所以可以使用uniapp编写前端页面部署更方便,也就是说支付宝部署的是H5,微信部署的是小程序。

我写的demo是搞了两个,不要学我哦,怎么方便怎么来,左边是支付宝H5扫出来的样子,右边是微信小程序扫出来的样子。


支付做多了服务端其实也不复杂,注意,支付宝对接的是H5支付,微信对接的是小程序支付,简单贴一下代码。需要写回调的话也一定不要忘了哦。

支付宝H5支付

public AjaxResult aliPayH5(PayModel payModel) {
  payModel.setBody("支付宝H5支付").setSubject("支付宝H5支付");
  String outTradeNo = IdUtil.getSnowflake(1,1).nextIdStr();
  payModel.setOutTradeNo(outTradeNo).setPassbackParams(outTradeNo);
  String form = aliPayService.aliPayH5(payModel);
if(StringUtils.isNotBlank(form)) {
      Map<String, Object> result = new HashMap<>(2);
      result.put("form", form);
return AjaxResult.success(result);
  }
return AjaxResult.error("数据准备异常");
}

微信小程序支付

public AjaxResult jsapiMaPayCommon(JsapiOrderParam param, HttpServletRequest request) {
  String openId = param.getOpenId();
  String remoteAddr = IpUtils.getIpAddr(request);
  String outTradeNo = IdUtil.getSnowflake(1,1).nextIdStr();
  BigDecimal decimal100 = new BigDecimal("100");
  BigDecimal orderAmount = new BigDecimal(String.valueOf(param.getAmount()));
  JsapiParam jsapiParam = new JsapiParam();
  jsapiParam.setAppid(wechatProperties.getMaAppId())
          .setMchid(wechatProperties.getMchId())
          .setDescription("微信小程序支付")
          .setOut_trade_no(outTradeNo)
          .setAttach(outTradeNo)
          .setNotify_url(wechatProperties.getNotifyUrlCommon());
  Amount amount = new Amount();
  amount.setTotal(decimal100.multiply(orderAmount).intValue());
  jsapiParam.setAmount(amount);
  Payer payer = new Payer();
  payer.setOpenid(openId);
  jsapiParam.setPayer(payer);
  SceneInfo sceneInfo = new SceneInfo();
  sceneInfo.setDevice_id("POS1:12");
  sceneInfo.setPayer_client_ip(remoteAddr);
  jsapiParam.setScene_info(sceneInfo);
  BaseParam baseParam = new BaseParam();
  baseParam.setAppName(wechatProperties.getAppName())
          .setMchId(wechatProperties.getMchId())
          .setMchSerialNo(wechatProperties.getMchSerialNo())
          .setWechatSerialNo(wechatProperties.getWechatSerialNo())
          .setMchPrivateKeyPath(wechatProperties.getMchPrivateKeyPath())
          .setWechatPubKeyPath(wechatProperties.getWechatPubKeyPath());
  JSONObject result = wechatService.jsapiPay(jsapiParam, baseParam);
  int status = result.getInteger("requestStatus");
if (status == 200) {
      SortedMap<Object, Object> params = new TreeMap<>();
      String timestamp = Long.toString(System.currentTimeMillis() / 1000);
      String nonceStr = UuidUtils.randomUUID();
      String packageParam = "prepay_id=" + result.getString("prepay_id");
      String paySign = SignUtils.paySign(wechatProperties.getMaAppId(), timestamp, nonceStr, packageParam,
              wechatProperties.getMchPrivateKeyPath());
      params.put("appId", wechatProperties.getMaAppId());
      params.put("timeStamp", timestamp);
      params.put("paySign", paySign);
      params.put("signType", "RSA");
      params.put("nonceStr", nonceStr);
      params.put("package", "prepay_id=" + result.getString("prepay_id"));
      logger.info("params:{}",params);
return AjaxResult.success(params);
  } else {
return AjaxResult.error(result.getString("message"), result);
  }
}

部署起来后,支付宝基本就这样了,能支付就行,微信还需要配置一些东西。首先,微信商户号后台,支付方式配置,云闪付需要开启状态。


其次,小程序后台,需要配置扫普通链接二维码打开小程序,将部署的支付宝H5支付链接地址映射到微信小程序的支付页面,测试范围选择线上版,全网发布即可。这样,当使用微信扫描该二维码地址时,就会自动跳转到微信小程序支付页面。


然后使用草料二维码生成器将H5地址塞到二维码里面,就大功告成了,以后使用微信或支付宝,或者其他APP扫码就可以完成支付了。支持微信、云闪付、支付宝、花呗、银行卡、信用卡支付。打完收工。


一码在手,生意你有。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

作者:悟空码字
来源:juejin.cn/post/7192983769618317370

收起阅读 »

【附源码】国内首届Discord场景创意编程开源项目

以下开源项目是由环信联合华为举办的《国内首届Discord场景创意编程赛》作品,附源码,一键即用。一、 模拟器游戏直播-新新人类新新人类模拟器游戏直播基于环信超级社区Demo构建,增加以“video-x”命名的新型Channel,用户可在本机操作/控制当前游戏...
继续阅读 »

以下开源项目是由环信联合华为举办的《国内首届Discord场景创意编程赛》作品,附源码,一键即用。


一、 模拟器游戏直播-新新人类

新新人类模拟器游戏直播基于环信超级社区Demo构建,增加以“video-x”命名的新型Channel,用户可在本机操作/控制当前游戏界面,并通过集成声网RTC SDK, 在聊天频道中实现连麦聊天, 一对多直播。其中直播流来自于NES模拟器画面, 用户可以观看房主游玩经典NES的游戏画面. 并进一步与房主联机, 实现2p游戏。


模拟器游戏直播-项目预览

该项目不仅集成了环信超级社区SDK,声网的RTC功能,也同时集成了第三方小游戏。而这正是超级社区,也就是Discord产品的精髓之一。用户不仅可以通过IM聊天,也可以进行语聊,看游戏直播,甚至自己进行游戏直播。这些都是当下Discord这款产品中使用率最高的功能。这个作品不仅让人眼前一亮,也展示出作者对Discord和超级社区场景深入的理解,令人印象深刻。

源码:https://github.com/easemob/EasemobCircle-2022-Innovation-Challenge/tree/master/Innovation-Challenge/%E6%96%B0%E6%96%B0%E4%BA%BA%E7%B1%BB-%E6%A8%A1%E6%8B%9F%E5%99%A8%E7%9B%B4%E6%92%AD


二、 代码搬运工-CT超级社区

CT超级社区基于环信超级社区Demo,在实时聊天场景基本功能中,丰富了聊天内容英译汉翻译功能。同时增加了频道插件功能,通过将封装部分API成SDK,部分功能可通过插件的形式去实现,通过丰富的插件功能提高用户互动性,提升社区体验。目前实现的插件有:投票、社区签到、打卡分享、代码分享、频道内置机器人、外置拓展机器人。

CT超级社区-项目预览

该项目集成了多个超级社区场景下的高使用频率功能,投票以及打卡签到、机器人等插件有助于提升社区活跃度,鼓励社区内用户发起讨论,独到之处也为开发者们提供了分享代码的功能插件,为社区提供了更丰富的互动元素,高度契合了超级社区场景化需求。

源码:https://github.com/easemob/EasemobCircle-2022-Innovation-Challenge/tree/master/Innovation-Challenge/%E4%BB%A3%E7%A0%81%E6%90%AC%E8%BF%90%E5%B7%A5-CT%E7%A4%BE%E5%8C%BA


三、小雪花-有趣点儿圈子

“有趣点儿圈子”基于环信超级社区构建,在于支持万人场景下的沟通交流娱乐。多种分类频道(通知频道、直播频道),满足于用户畅游。用户等级VIP信息,专属聊天图标。内置扔骰子游戏,石头剪刀布游戏,红包功能,随机打卡功能。还有可以陪你的机器人功能,萌萌的它,可以每日单词、笑话、天气提醒...,更有功能强大的ChatGPT AI对话。

有趣点儿圈子-项目预览


该项目集成了多种群内小游戏以及红包功能,同时支持了不同群成员的等级属性。作为一个社交类产品,这些功能都极大提升了一个社区的活力和丰富程度。值得一提的是虽然ChatGPT功能并没有在此项目中完全跑通,但这种创新精神和将新兴功能接入超级社区的想象力仍然值得鼓励。期待后期继续完善,为广大开发者分享更加卓越的场景应用。

源码:https://github.com/easemob/EasemobCircle-2022-Innovation-Challenge/tree/master/Innovation-Challenge/%E5%B0%8F%E9%9B%AA%E8%8A%B1-%E6%9C%89%E8%B6%A3%E7%82%B9%E5%84%BF%E5%9C%88%E5%AD%90

以上开源作品中使用到的SDK:

 ●注册环信:https://console.easemob.com/user/register

●超级社区介绍:https://www.easemob.com/product/im/circle

●超级社区SDK 集成文档::https://docs-im.easemob.com/ccim/circle/overview

●超级社区Demo体验:https://www.easemob.com/download/demo#discord

●技术支持社区:https://www.imgeek.org

收起阅读 »

Android 关于集成环信如何设置桌面角标

1、小米角标:MIUI6-MIUI11桌面应用角标适配方法通过反射调用设置桌面角标,参考代码如下:try { Field field = notification.getClass().getDeclaredField(“extraNotificatio...
继续阅读 »

1、小米角标

MIUI6-MIUI11桌面应用角标适配方法

通过反射调用设置桌面角标,参考代码如下:

try {
Field field = notification.getClass().getDeclaredField(“extraNotification”);
Object extraNotification = field.get(notification);
Method method = extraNotification.getClass().getDeclaredMethod(“setMessageCount”, int.class);
method.invoke(extraNotification, mCount);
} catch (Exception e) {
e.printStackTrace();
}


MIUI12及以后桌面应用角标适配方法

由于Google屏蔽了hideAPI的反射调用,因此MIUI12及以后可以使用notification.number,可参照Android开发者文档 https://developer.android.google.cn/reference/android/app/Notification#number,参考代码如下:

Notification notification = new Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(textTitle)
.setContentText(textContent)
.setNumber(int number)
.build();


如何判断MIUI版本

可参考文档https://dev.mi.com/console/doc/detail?pId=1312,其中6.1节有具体方法说明。

桌面应用角标问题Q&A

https://dev.mi.com/console/doc/detail?pId=2321

2、华为角标:

需要在消息扩展里设置上应用入口activity,如demo是com.hyphenate.chatuidemo.ui.SplashActivity

// 设置自定义推送提示
JSONObject extObject = new JSONObject();
try {
extObject.put("em_huawei_push_badge_class", "com.hyphenate.chatuidemo.ui.SplashActivity");
} catch (JSONException e) {
e.printStackTrace();
}
// 将推送扩展设置到消息中
message.setAttribute("em_apns_ext", extObject);


3、vivo角标:

“桌面图标角标”默认关闭

接入成功后,“桌面图标角标”默认关闭,需要用户手动开启。

开启路径:“设置”-“通知与状态栏”-“应用通知管理”-应用名称-“桌面图标角标”。

未成功接入“桌面图标角标”的应用,无“桌面图标角标”选项。

备注:视OS版本差异,“桌面图标角标”名称可能为“应用图标标记”或“桌面角标”。

具体实现

a. 添加权限:

b. 应用在需要显示桌面角标的场景,通过广播将信息发送给vivoLauncher:

广播参数:

action:launcher.action.CHANGE_APPLICATION_NOTIFICATION_NUM

packageName:应用包名

className:主类名

notificationNum:未读消息数目

简单示例:

Intent intent = new Intent();

int missedCalls = 10;

intent.setAction("launcher.action.CHANGE_APPLICATION_NOTIFICATION_NUM");

intent.putExtra("packageName", "com.android.xxxx");

intent.putExtra("className", "com.android.xxxx.Mainxxxx");

intent.putExtra("notificationNum", missedCalls);

intent.addFlags(Intent.FLAG_RECEIVER_INCLUDE_BACKGROUND);

sendBroadcast(intent);


注意:

在8.0上,还需要给Intent加上下面的flag

Intent.FLAG_RECEIVER_INCLUDE_BACKGROUND

4、oppo角标:

需要申请Push角标功能

https://open.oppomobile.com/bbs/forum.php?mod=viewthread&tid=2448&extra=page%3D1&aid=11392

5、魅族角标:

不支持

收起阅读 »

国家级出行平台亮相,“强国交通”完成内测即将上线

作为国内首个国家级交通出行平台,涵盖约车、货运、水运、航运等功能的“强国交通”近日已完成内测,进入上线倒计时。记者从“强国交通”项目组获悉,“强国交通”将率先推出约车服务,已接入数十家网约车运力公司,未来预计接入运力将占市场全部运力的90%以上。通过项目组提供...
继续阅读 »

作为国内首个国家级交通出行平台,涵盖约车、货运、水运、航运等功能的“强国交通”近日已完成内测,进入上线倒计时。记者从“强国交通”项目组获悉,“强国交通”将率先推出约车服务,已接入数十家网约车运力公司,未来预计接入运力将占市场全部运力的90%以上。


通过项目组提供的邀请码,记者尝鲜登录“强国交通”应用界面,应用首页规划有“约车”“货运”“公路”“铁路”“水运”“航运”等六大服务门类,以及维修、保险、油卡优惠等配套服务。目前“约车”服务已开通运营测试,可选择“打车”“助老约车”两种途径。如选择“打车”,用户输入目的地后,可进行叫车、接单、支付等全流程;选择“助老约车”,则可以电话约车,极大方便广大老年用户出行。

记者注意到,“强国交通”已接入数十家网约车运力公司,运力较为充足、车型较为丰富。“强国交通”项目组负责人童光来介绍,平台仍在增加运力聚合,预计接入运力将占市场全部运力的90%以上。“强国交通”上线后也将接入微信、支付宝、抖音等平台,方便群众享受安全、便捷的数字交通服务。

据悉,“强国交通”是“学习强国”学习平台与相关部门联合打造的数字立体交通应用服务平台,为平台用户提供涵盖水、路、空、铁等多门类数字交通工具,立体化、集成化、智能化、安全化的交通出行服务。

基于网约车行业一度存在的无序扩张和数据安全问题,“强国交通”秉承数字惠民和安全可控的原则,将陆续上线多项功能。其中,将率先为“学习强国”学习平台注册用户提供约车服务。除了提供“助老约车”特色服务,“强国交通”还将为重点央企、国企等企事业单位职工提供定制化出行服务,最大程度保护用户数据安全与个人隐私。

编辑:蔡文清
来源:bj.bjd.com.cn/5b165687a010550e5ddc0e6a/contentShare/5b16573ae4b02a9fe2d558f9/AP63c7b528e4b0ed71f9280811.html

收起阅读 »

超实用!过年该给爸妈买什么?看这一篇就够了

春节将至,你准备啥时候回家?小时候,爸妈把最好的留给我们,从无怨言;如今我们在外求学工作,该带点什么回去孝顺忙碌了一年的爸妈?根据学生党、工薪族不同预算,送你一份最走心的春节礼物清单,供参考打包好自己,把爱带回家!来源:人民日报

春节将至,你准备啥时候回家?小时候,爸妈把最好的留给我们,从无怨言;如今我们在外求学工作,该带点什么回去孝顺忙碌了一年的爸妈?根据学生党、工薪族不同预算,送你一份最走心的春节礼物清单,供参考打包好自己,把爱带回家!










来源:人民日报

微博图床挂了!

一直担心的事情还是发生了。作为hexo多年的使用者,微博图床一直是我的默认选项,hexo+typora+iPic更是我这几年写文章的黄金组合。而图床中,新浪图床一直都是我的默认选项,速度快、稳定同时支持大图片批量上传更是让其成为了众多图床工具的默认选项。虽然今...
继续阅读 »

一直担心的事情还是发生了。

作为hexo多年的使用者,微博图床一直是我的默认选项,hexo+typora+iPic更是我这几年写文章的黄金组合。而图床中,新浪图床一直都是我的默认选项,速度快、稳定同时支持大图片批量上传更是让其成为了众多图床工具的默认选项。虽然今年早些的时候,部分如「ws1、ws2……」的域名就已经无法使用了,但通过某些手段还是可以让其存活的,而最近,所有调用的微博图床图片都无法加载并提示“403 Forbidden”了。


💡Tips:图片中出现的Tengine是淘宝在Nginx的基础上修改后开源的一款Web服务器,基本上,Tengine可以被看作一个更好的Nginx,或者是Nginx的超集,详情可参考👉淘宝Web服务器Tengine正式开源 - The Tengine Web Server

刚得知这个消息的时候,我的第一想法其实是非常生气的,毕竟自己这几年上千张图片都是用的微博图床,如今还没备份就被403了,可仔细一想,说到底还是把东西交在别人手里的下场,微博又不是慈善企业,也要控制成本,一直睁一只眼闭一只眼让大家免费用就算了,出了问题还是不太好怪到微博上来的。

那么有什么比较好的办法解决这个问题呢?

查遍了网上一堆复制/粘贴出来的文章,不是开启反向代理就是更改请求头,真正愿意从根本上解决问题的没几个。

如果不想将自己沉淀的博客、文章托管在印象笔记、notion、语雀这些在线平台的话,想要彻底解决这个问题最好的方式是:自建图床!

为了更好的解决问题,我们先弄明白,403是什么,以及我们存在微博上的图片究竟是如何被403的。

403

百度百科,对于403错误的解释很简单

403错误是一种在网站访问过程中,常见的错误提示,表示资源不可用。服务器理解客户的请求,但拒绝处理它,通常由于服务器上文件或目录的权限设置导致的WEB访问错误。

所以说到底是因为访问者无权访问服务器端所提供的资源。而微博图床出现403的原因主要在于微博开启了防盗链。

防盗链的原理很简单,站点在得知有请求时,会先判断请求头中的信息,如果请求头中有Referer信息,然后根据自己的规则来判断Referer头信息是否符合要求,Referer 信息是请求该图片的来源地址。

如果盗用网站是 https 的 协议,而图片链接是 http 的话,则从 https 向 http 发起的请求会因为安全性的规定,而不带 referer,从而实现防盗链的绕过。官方输出图片的时候,判断了来源(Referer),就是从哪个网站访问这个图片,如果是你的网站去加载这个图片,那么 Referer 就是你的网站地址;你的网址肯定没在官方的白名单内,(当然作为可操作性极强的浏览器来说 referer 是完全可以伪造一个官方的 URL 这样也也就也可以饶过限制🚫)所以就看不到图片了。


解决问题

解释完原理之后我们发现,其实只要想办法在自己的个人站点中设置好referer就可以解决这个问题,但说到底也只是治标不治本,真正解决这个问题就是想办法将图片迁移到自己的个人图床上。

现在的图床工具很多,iPic、uPic、PicGo等一堆工具既免费又开源,问题在于选择什么云存储服务作为自己的图床以及如何替换自己这上千张图片。

  1. 选择什么云存储服务

  2. 如何替换上千张图片

什么是OSS以及如何选择

「OSS」的英文全称是Object Storage Service,翻译成中文就是「对象存储服务」,官方一点解释就是对象存储是一种使用HTTP API存储和检索非结构化数据和元数据对象的工具。

白话文解释就是将系统所要用的文件上传到云硬盘上,该云硬盘提供了文件下载、上传等一列服务,这样的服务以及技术可以统称为OSS,业内提供OSS服务的厂商很多,知名常用且成规模的有阿里云、腾讯云、百度云、七牛云、又拍云等。

对于我们这些个人用户来说,这些云厂商提供的服务都是足够使用的,我们所要关心的便是成本💰。

笔者使用的是七牛云,它提供了10G的免费存储,基本已经够用了。

有人会考虑将GitHub/Gitee作为图床,并且这样的文章在中文互联网里广泛流传,因为很多人的个人站点都是托管在GitHub Pages上的,但是个人建议是不要这么做。

首先GitHub在国内的访问就很受限,很多场景都需要科学上网才能获得完整的浏览体验。再加上GitHub官方也不推荐将Git仓库存储大文件,GitHub建议仓库保持较小,理想情况下小于 1 GB,强烈建议小于 5 GB。

如何替换上千张图片

替换文章中的图片链接和“把大象放进冰箱里”步骤是差不多的

  1. 下载所有的微博图床的图片

  2. 上传所有的图片到自己的图床(xx云)

  3. 对文本文件执行replaceAll操作

考虑到我们需要迁移的文件数量较多,手动操作肯定是不太可行的,因此我们可以采用代码的方式写一个脚本完成上述操作。考虑到自己已经是一个成熟的Java工程师了,这个功能就干脆用Java写了。

为了减少代码量,精简代码结构,我这里引入了几个第三方库,当然不引入也行,如果不引入有一些繁琐而又简单的业务逻辑需要自己实现,有点浪费时间了。

整个脚本逻辑非常简单,流程如下:


获取博客文件夹下的Markdown文件

这里我们直接使用hutool这个三方库,它内置了很多非常实用的工具类,获取所有markdown文件也变得非常容易

/**
* 筛选出所有的markdown文件
*/
public static List<File> listAllMDFile() {
   List<File> files = FileUtil.loopFiles(VAULT_PATH);
   return files.stream()
    .filter(Objects::nonNull)
      .filter(File::isFile)
      .filter(file -> StringUtils.endsWith(file.getName(), ".md"))
      .collect(Collectors.toList());
}

获取文件中的所有包含微博图床的域名

通过Hutools内置的FileReader我们可以直接读取markdown文件的内容,因此我们只需要解析出文章里包含微博图床的链接即可。我们可以借助正则表达式快速获取一段文本内容里的所有url,然后做一下filter即可。

/**
* 获取一段文本内容里的所有url
*
* @param content 文本内容
* @return 所有的url
*/
public static List<String> getAllUrlsFromContent(String content) {
   List<String> urls = new ArrayList<>();
   Pattern pattern = Pattern.compile(
       "\\b(((ht|f)tp(s?)\\:\\/\\/|~\\/|\\/)|www.)" + "(\\w+:\\w+@)?(([-\\w]+\\.)+(com|org|net|gov"
           + "|mil|biz|info|mobi|name|aero|jobs|museum" + "|travel|[a-z]{2}))(:[\\d]{1,5})?"
           + "(((\\/([-\\w~!$+|.,=]|%[a-f\\d]{2})+)+|\\/)+|\\?|#)?" + "((\\?([-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
           + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)" + "(&(?:[-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
           + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)*)*" + "(#([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)?\\b");
   Matcher matcher = pattern.matcher(content);
   while (matcher.find()) {
       urls.add(matcher.group());
  }
   return urls;
}

下载图片

用Java下载文件的代码在互联网上属实是重复率最高的一批检索内容了,这里就直接贴出代码了。

public static void download(String urlString, String fileName) throws IOException {
   File file = new File(fileName);
   if (file.exists()) {
       return;
  }
   URL url = null;
   OutputStream os = null;
   InputStream is = null;
   try {
       url = new URL(urlString);
       URLConnection con = url.openConnection();
       // 输入流
       is = con.getInputStream();
       // 1K的数据缓冲
       byte[] bs = new byte[1024];
       // 读取到的数据长度
       int len;
       // 输出的文件流
       os = Files.newOutputStream(Paths.get(fileName));
       // 开始读取
       while ((len = is.read(bs)) != -1) {
           os.write(bs, 0, len);
      }
  } finally {
       if (os != null) {
           os.close();
      }
       if (is != null) {
           is.close();
      }
  }
}

上传图片

下载完图片后我们便要着手将下载下来的图片上传至我们自己的云存储服务了,这里直接给出七牛云上传图片的文档链接了,文档里写的非常详细,我就不赘述了👇

Java SDK_SDK 下载_对象存储 - 七牛开发者中心

全局处理

通过阅读代码的细节,我们可以发现,我们的方法粒度是单文件的,但事实上,我们可以先将所有的文件遍历一遍,统一进行图片的下载、上传与替换,这样可以节约点时间。

统一替换的逻辑也很简单,我们申明一个全局Map,

private static final Map<String, String> URL_MAP = Maps.newHashMap();

其中,key是旧的新浪图床的链接,value是新的自定义图床的链接。

我们将listAllMDFile这一步中所获取到的所有文件里的所有链接保存于此,下载时只需遍历这个Map的key即可获取到需要下载的图片链接。然后将上传后得到的新链接作为value存在到该Map中即可。

全文替换链接并更新文件

有了上述这些处理步骤,接下来一步就变的异常简单,只需要遍历每个文件,将匹配到全局Map中key的链接替换成Map中的value即可。

/**
* 替换所有的图片链接
*/
private static String replaceUrl(String content, Map<String, String> urlMap) {
   for (Map.Entry<String, String> entry : urlMap.entrySet()) {
       String oldUrl = entry.getKey();
       String newUrl = entry.getValue();
       if (StringUtils.isBlank(newUrl)) {
           continue;
      }
content = RegExUtils.replaceAll(content, oldUrl, newUrl);
  }
   return content;
}

我们借助commons-lang实现字符串匹配替换,借助Hutools实现文件的读取和写入。

files.forEach(file -> {
   try {
       FileReader fileReader = new FileReader(file.getPath());
       String content = fileReader.readString();
       String replaceContent = replaceUrl(content, URL_MAP);
       FileWriter writer = new FileWriter(file.getPath());
       writer.write(replaceContent);
  } catch (Throwable e) {
       log.error("write file error, errorMsg:{}", e.getMessage());
  }
});

为了安全起见,最好把文件放在新的目录中,不要直接替换掉原来的文件,否则程序出现意外就麻烦了。

接下来我们只需要运行程序,静待备份结果跑完即可。

以上就是本文的全部内容了,希望对你有所帮助

作者:插猹的闰土
来源:juejin.cn/post/7189651446306963514

收起阅读 »

Android动态加载so!这一篇就够了!

背景对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到native的需求,所以so库的动态化可以减少极大的包体积,自从2020腾讯的bugly团队发部关于动态化so的相关文章后,已经过去两年了,...
继续阅读 »

背景

对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到native的需求,所以so库的动态化可以减少极大的包体积,自从2020腾讯的bugly团队发部关于动态化so的相关文章后,已经过去两年了,相关文章,经过两年的考验,实际上so动态加载也是非常成熟的一项技术了,但是很遗憾,许多公司都还没有这方面的涉略又或者说不知道从哪里开始进行,因为so动态其实涉及到下载,so版本管理,动态加载实现等多方面,我们不妨抛开这些额外的东西,从最本质的so动态加载出发吧!这里是本次的例子,我把它命名为sillyboy,欢迎pr还有后续点赞呀!

so动态加载介绍

动态加载,其实就是把我们的so库在打包成apk的时候剔除,在合适的时候通过网络包下载的方式,通过一些手段,在运行的时候进行分离加载的过程。这里涉及到下载器,还有下载后的版本管理等等确保一个so库被正确的加载等过程,在这里,我们不讨论这些辅助的流程,我们看下怎么实现一个最简单的加载流程。


从一个例子出发

我们构建一个native工程,然后在里面编入如下内容,下面是cmake

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.

cmake_minimum_required(VERSION 3.18.1)

# Declares and names the project.

project("nativecpp")

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library( # Sets the name of the library.
      nativecpp

      # Sets the library as a shared library.
      SHARED

      # Provides a relative path to your source file(s).
      native-lib.cpp)

add_library(
      nativecpptwo
      SHARED
      test.cpp

)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
      log-lib

      # Specifies the name of the NDK library that
      # you want CMake to locate.
      log)

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
      nativecpp

      # Links the target library to the log library
      # included in the NDK.
      ${log-lib})


target_link_libraries( # Specifies the target library.
      nativecpptwo

      # Links the target library to the log library
      # included in the NDK.
      nativecpp
      ${log-lib})

可以看到,我们生成了两个so库一个是nativecpp,还有一个是nativecpptwo(为什么要两个呢?我们可以继续看下文) 这里也给出最关键的test.cpp代码

#include <jni.h>
#include <string>
#include<android/log.h>


extern "C"
JNIEXPORT void JNICALL
Java_com_example_nativecpp_MainActivity_clickTest(JNIEnv *env, jobject thiz) {
  // 在这里打印一句话
  __android_log_print(ANDROID_LOG_INFO,"hello"," native 层方法");

}

很简单,就一个native方法,打印一个log即可,我们就可以在java/kotin层进行方法调用了,即

public native void clickTest();

so库检索与删除

要实现so的动态加载,那最起码是要知道本项目过程中涉及到哪些so吧!不用担心,我们gradle构建的时候,就已经提供了相应的构建过程,即构建的task【 mergeDebugNativeLibs】,在这个过程中,会把一个project里面的所有native库进行一个收集的过程,紧接着task【stripDebugDebugSymbols】是一个符号表清除过程,如果了解native开发的朋友很容易就知道,这就是一个减少so体积的一个过程,我们不在这里详述。所以我们很容易想到,我们只要在这两个task中插入一个自定义的task,用于遍历和删除就可以实现so的删除化了,所以就很容易写出这样的代码

ext {
   deleteSoName = ["libnativecpptwo.so","libnativecpp.so"]
}
// 这个是初始化 -配置 -执行阶段中,配置阶段执行的任务之一,完成afterEvaluate就可以得到所有的tasks,从而可以在里面插入我们定制化的数据
task(dynamicSo) {
}.doLast {
   println("dynamicSo insert!!!! ")
   //projectDir 在哪个project下面,projectDir就是哪个路径
   print(getRootProject().findAll())

   def file = new File("${projectDir}/build/intermediates/merged_native_libs/debug/out/lib")
   //默认删除所有的so库
   if (file.exists()) {
       file.listFiles().each {
           if (it.isDirectory()) {
               it.listFiles().each {
                   target ->
                       print("file ${target.name}")
                       def compareName = target.name
                       deleteSoName.each {
                           if (compareName.contains(it)) {
                               target.delete()
                          }
                      }
              }
          }
      }
  } else {
       print("nil")
  }
}
afterEvaluate {
   print("dynamicSo task start")
   def customer = tasks.findByName("dynamicSo")
   def merge = tasks.findByName("mergeDebugNativeLibs")
   def strip = tasks.findByName("stripDebugDebugSymbols")
   if (merge != null || strip != null) {
       customer.mustRunAfter(merge)
       strip.dependsOn(customer)
  }

}

可以看到,我们定义了一个自定义task dynamicSo,它的执行是在afterEvaluate中定义的,并且依赖于mergeDebugNativeLibs,而stripDebugDebugSymbols就依赖于我们生成的dynamicSo,达到了一个插入操作。那么为什么要在afterEvaluate中执行呢?那是因为android插件是在配置阶段中才生成的mergeDebugNativeLibs等任务,原本的gradle构建是不存在这样一个任务的,所以我们才需要在配置完所有task之后,才进行的插入,我们可以看一下gradle的生命周期


通过对条件检索,我们就删除掉了我们想要的so,即ibnativecpptwo.so与libnativecpp.so。

动态加载so

根据上文检索出来的两个so,我们就可以在项目中上传到自己的后端中,然后通过网络下载到用户的手机上,这里我们就演示一下即可,我们就直接放在data目录下面吧


真实的项目过程中,应该要有校验操作,比如md5校验或者可以解压等等操作,这里不是重点,我们就直接略过啦!

那么,怎么把一个so库加载到我们本来的apk中呢?这里是so原本的加载过程,可以看到,系统是通过classloader检索native目录是否存在so库进行加载的,那我们反射一下,把我们自定义的path加入进行不就可以了吗?这里采用tinker一样的思路,在我们的classloader中加入so的检索路径即可,比如

private static final class V25 {
  private static void install(ClassLoader classLoader, File folder) throws Throwable {
      final Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
      final Object dexPathList = pathListField.get(classLoader);

      final Field nativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "nativeLibraryDirectories");

      List<File> origLibDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
      if (origLibDirs == null) {
          origLibDirs = new ArrayList<>(2);
      }
      final Iterator<File> libDirIt = origLibDirs.iterator();
      while (libDirIt.hasNext()) {
          final File libDir = libDirIt.next();
          if (folder.equals(libDir)) {
              libDirIt.remove();
              break;
          }
      }
      origLibDirs.add(0, folder);

      final Field systemNativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
      List<File> origSystemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
      if (origSystemLibDirs == null) {
          origSystemLibDirs = new ArrayList<>(2);
      }

      final List<File> newLibDirs = new ArrayList<>(origLibDirs.size() + origSystemLibDirs.size() + 1);
      newLibDirs.addAll(origLibDirs);
      newLibDirs.addAll(origSystemLibDirs);

      final Method makeElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class);

      final Object[] elements = (Object[]) makeElements.invoke(dexPathList, newLibDirs);

      final Field nativeLibraryPathElements = ShareReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
      nativeLibraryPathElements.set(dexPathList, elements);
  }
}

我们在原本的检索路径中,在最前面,即数组为0的位置加入了我们的检索路径,这样一来classloader在查找我们已经动态化的so库的时候,就能够找到!

结束了吗?

一般的so库,比如不依赖其他的so的时候,直接这样加载就没问题了,但是如果存在着依赖的so库的话,就不行了!相信大家在看其他的博客的时候就能看到,是因为Namespace的问题。具体是我们动态库加载的过程中,如果需要依赖其他的动态库,那么就需要一个链接的过程对吧!这里的实现就是Linker,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libxxx.so 文件(当前的so)能找到,而依赖的so 找不到的情况。bugly文章

很多实现都采用了Tinker的实现,既然我们系统的classloader是这样,那么我们在合适的时候把这个替换掉不就可以了嘛!当然bugly团队就是这样做的,但是笔者认为,替换一个classloader显然对于一个普通应用来说,成本还是太大了,而且兼容性风险也挺高的,当然,还有很多方式,比如采用Relinker这个库自定义我们加载的逻辑。

为了不冷饭热炒,嘿嘿,虽然我也喜欢吃炒饭(手动狗头),这里我们就不采用替换classloader的方式,而是采用跟relinker的思想,去进行加载!具体的可以看到sillyboy的实现,其实就不依赖relinker跟tinker,因为我把关键的拷贝过来了,哈哈哈,好啦,我们看下怎么实现吧!不过在此这前,我们需要了解一些前置知识

ELF文件

我们的so库,本质就是一个elf文件,那么so库也符合elf文件的格式,ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。


那么我们so中,如果依赖于其他的so,那么这个信息存在哪里呢!?没错,它其实也存在elf文件中,不然链接器怎么找嘛,它其实就存在.dynamic段中,所以我们只要找打dynamic段的偏移,就能到dynamic中,而被依赖的so的信息,其实就存在里面啦 我们可以用readelf(ndk中就有toolchains目录后) 查看,readelf -d nativecpptwo.so 这里的 -d 就是查看dynamic段的意思


这里面涉及到动态加载so的知识,可以推荐大家一本书,叫做程序员的自我修养-链接装载与库这里就画个初略图


我们再看下本质,dynamic结构体如下,定义在elf.h中

typedef struct{
Elf32_Sword d_tag;
union{
Elf32_Addr d_ptr;
....
}
}

当d_tag的数值为DT_NEEDED的时候,就代表着依赖的共享对象文件,d_ptr表示所依赖的共享对象的文件名。看到这里读者们已经知道了,如果我们知道了文件名,不就可以再用System.loadLibrary去加载这个文件名确定的so了嘛!不用替换classloader就能够保证被依赖的库先加载!我们可以再总结一下这个方案的原理,如图


比如我们要加载so3,我们就需要先加载so2,如果so2存在依赖,那我们就调用System.loadLibrary先加载so1,这个时候so1就不存在依赖项了,就不需要再调用Linker去查找其他so库了。我们最终方案就是,只要能够解析对应的elf文件,然后找偏移,找到需要的目标项(DT_NEED)所对应的数值(即被依赖的so文件名)就可以了

public List<String> parseNeededDependencies() throws IOException {
  channel.position(0);
  final List<String> dependencies = new ArrayList<String>();
  final Header header = parseHeader();
  final ByteBuffer buffer = ByteBuffer.allocate(8);
  buffer.order(header.bigEndian ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN);

  long numProgramHeaderEntries = header.phnum;
  if (numProgramHeaderEntries == 0xFFFF) {
      /**
        * Extended Numbering
        *
        * If the real number of program header table entries is larger than
        * or equal to PN_XNUM(0xffff), it is set to sh_info field of the
        * section header at index 0, and PN_XNUM is set to e_phnum
        * field. Otherwise, the section header at index 0 is zero
        * initialized, if it exists.
        **/
      final SectionHeader sectionHeader = header.getSectionHeader(0);
      numProgramHeaderEntries = sectionHeader.info;
  }

  long dynamicSectionOff = 0;
  for (long i = 0; i < numProgramHeaderEntries; ++i) {
      final ProgramHeader programHeader = header.getProgramHeader(i);
      if (programHeader.type == ProgramHeader.PT_DYNAMIC) {
          dynamicSectionOff = programHeader.offset;
          break;
      }
  }

  if (dynamicSectionOff == 0) {
      // No dynamic linking info, nothing to load
      return Collections.unmodifiableList(dependencies);
  }

  int i = 0;
  final List<Long> neededOffsets = new ArrayList<Long>();
  long vStringTableOff = 0;
  DynamicStructure dynStructure;
  do {
      dynStructure = header.getDynamicStructure(dynamicSectionOff, i);
      if (dynStructure.tag == DynamicStructure.DT_NEEDED) {
          neededOffsets.add(dynStructure.val);
      } else if (dynStructure.tag == DynamicStructure.DT_STRTAB) {
          vStringTableOff = dynStructure.val; // d_ptr union
      }
      ++i;
  } while (dynStructure.tag != DynamicStructure.DT_NULL);

  if (vStringTableOff == 0) {
      throw new IllegalStateException("String table offset not found!");
  }

  // Map to file offset
  final long stringTableOff = offsetFromVma(header, numProgramHeaderEntries, vStringTableOff);
  for (final Long strOff : neededOffsets) {
      dependencies.add(readString(buffer, stringTableOff + strOff));
  }

  return dependencies;
}

扩展

我们到这里,就能够解决so库的动态加载的相关问题了,那么还有人可能会问,项目中是会存在多处System.load方式的,如果加载的so还不存在怎么办?比如还在下载当中,其实很简单,这个时候我们字节码插桩就派上用场了,只要我们把System.load替换为我们自定义的加载so逻辑,进行一定的逻辑处理就可以了,嘿嘿,因为笔者之前就有写一个字节码插桩的库的介绍,所以在本次就不重复了,可以看Sipder,同时也可以用其他的字节码插桩框架实现,相信这不是一个问题。

总结

看到这里的读者,相信也能够明白动态加载so的步骤了,最后源代码可以在SillyBoy,当然也希望各位点赞呀!当然,有更好的实现也欢迎评论!!

作者:Pika
来源:juejin.cn/post/7107958280097366030

收起阅读 »

8年了,Web3到底给这个世界带来了什么?

一言以蔽之,Web3 并没有带来普遍的价值,它只给少数人带来了价值。Web3 这个词,可以追溯到 1999 年。但我们如今谈论的 Web3 不是那个定义。从 1999 年到 2014 年期间,不断有很多名人来定义 web3。但都没有获得普遍认可。2014 年 ...
继续阅读 »

一言以蔽之,Web3 并没有带来普遍的价值,它只给少数人带来了价值。

Web3 这个词,可以追溯到 1999 年。但我们如今谈论的 Web3 不是那个定义。

从 1999 年到 2014 年期间,不断有很多名人来定义 web3。但都没有获得普遍认可。2014 年 Polkadot 创始人和以太坊的创始人 Gavin Wood 提出的概念才是如今真正让 Web3 成为大多数人熟知的起点。

Web3 的起因

Web2 模型与社会结构非常相似,互联网上的各种中心化平台和现实世界中的各种机构对等。

Gavin 认为这种模型是坏掉的模型,他想通过技术手段创造一种全新的模型。如果继续使用这种模型,人类社会会走向衰败。

而这种新模型,就是去中心化,让用户自己拥有自己的数据与资产。

你可能会想,打破平台垄断,无疑会触碰到某些人的利益,遭到很多既得利益者的抵制。

但事实并非如此。

Gavin 一直强调,Web3 并不是取代现存的科技巨头或者权力机构。它只是一种新的模型,这种模型的优势就是权力分配的机制不同。Gavin 试图通过这种模型来保护自由世界。而那些不需要自由的人,仍然可以活在 Web2 的世界里。两者并不冲突。

Web3 的门槛

你可能又会想,如何实现上述模型?

Gavin 曾给出过答案,基于区块链技术。

区块链是一种低成本构造人人互信环境的一种技术。它天生就具有分布式、去中心化的属性。

但是,让用户去编写代码与区块链进行交互,门槛无疑太高了。

所以 Gavin 和志同道合的伙伴搭建了以太坊平台、solidity 智能合约等技术,帮助大家降低编程门槛。

但即使如此,Web3 的门槛仍然很高。

除去技术门槛,交易的成本也是让很多普通人无法接受的重要原因。

在以太坊进行交易,最少要支付 10 元人民币左右的 gas 费。再看看中国的现状,还有多少人在菜市场买菜还要为了几毛钱砍价?大多数普通人不会接受如此昂贵的 gas 费。

所以目前的 Web3,参与者都是些技术极客、创业者和投机者。普通人短时间内无法跨越这些天生的门槛,进入 Web3 的世界。

我认为,普通人不需要自由,自然也不需要 Web3。

Web3 的现状

虽然 Web3 的设想很好,但如今的 Web3 很混乱。

炒作、内斗、诈骗、抄袭,无处不在。

矿场、赌狗、NFT 炒作、洗钱、传销,他们并不是 Web3 好的一面,但 Web3 又没办法离开它们。

他们的关系,是一种相互依赖,相互依存、相互利用的关系。

投机者希望借助这个自由的平台实现利益收割、敛财、暴富。

Web3 的推动者希望借助这帮狂热的疯子来扩大 Web3 对世界的影响力。

投机者不懂那些理念,也不需要懂。它们只在乎电费涨没涨;自己压的币涨没涨;酝酿了很久的盘有没有杀到猪;新发行的 NFT 有没有被 Mint 光。

你说如今 Web3 中的人有没有信仰?不好回答,不过我总结的道理就是:增长就是信仰。就这么简单。

Web2 与 Web3 的关系?

既不冲突,也不对立。

现实世界中的 Web3 发展,与 Gavin 最初提出的目标有不少偏差。但基本的逻辑没有变化。

很多人批判 Web3,认为它是一种新型骗局。

你可以说 Web3 没有普遍价值,但说它是一种骗局我是绝不认可的。人与人之间社交,本身就存在欺骗,这是人的天性,与 Web2 或者 Web3 无关。

人们对 Web3 最错误的认知就是把修补 Web2 已存在的窟窿的重任托付给 Web3。

Web3 不是为了让 Web 更好,也不是为了替代 Web2。

它的使命是创造属于自由的新东西。

Web3 虽然名字中有个 Web,但它不是 Web。

Web3 的未来

自从 2021 年,Web3 火了之后。对 Web3 的定义也越来越模糊。

人们迫切地希望把 Web2 中,或者说现实世界中那些令人痛恨,而又无力改变的现状,通通扔到 Web3 里面,把希望寄托于 Web3。

这个奇怪的现象一直持续至今。

如今我们谈论的 Web3,已经不再单单是曾经 Gavin 下的定义了。

元宇宙、ESG 电商、虚拟地产、创作者经济、去中心化金融等等一堆衍生概念,全部涌入 Web3 中。

如今,Web2 中的各大科技公司纷纷布局 Web3,而 Web3 也成为各大投资机构的焦点。沉浸在这种焦点之下,难免会让 Web3 的人看不清自己。

假泡沫还是真趋势?

没人能看清,我也看不清,也无法预测。

不过,我终归是对 Web3 有信仰的,这份信仰源自增长。

作者:代码与野兽
来源:juejin.cn/post/7189085299304038459

收起阅读 »

什么样的故障让阿里云换了总裁?

2022年12月18日大概是阿里云最黑暗的日子,阿里云香港Region可用区C因为机房水冷装置出现故障导致大规模服务中断,对很多客户业务产生重大影响,包括澳门多家网站及App自当日午时起无法访问使用。云服务器宕机后一般几个小时左右便能恢复,可是阿里云这次宕机于...
继续阅读 »

2022年12月18日大概是阿里云最黑暗的日子,阿里云香港Region可用区C因为机房水冷装置出现故障导致大规模服务中断,对很多客户业务产生重大影响,包括澳门多家网站及App自当日午时起无法访问使用。云服务器宕机后一般几个小时左右便能恢复,可是阿里云这次宕机于12月18日早上8点56分首次检测到故障警告,直到次日凌晨0点30分所有服务才恢复正常,整个故障持续时间超过15个半小时。号称世界第三的云计算服务商在机房出现异常的时候要耗费如此多的时间才能恢复简直不敢想象,实在和其宣扬的各种黑科技高可用大相径庭。

这次的宕机事件对阿里云的技术品牌力损伤非常大,可能后期你再宣扬自己技术有多牛,客户可能都不会相信了,一旦失去了客户的信任,那离失去市场也就不远了。毕竟现在云计算市场竞争还是比较激烈的,像华为云、腾讯云以及百度云等都在摩拳擦掌抢占市场份额。

故障复盘


暴露的问题

制冷系统监控不足

在机房出现故障的时候,首先检测到的是机房温度异常升高而后才排查到是机房制冷设备问题,很明显机房监控系统没有对制冷机组进行监控。原因机房主备水冷机组共用了同一个水路循环系统,因此存在单点故障问题,一旦水路循环系统出问题,主备水冷机组都会受影响。后期对制冷设备进行手动操作仍然不能恢复其正常运行,很明显没有对水冷设备进行过设备故障演练,导致出现问题后恢复操作时间过长。

为什么机房直接进行喷淋?

大家都知道机房中的服务器都是电子设备,而电子设备最怕水了,如果服务器进水了那就很有可能导致服务器短路损坏,进而导致服务器数据丢失,更加延长了故障恢复的时间。所以为什么不是喷洒七氟丙烷气体灭火,最起码泡沫、粉末也行啊,但是实际上直接触发了喷淋操作,这也是非常诡异的地方。

高可用形同虚设

企业使用阿里云的一个重要原因就是看中了其高可用能力,希望哪天在发生故障的时候用户可以无感地快速进行切换,尽可能的减少故障导致的业务中断时间。而在此次的阿里云宕机故障中,虽然采用了B、C可用区双机房进行了容灾,在C可用区故障之后通过B可用区对外提供服务,但是可惜的是B可用区服务实例资源不够,同时ECS启动时依赖的中间件服务没有进行双机房容灾,只部署在了可用区C机房当中,而此时的可用区C机房已经出现故障,导致B区无法实现扩容。这也暴露了阿里云对重要的基础中间件以及oss基础服务并没有真正做到全部双机房容灾,出现了单点故障问题。

阿里云智能换帅

2022年12月29日,阿里云宕机事件不到两周的时间,阿里巴巴董事会主席兼CEO张勇向阿里全员信公布人事变动信息,张勇将亲自兼任阿里云智能总裁,张建锋不再担任阿里云智能总裁。在这封信中提到:只有在一点一滴的行动上保障好客户利益,创造好客户价值,才能承担好引领发展、创造就业、参与国际竞争的大使命。

在这封全员信发出后,张勇作为阿里云智能总裁单独面向阿里云全体小二发了内部信,信中措辞更为严厉,在800多字的内容中,提到了超过20次“客户”,可见张勇这次是真的急了。以下截取了信中部分内容。


反思

无论是像阿里这样体量的公司还是正在创业的公司,都要始终把客户的利益放在第一位,把客户的信任放在第一位才能赢得客户,赢得市场。否则一旦失去客户的信任,也就慢慢失去了市场,那么在激烈的市场竞争中就会被淘汰。希望在新的一年里阿里云真的可以吸取这次宕机事件的教训,真正为客户创造价值。

作者:慕枫技术笔记
来源:juejin.cn/post/7188431024051716152

收起阅读 »

vue阻止重复请求(下)

接:vue阻止重复333请求(上)(c)代码步骤1-通过axios请求拦截器取消重复请求通过axios请求拦截器,在每次请求前把请求信息和请求的取消方法放到一个map对象当中,并且判断map对象当中是否已经存在该请求信息的请求,如果存在取消上传请求<!D...
继续阅读 »

接:vue阻止重复333请求(上)

(c)代码

步骤1-通过axios请求拦截器取消重复请求

通过axios请求拦截器,在每次请求前把请求信息和请求的取消方法放到一个map对象当中,并且判断map对象当中是否已经存在该请求信息的请求,如果存在取消上传请求

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Document</title>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript"></script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript"></script>
   <script src="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/qs/6.10.3/qs.js" type="application/javascript"></script>

</head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1</button>
       <button @click="onClick2" ref="btn2">请求2</button>
   </div>
</body>
<script>
//存储请求信息和取消方法的的map对象    
const pendingRequest = new Map();  
//根据请求的信息(请求方式,url,请求get/post数据),产生map的key
function getRequestKey(config){
   const { methodurlparamsdata } = config;
   return [methodurlQs.stringify(params), Qs.stringify(data)].join("&");
}  
//请求拦截器
axios.interceptors.request.use(
   function (config) {
   //根据请求的信息(请求方式,url,请求get/post数据),产生map的key
   let requestKey = getRequestKey(config)
   //判断请求是否重复
   if(pendingRequest.has(requestKey)){
       //取消上次请求
       let cancel = pendingRequest.get(requestKey)
       cancel()
       //删除请求信息
       pendingRequest.delete(requestKey)
  }
   //把请求信息,添加请求到map当中
   // 生成取消方法
   config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
       // 把取消方法添加到map
       if (!pendingRequest.has(requestKey)) {
           pendingRequest.set(requestKeycancel)
      }
  })
   return config;
},
(error=> {
    return Promise.reject(error);
}
);
let sendPost = function(data){
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       // 使用lodash对请求方法做防抖
       //这里有问题,只是对每个按钮的点击事件单独做了防抖,但是两个按钮之间做不到防抖的效果
       onClick1async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res.data)
      },
       onClick2async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res.data)
      },
  },
})
</script>
</html>

预览

步骤2-通过axios响应拦截器处理请求成功

通过axios的响应拦截器,在请求成功后在map对象当中,删除该请求信息的数据

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Document</title>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript"></script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript"></script>
   <script src="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/qs/6.10.3/qs.js" type="application/javascript"></script>

</head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1</button>
       <button @click="onClick2" ref="btn2">请求2</button>
   </div>
</body>
<script>
//存储请求信息和取消方法的的map对象    
const pendingRequest = new Map();  
//根据请求的信息(请求方式,url,请求get/post数据),产生map的key
function getRequestKey(config){
   const { methodurlparamsdata } = config;
   return [methodurlQs.stringify(params), Qs.stringify(data)].join("&");
}  
//请求拦截器
axios.interceptors.request.use(
   function (config) {
   //根据请求的信息(请求方式,url,请求get/post数据),产生map的key
   let requestKey = getRequestKey(config)
   //判断请求是否重复
   if(pendingRequest.has(requestKey)){
       //取消上次请求
       let cancel = pendingRequest.get(requestKey)
       cancel()
       //删除请求信息
       pendingRequest.delete(requestKey)
  }
   //把请求信息,添加请求到map当中
   // 生成取消方法
   config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
       // 把取消方法添加到map
       if (!pendingRequest.has(requestKey)) {
           pendingRequest.set(requestKeycancel)
      }
  })
   return config;
},
(error=> {
    return Promise.reject(error);
}
);

//响应拦截器
axios.interceptors.response.use(
(response=> {
       //请求成功
       //删除请求的信息
       let requestKey = getRequestKey(response.config)
       if(pendingRequest.has(requestKey)){
           pendingRequest.delete(requestKey)  
      }
       return response;
  },
  (error=> {
       return Promise.reject(error);
  }
);
let sendPost = function(data){
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       // 使用lodash对请求方法做防抖
       //这里有问题,只是对每个按钮的点击事件单独做了防抖,但是两个按钮之间做不到防抖的效果
       onClick1async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res.data)
      },
       onClick2async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res.data)
      },
  },
})
</script>
</html>

预览

步骤3-通过axios响应拦截器处理请求失败

通过axios的响应拦截器,在请求失败后在map对象当中,删除该请求信息的数据

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Document</title>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript"></script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript"></script>
   <script src="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/qs/6.10.3/qs.js" type="application/javascript"></script>

</head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1</button>
       <button @click="onClick2" ref="btn2">请求2</button>
   </div>
</body>
<script>
//存储请求信息和取消方法的的map对象    
const pendingRequest = new Map();  
//根据请求的信息(请求方式,url,请求get/post数据),产生map的key
function getRequestKey(config){
   const { methodurlparamsdata } = config;
   return [methodurlQs.stringify(params), Qs.stringify(data)].join("&");
}  
//请求拦截器
axios.interceptors.request.use(
   function (config) {
   //根据请求的信息(请求方式,url,请求get/post数据),产生map的key
   let requestKey = getRequestKey(config)
   //判断请求是否重复
   if(pendingRequest.has(requestKey)){
       //取消上次请求
       let cancel = pendingRequest.get(requestKey)
       cancel()
       //删除请求信息
       pendingRequest.delete(requestKey)
  }
   //把请求信息,添加请求到map当中
   // 生成取消方法
   config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
       // 把取消方法添加到map
       if (!pendingRequest.has(requestKey)) {
           pendingRequest.set(requestKeycancel)
      }
  })
   return config;
},
(error=> {
    return Promise.reject(error);
}
);
//删除请求信息
function delPendingRequest(config){
   let requestKey = getRequestKey(config)
   if(pendingRequest.has(requestKey)){
       pendingRequest.delete(requestKey)  
  }
}
//响应拦截器
axios.interceptors.response.use(
(response=> {
       //请求成功
       //删除请求的信息
       delPendingRequest(response.config)
       return response;
  },
  (error=> {
       //请求失败
       //不是取消请求的错误
       if (!axios.isCancel(error)){
           //服务器报400,500报错,删除请求信息
           delPendingRequest(error.config || {})
      }
       return Promise.reject(error);
  }
);
let sendPost = function(data){
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       // 使用lodash对请求方法做防抖
       //这里有问题,只是对每个按钮的点击事件单独做了防抖,但是两个按钮之间做不到防抖的效果
       onClick1async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res.data)
      },
       onClick2async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res.data)
      },
  },
})
</script>
</html>

预览

作者:黄金林
来源:juejin.cn/post/7189231050806001719

收起阅读 »

vue阻止重复333请求(上)

项目当中前端代码会遇到同一个请求向服务器发了多次的情况,我们要避免服务器资源浪费,同一个请求一定时间只允许发一次请求(1)如果业务简单,例如同一个按钮防止多次点击,我们可以用定时器做防抖处理 (2)如果业务复杂,例如多个组件通过代码,同一个请求发多次,这个时...
继续阅读 »

背景

项目当中前端代码会遇到同一个请求向服务器发了多次的情况,我们要避免服务器资源浪费,同一个请求一定时间只允许发一次请求

思路

(1)如果业务简单,例如同一个按钮防止多次点击,我们可以用定时器做防抖处理
(2)如果业务复杂,例如多个组件通过代码,同一个请求发多次,这个时候防抖已经不好处理了,最好是对重复的ajax请求统一做取消操作

实现

方式1-通过定时器做防抖处理

(a)概述

效果:当用户连续点击多次同一个按钮,最后一次点击之后,过小段时间后才发起一次请求
原理:每次调用方法后都产生一个定时器,定时器结束以后再发请求,如果重复调用方法,就取消当前的定时器,创建新的定时器,等结束后再发请求,工作当中可以用第三方封装的工具函数例如lodashdebounce方法来简化防抖的代码

(b)代码


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lodash.js/4.17.21/lodash.min.js" type="application/javascript">script>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript">script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript">script>
head>
<body>
   <div id="app">
       <button @click="onClick">请求button>
   div>
body>
<script>
// 定义请求接口
function sendPost(data){
   return axios({
       url'https://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   methods: {
       // 调用lodash的防抖方法debounce,实现连续点击按钮多次,0.3秒后调用1次接口
       onClick_.debounce(async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求的结果'res.data)
      }, 300),
  },
})
script>
html>

(c)预览

连接

(d)存在的问题

无法解决多个按钮件的重复请求的发送问题,例如下面两种情况

情况-在点击事件上做防抖

按钮事件间是相互独立的,调用的是不同方法,做不到按钮间防抖效果


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lodash.js/4.17.21/lodash.min.js" type="application/javascript">script>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript">script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript">script>
head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1button>
       <button @click="onClick2" ref="btn2">请求2button>
   div>
body>
<script>
 
let sendPost = function(data){
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       // 使用lodash对请求方法做防抖
       //这里有问题,只是对每个按钮的点击事件单独做了防抖,但是两个按钮之间做不到防抖的效果
       onClick1_.debounce(async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res.data)
      }, 300),
       onClick2_.debounce(async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res.data)
      }, 300),
  },
})
script>
html>

预览

情况2-在接口方法做防抖

按钮间调用的方法是相同的,是可以对方法做防抖处理,但是处理本身对方法做了一次封装,会影响到之前方法的返回值接收,需要对之前的方法做更多处理,变得更加复杂,不推荐


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lodash.js/4.17.21/lodash.min.js" type="application/javascript">script>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript">script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript">script>
head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1button>
       <button @click="onClick2" ref="btn2">请求2button>
   div>
body>
<script>
// 使用lodash对请求方法做防抖,    
let sendPost = _.debounce(function(data){
   //这里有问题,这里的返回值不能作为sendPost方法执行的返回值,因为debounce内部包裹了一层
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}, 300)
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       onClick1async function(){
           //这里有问题,sendPost返回值不是promise,而是undefined
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res)
      },
       onClick2async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res)
      },
  },
})
script>
html>

预览

方式2-通过取消ajax请求

(a) 概述

直接对请求方法做处理,通过ajax库的api方法把重复的请求给取消掉

(b)原理

原生ajax取消请求

通过调用XMLHttpRequest对象实例的abort方法把请求给取消掉


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
head>
<body>
body>
<script>
//原生ajax的语法    
let xhr = new XMLHttpRequest();
xhr.open("GET""http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test?username=zs&age=20"true);
xhr.onload = function(){
   console.log(xhr.responseText)
}
xhr.send();
//在谷歌浏览器的低速3g下面测试
//通过XMLHttpRequest实例的abort方法取消请求
setTimeout(() => xhr.abort(), 100);
script>
html>

预览

axios取消请求

通过axiosCancelToken对象实例cancel方法把请求给取消掉


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript">script>
head>
<body>
body>
<script>
/*axios的取消的语法*/
// 方式1-通过axios.CancelToken.source产生cancelToken和cancel方法
/*
const source = axios.CancelToken.source();
axios.get('http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test', {
  params: {username: 'zs', age: 20},
  cancelToken: source.token
}).then(res=>{
  console.log('res', res.data)
}).catch(err=>{
  console.log('err', err)
})
//在谷歌浏览器的低速3g下面测试
//通过调用source的cancel方法取消
setTimeout(() => source.cancel(), 100);
*/

/**/
// 方式2-通过new axios.CancelToken产生cancelToken和cancel方法
let cancelFn
const cancelToken =  new axios.CancelToken(cancel=>{
   cancelFn = cancel
});
axios.get('http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test', {
   params: {username: 'zs'age: 20},
   cancelToken: cancelToken
}).then(res=>{
   console.log('res'res.data)
}).catch(err=>{
   console.log('err'err)
})
//在谷歌浏览器的低速3g下面测试
//通过调用cancelFn方法取消
setTimeout(() => cancelFn(), 100);

script>
html>

预览

续:vue阻止重复请求(下)

作者:黄金林
来源:juejin.cn/post/7189231050806001719

收起阅读 »

基于 Android 系统方案适配 Night Mode 后,老板要再加一套皮肤?

背景说明原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。其实我们之...
继续阅读 »

背景说明

原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。

其实我们之前两个 App 的换肤方案都是使用 Android-skin-support 来做的,在此基础上再加套皮肤也不是难事。但在新的 App 实现多皮肤时,由于前两个 App 做了这么久都只有两套皮肤,而且新的 App 需要实现跟随系统,为了更好的体验和较少的代码实现,就采用了系统方案进行适配暗黑模式。

Android-skin-support 和系统两种方案适配经验来看,系统方案适配改动的代码更少,所花费的时间当然也就更少了。所以在需要新添一套皮肤的时候,也不可能再去切方案了。那么在使用系统方案的情况下,如何再加一套皮肤呢?来,先看源码吧。

源码分析

以下源码基于 android-31

首先,在代码中获取资源一般通过 Context 对象的一些方法,例如:

// Context.java

@ColorInt
public final int getColor(@ColorRes int id) {
   return getResources().getColor(id, getTheme());
}

@Nullable
public final Drawable getDrawable(@DrawableRes int id) {
   return getResources().getDrawable(id, getTheme());
}

可以看到 Context 是通过 Resources 对象再去获取的,继续看 Resources

// Resources.java

@ColorInt
public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
   final TypedValue value = obtainTempTypedValue();
   try {
       final ResourcesImpl impl = mResourcesImpl;
       impl.getValue(id, value, true);
       if (value.type >= TypedValue.TYPE_FIRST_INT
           && value.type <= TypedValue.TYPE_LAST_INT) {
           return value.data;
      } else if (value.type != TypedValue.TYPE_STRING) {
           throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)                                 + " type #0x" + Integer.toHexString(value.type) + " is not valid");
      }
       // 这里调用 ResourcesImpl#loadColorStateList 方法获取颜色
       final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
       return csl.getDefaultColor();
  } finally {
    releaseTempTypedValue(value);
  }
}

public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme)
       throws NotFoundException {
   return getDrawableForDensity(id, 0, theme);
}

@Nullable
public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
   final TypedValue value = obtainTempTypedValue();
   try {
       final ResourcesImpl impl = mResourcesImpl;
       impl.getValueForDensity(id, density, value, true);
    // 看到这里
       return loadDrawable(value, id, density, theme);
  } finally {
    releaseTempTypedValue(value);
  }
}

@NonNull
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
Drawable loadDrawable(@NonNull TypedValue value, int id, int density, @Nullable Theme theme)
       throws NotFoundException {
   // 这里调用 ResourcesImpl#loadDrawable 方法获取 drawable 资源
   return mResourcesImpl.loadDrawable(this, value, id, density, theme);
}

到这里我们知道在代码中获取资源时,是通过 Context -> Resources -> ResourcesImpl 调用链实现的。

先看 ResourcesImpl.java

/**
* The implementation of Resource access. This class contains the AssetManager and all caches
* associated with it.
*
* {@link Resources} is just a thing wrapper around this class. When a configuration change
* occurs, clients can retain the same {@link Resources} reference because the underlying
* {@link ResourcesImpl} object will be updated or re-created.
*
* @hide
*/
public class ResourcesImpl {
  ...
}

虽然是 public 的类,但是被 @hide 标记了,意味着想通过继承后重写相关方法这条路行不通了,pass。

再看 Resources.java,同样是 public 类,但没被 @hide 标记。我们就可以通过继承 Resources 类,然后重写 Resources#getColorResources#getDrawableForDensity 等方法来改造获取资源的逻辑。

先看相关代码:

// SkinResources.kt

class SkinResources(context: Context, res: Resources) : Resources(res.assets, res.displayMetrics, res.configuration) {

   val contextRef: WeakReference<Context> = WeakReference(context)

   override fun getDrawableForDensity(id: Int, density: Int, theme: Theme?): Drawable? {
       return super.getDrawableForDensity(resetResIdIfNeed(contextRef.get(), id), density, theme)
  }

   override fun getColor(id: Int, theme: Theme?): Int {
       return super.getColor(resetResIdIfNeed(contextRef.get(), id), theme)
  }
 
   private fun resetResIdIfNeed(context: Context?, resId: Int): Int {
       // 非暗黑蓝无需替换资源 ID
       if (context == null || !UIUtil.isNightBlue(context)) return resId

       var newResId = resId
       val res = context.resources
       try {
           val resPkg = res.getResourcePackageName(resId)
           // 非本包资源无需替换
           if (context.packageName != resPkg) return newResId

           val resName = res.getResourceEntryName(resId)
           val resType = res.getResourceTypeName(resId)
        // 获取对应暗蓝皮肤的资源 id
           val id = res.getIdentifier("${resName}_blue", resType, resPkg)
           if (id != 0) newResId = id
      } finally {
           return newResId
      }
  }

}

主要原理与逻辑:

  • 所有资源都会在 R.java 文件中生成对应的资源 id,而我们正是通过资源 id 来获取对应资源的。

  • Resources 类提供了 getResourcePackageName/getResourceEntryName/getResourceTypeName 方法,可通过资源 id 获取对应的资源包名/资源名称/资源类型。

  • 过滤掉无需替换资源的场景。

  • Resources 还提供了 getIdentifier 方法来获取对应资源 id。

  • 需要适配暗蓝皮肤的资源,统一在原资源名称的基础上加上 _blue 后缀。

  • 通过 Resources#getIdentifier 方法获取对应暗蓝皮肤的资源 id。如果没找到,改方法会返回 0

现在就可以通过 SkinResources 来获取适配多皮肤的资源了。但是,之前的代码都是通过 Context 直接获取的,如果全部替换成 SkinResources 来获取,那代码改动量就大了。

我们回到前面 Context.java 的源码,可以发现它获取资源时,都是通过 Context#getResources 方法先得到 Resources 对象,再通过其去获取资源的。而 Context#getResources 方法也是可以重写的,这意味着我们可以维护一个自己的 Resources 对象。ApplicationActivity 也都是继承自 Context 的,所以我们在其子类中重写 getResources 方法即可:

// BaseActivity.java/BaseApplication.java

private Resources mSkinResources;

@Override
public Resources getResources() {
   if (mSkinResources == null) {
    mSkinResources = new SkinResources(this, super.getResources());
  }
   return mSkinResources;
}

到此,基本逻辑就写完了,马上 build 跑起来。

咦,好像有点不太对劲,有些 colordrawable 没有适配成功。

经过一番对比,发现 xml 布局中的资源都没有替换成功。

那么问题在哪呢?还是先从源码着手,先来看看 View 是如何从 xml 中获取并设置 background 属性的:

// View.java

public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);

   // AttributeSet 是 xml 中所有属性的集合
   // TypeArray 则是经过处理过的集合,将原始的 xml 属性值("@color/colorBg")转换为所需的类型,并应用主题和样式
   final TypedArray a = context.obtainStyledAttributes(
           attrs, com.android.internal.R.styleable.View, defStyleAttr, defStyleRes);

  ...
   
   Drawable background = null;

  ...
   
   final int N = a.getIndexCount();
for (int i = 0; i < N; i++) {
    int attr = a.getIndex(i);
    switch (attr) {
           case com.android.internal.R.styleable.View_background:
               // TypedArray 提供一些直接获取资源的方法
            background = a.getDrawable(attr);
            break;
          ...
      }
  }
 
  ...
   
   if (background != null) {
    setBackground(background);
  }
 
  ...
}

再接着看 TypedArray 是如何获取资源的:

// TypedArray.java

@Nullable
public Drawable getDrawable(@StyleableRes int index) {
   return getDrawableForDensity(index, 0);
}

@Nullable
public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
   if (mRecycled) {
    throw new RuntimeException("Cannot make calls to a recycled instance!");
  }

   final TypedValue value = mValue;
   if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
       if (value.type == TypedValue.TYPE_ATTRIBUTE) {
           throw new UnsupportedOperationException(
               "Failed to resolve attribute at index " + index + ": " + value);
      }

       if (density > 0) {
           // If the density is overridden, the value in the TypedArray will not reflect this.
           // Do a separate lookup of the resourceId with the density override.
           mResources.getValueForDensity(value.resourceId, density, value, true);
      }
    // 看到这里
       return mResources.loadDrawable(value, value.resourceId, density, mTheme);
  }
   return null;
}

TypedArray 是通过 Resources#loadDrawable 方法来加载资源的,而我们之前写 SkinResources 的时候并没有重写该方法,为什么呢?那是因为该方法是被 @UnsupportedAppUsage 标记的。所以,这就是 xml 布局中的资源替换不成功的原因。

这个问题又怎么解决呢?

之前采用 Android-skin-support 方案做换肤时,了解到它的原理,其会替换成自己的实现的 LayoutInflater.Factory2,并在创建 View 时替换生成对应适配了换肤功能的 View 对象。例如:将 View 替换成 SkinView,而 SkinView 初始化时再重新处理 background 属性,即可完成换肤。

AppCompat 也是同样的逻辑,通过 AppCompatViewInflater 将普通的 View 替换成带 AppCompat- 前缀的 View。

其实我们只需能操作生成后的 View,并且知道 xml 中写了哪些属性值即可。那么我们完全照搬 AppCompat 这套逻辑即可:

  • 定义类继承 LayoutInflater.Factory2,并实现 onCreateView 方法。

  • onCreateView 主要是创建 View 的逻辑,而这部分逻辑完全 copy AppCompatViewInflater 类即可。

  • onCreateView 中创建 View 之后,返回 View 之前,实现我们自己的逻辑。

  • 通过 LayoutInflaterCompat#setFactory2 方法,设置我们自己的 Factory2。

相关代码片段:

public class SkinViewInflater implements LayoutInflater.Factory2 {
   @Nullable
   @Override
   public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    // createView 方法就是 AppCompatViewInflater 中的逻辑
       View view = createView(parent, name, context, attrs, false, false, true, false);
       onViewCreated(context, view, attrs);
       return view;
  }

   @Nullable
   @Override
   public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
       return onCreateView(null, name, context, attrs);
  }

   private void onViewCreated(@NonNull Context context, @Nullable View view, @NonNull AttributeSet attrs) {
    if (view == null) return;
       resetViewAttrsIfNeed(context, view, attrs);
  }
 
   private void resetViewAttrsIfNeed(Context context, View view, AttributeSet attrs) {
    if (!UIUtil.isNightBlue(context)) return;
     
    String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
    String BACKGROUND = "background";
     
    // 获取 background 属性值的资源 id,未找到时返回 0
    int backgroundId = attrs.getAttributeResourceValue(ANDROID_NAMESPACE, BACKGROUND, 0);
    if (backgroundId != 0) {
           view.setBackgroundResource(resetResIdIfNeed(context, backgroundId));
      }
  }
}
// BaseActivity.java

@Override
public void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   SkinViewInflater inflater = new SkinViewInflater();
   LayoutInflater layoutInflater = LayoutInflater.from(this);
   // 生成 View 的逻辑替换成我们自己的
   LayoutInflaterCompat.setFactory2(layoutInflater, inflater);
}

至此,这套方案已经可以解决目前的换肤需求了,剩下的就是进行细节适配了。

其他说明

自定义控件与第三方控件适配

上面只对 background 属性进行了处理,其他需要进行换肤的属性也是同样的处理逻辑。如果是自定义的控件,可以在初始化时调用 TypedArray#getResourceId 方法先获取资源 id,再通过 context 去获取对应资源,而不是使用 TypedArray#getDrawable 类似方法直接获取资源对象,这样可以确保换肤成功。而第三方控件也可通过 background 属性同样的处理逻辑进行适配。

XML <shape> 的处理

<!-- bg.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background" />
</shape>

上面的 bg.xml 文件内的 color 并不会完成资源替换,根据上面的逻辑,需要新增以下内容:

<!-- bg_blue.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background_blue" />
</shape>

如此,资源替换才会成功。

设计的配合

这次对第三款皮肤的适配还是蛮轻松的,主要是有以下基础:

  • 在适配暗黑主题的时候,设计有出设计规范,后续开发按照设计规范来。

  • 暗黑和暗蓝共用一套图片资源,大大减少适配工作量。

  • 暗黑和暗蓝部份共用颜色值含透明度,同样减少了工作量,仅少量颜色需要新增。

这次适配的主要工作量还是来自 <shape> 的替换。

暗蓝皮肤资源文件的归处

我知道很多换肤方案都会将皮肤资源制作成皮肤包,但是这个方案没有这么做。一是没有那么多需要替换的资源,二是为了减少相应的工作量。

我新建了一个资源文件夹,与 res 同级,取名 res-blue。并在 gradle 配置文件中配置它。编译后系统会自动将它们合并,同时也能与常规资源文件隔离开来。

// build.gradle
sourceSets {
main {
java {
srcDir 'src/main/java'
}
res.srcDirs += 'src/main/res'
res.srcDirs += 'src/main/res-blue'
}
}

有哪些坑?

WebView 资源缺失导致闪退

版本上线后,发现有 android.content.res.Resources$NotFoundException 异常上报,具体异常堆栈信息:

android.content.res.ResourcesImpl.getValue(ResourcesImpl.java:321)
android.content.res.Resources.getInteger(Resources.java:1279)
org.chromium.ui.base.DeviceFormFactor.b(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.n(chromium-TrichromeWebViewGoogle.apk-stable-447211483:1)
N7.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:8)
Gu.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:2)
com.android.internal.policy.DecorView$ActionModeCallback2Wrapper.onCreateActionMode(DecorView.java:3255)
com.android.internal.policy.DecorView.startActionMode(DecorView.java:1159)
com.android.internal.policy.DecorView.startActionModeForChild(DecorView.java:1115)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.View.startActionMode(View.java:7716)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.I(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vc0.a(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vf0.i(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
A5.run(chromium-TrichromeWebViewGoogle.apk-stable-447211483:3)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loopOnce(Looper.java:233)
android.os.Looper.loop(Looper.java:334)
android.app.ActivityThread.main(ActivityThread.java:8333)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:582)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1065)

经查才发现在 WebView 中长按文本弹出操作菜单时,就会引发该异常导致 App 闪退。

这是其他插件化方案也踩过的坑,我们只需在创建 SkinResources 之前将外部 WebView 的资源路径添加进来即可。

@Override
public Resources getResources() {
if (mSkinResources == null) {
WebViewResourceHelper.addChromeResourceIfNeeded(this);
mSkinResources = new SkinResources(this, super.getResources());
}
return mSkinResources;
}

RePlugin/WebViewResourceHelper.java 源码文件

具体问题分析可参考

Fix ResourceNotFoundException in Android 7.0 (or above)

最终效果图

skin_demo.gif

总结

这个方案在原本使用系统方式适配暗黑主题的基础上,通过拦截 Resources 相关获取资源的方法,替换换肤后的资源 id,以达到换肤的效果。针对 XML 布局换肤不成功的问题,复制 AppCompatViewInflater 创建 View 的代码逻辑,并在 View 创建成功后重新设置需要进行换肤的相关 XML 属性。同一皮肤资源使用单独的资源文件夹独立存放,可以与正常资源进行隔离,也避免了制作皮肤包而增加工作量。

目前来说这套方案是改造成本最小,侵入性最小的选择。选择适合自身需求的才是最好的。

作者:ONEW
来源:https://juejin.cn/post/7187282270360141879

收起阅读 »

自定义View模仿即刻点赞数字切换效果

即刻点赞展示点赞的数字增加和减少并不是整个替换,而是差异化替换。再加上动画效果就看的很舒服。自己如何实现这种数字切换呢?下面用一张图来展示我的思路:现在只需要根据这张图,写出对应的动画即可。 分为2种场景:数字+1:差异化的数字从3号区域由渐变动画(透明度 0...
继续阅读 »

即刻点赞展示


点赞的数字增加和减少并不是整个替换,而是差异化替换。再加上动画效果就看的很舒服。

自己如何实现这种数字切换呢?

下面用一张图来展示我的思路:


现在只需要根据这张图,写出对应的动画即可。 分为2种场景:

  • 数字+1:

    • 差异化的数字从3号区域由渐变动画(透明度 0- 255) + 偏移动画 (3号区域绘制文字的基线,2号区域绘制文字的基线),将数字移动到2号位置处

    • 差异化的数字从2号区域由渐变动画(透明度 255- 0) + 偏移动画(2号区域绘制文字的基线,1号区域绘制文字的基线),将数字移动到1号位置处

  • 数字-1

    • 差异化的数字从1号区域由渐变动画(透明度 0- 255) + 偏移动画 (1号区域绘制文字的基线,2号区域绘制文字的基线),将数字移动到2号位置处

    • 差异化的数字从2号区域由渐变动画(透明度 255- 0) + 偏移动画(2号区域绘制文字的基线,3号区域绘制文字的基线),将数字移动到3号位置处

公共部分就是: 不变的文字不需要做任何处理,绘制在2号区域就行。绘制差异化文字时,需要加上不变的文字的宽度就行。

效果展示


源码

class LikeView @JvmOverloads constructor(
  context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

  private val paint = Paint().also {
      it.isAntiAlias = true
      it.textSize = 200f
  }

  private val textRect0 = Rect(300, 100, 800, 300)
  private val textRect1 = Rect(300, 300, 800, 500)
  private val textRect2 = Rect(300, 500, 800, 700)

  private var nextNumberAlpha: Int = 0
      set(value) {
          field = value
          invalidate()
      }

  private var currentNumberAlpha: Int = 255
      set(value) {
          field = value
          invalidate()
      }

  private var offsetPercent = 0f
      set(value) {
          field = value
          invalidate()
      }

  private val fontMetrics: FontMetrics = paint.fontMetrics
  private var currentNumber = 99
  private var nextNumber = 0
  private var motionLess = currentNumber.toString()
  private var currentMotion = ""
  private var nextMotion = ""

  private val animator: ObjectAnimator by lazy {
      val nextNumberAlphaAnimator = PropertyValuesHolder.ofInt("nextNumberAlpha", 0, 255)
      val offsetPercentAnimator = PropertyValuesHolder.ofFloat("offsetPercent", 0f, 1f)
      val currentNumberAlphaAnimator = PropertyValuesHolder.ofInt("currentNumberAlpha", 255, 0)
      val animator = ObjectAnimator.ofPropertyValuesHolder(
          this,
          nextNumberAlphaAnimator,
          offsetPercentAnimator,
          currentNumberAlphaAnimator
      )
      animator.duration = 200
      animator.interpolator = DecelerateInterpolator()
      animator.addListener(
          onEnd = {
              currentNumber = nextNumber
          }
      )
      animator
  }

  override fun onDraw(canvas: Canvas) {
      paint.alpha = 255

      paint.color = Color.LTGRAY
      canvas.drawRect(textRect0, paint)

      paint.color = Color.RED
      canvas.drawRect(textRect1, paint)

      paint.color = Color.GREEN
      canvas.drawRect(textRect2, paint)

      paint.color = Color.BLACK
      if (motionLess.isNotEmpty()) {
          drawText(canvas, motionLess, textRect1, 0f)
      }

      if (nextMotion.isEmpty() || currentMotion.isEmpty()) {
          return
      }

      val textHorizontalOffset =
          if (motionLess.isNotEmpty()) paint.measureText(motionLess) else 0f
      if (nextNumber > currentNumber) {
          paint.alpha = currentNumberAlpha
          drawText(canvas, currentMotion, textRect1, textHorizontalOffset, -offsetPercent)
          paint.alpha = nextNumberAlpha
          drawText(canvas, nextMotion, textRect2, textHorizontalOffset, -offsetPercent)
      } else {
          paint.alpha = nextNumberAlpha
          drawText(canvas, nextMotion, textRect0, textHorizontalOffset, offsetPercent)
          paint.alpha = currentNumberAlpha
          drawText(canvas, currentMotion, textRect1, textHorizontalOffset, offsetPercent)
      }
  }

  private fun drawText(
      canvas: Canvas,
      text: String,
      rect: Rect,
      textHorizontalOffset: Float = 0f,
      offsetPercent: Float = 0f
  ) {
      canvas.drawText(
          text,
          rect.left.toFloat() + textHorizontalOffset,
          rect.top + (rect.bottom - rect.top) / 2f - (fontMetrics.bottom + fontMetrics.top) / 2f + offsetPercent * 200,
          paint
      )
  }


  override fun onDetachedFromWindow() {
      super.onDetachedFromWindow()
      animator.end()
  }

  fun plus() {
      if (currentNumber == Int.MAX_VALUE) {
          return
      }
      nextNumber = currentNumber + 1

      processText(findEqualsStringIndex())

      if (animator.isRunning) {
          return
      }
      animator.start()
  }

  fun minus() {
      if (currentNumber == 0) {
          return
      }
      nextNumber = currentNumber - 1
      processText(findEqualsStringIndex())
      if (animator.isRunning) {
          return
      }
      animator.start()
  }

  private fun findEqualsStringIndex(): Int {
      var equalIndex = -1
      val nextNumberStr = nextNumber.toString()
      val currentNumberStr = currentNumber.toString()

      val endIndex = min(currentNumberStr.length, nextNumberStr.length) - 1

      for (index in 0..endIndex) {
          if (nextNumberStr[index] != currentNumberStr[index]) {
              break
          }
          equalIndex = index
      }
      return equalIndex
  }

  private fun processText(index: Int) {
      val currentNumberStr = currentNumber.toString()
      val nextNumberStr = nextNumber.toString()
      if (index == -1) {
          motionLess = ""
          currentMotion = currentNumberStr
          nextMotion = nextNumberStr
      } else {
          motionLess = currentNumberStr.substring(0, index + 1)
          currentMotion = currentNumberStr.substring(index + 1)
          nextMotion = nextNumberStr.substring(index + 1)
      }
  }
}

作者:timer
来源:juejin.cn/post/7179181214530551867

收起阅读 »

android 原生安全音量逻辑设计

前言 接到一个开发需求,需要定制化开发一个安全音量功能;此前有了解过为了符合欧盟等有关国家和地区的规定,原生Android是有自带一个安全音量功能的,想要定制则先要了解这个功能原先长什么样子,下面我们就从一个系统工程师的角度出发去探寻一下,原生Android的...
继续阅读 »

前言


接到一个开发需求,需要定制化开发一个安全音量功能;此前有了解过为了符合欧盟等有关国家和地区的规定,原生Android是有自带一个安全音量功能的,想要定制则先要了解这个功能原先长什么样子,下面我们就从一个系统工程师的角度出发去探寻一下,原生Android的安全音量功能是如何实现的。


安全音量配置


安全音量的相关配置都在framework的config.xml里面,可以直接修改或者overlay配置修改其默认值。


<!-- Whether safe headphone volume is enabled or not (country specific). -->
<bool name="config_safe_media_volume_enabled">true</bool>

<!-- Safe headphone volume index. When music stream volume is below this index
the SPL on headphone output is compliant to EN 60950 requirements for portable music
players. -->
<integer name="config_safe_media_volume_index">10</integer>

config_safe_media_volume_enabled是安全音量功能的总开关,config_safe_media_volume_index则是表明触发安全音量弹框的音量大小值。


安全音量相关流程


安全音量的主要流程都在AudioService里面,其大致流程如下图所示:


MSG_CONFIGURE_SAFE_MEDIA_VOLUME_FORCED
onSystemReady
onConfigureSafeVolume
checkSafeMediaVolume
AudioManager
adjustStreamVolume
setStreamVolume
showSafetyWarningH

onSystemReady 初始化


系统启动过程略去不表,在系统启动完成后会调用onSystemReady;在onSystemReady中,service会发送一个MSG_CONFIGURE_SAFE_MEDIA_VOLUME_FORCED的msg,强制配置安全音量。


public void onSystemReady() {
...
sendMsg(mAudioHandler,
MSG_CONFIGURE_SAFE_MEDIA_VOLUME_FORCED,
SENDMSG_REPLACE,
0,
0,
TAG,
SystemProperties.getBoolean("audio.safemedia.bypass", false) ?
0 : SAFE_VOLUME_CONFIGURE_TIMEOUT_MS);
...
}

发送的MSG_CONFIGURE_SAFE_MEDIA_VOLUME_FORCED会调用onConfigureSafeVolume()来进行安全音量的配置


onConfigureSafeVolume() 安全音量配置


    private void onConfigureSafeVolume(boolean force, String caller) {
synchronized (mSafeMediaVolumeStateLock) {
//Mobile contry code,国家代码,主要用来区分不同国家,部分国家策略可能会不一致
int mcc = mContext.getResources().getConfiguration().mcc;
if ((mMcc != mcc) || ((mMcc == 0) && force)) {
//从config_safe_media_volume_index中获取回来的安全音量触发阈值
mSafeMediaVolumeIndex = mContext.getResources().getInteger(
com.android.internal.R.integer.config_safe_media_volume_index) * 10;

mSafeUsbMediaVolumeIndex = getSafeUsbMediaVolumeIndex();

//根据audio.safemedia.force属性值或者value配置的值来决定是否使能安全音量
boolean safeMediaVolumeEnabled =
SystemProperties.getBoolean("audio.safemedia.force", false)
|| mContext.getResources().getBoolean(
com.android.internal.R.bool.config_safe_media_volume_enabled);

//确认是否需要bypass掉安全音量功能
boolean safeMediaVolumeBypass =
SystemProperties.getBoolean("audio.safemedia.bypass", false);

// The persisted state is either "disabled" or "active": this is the state applied
// next time we boot and cannot be "inactive"
int persistedState;
if (safeMediaVolumeEnabled && !safeMediaVolumeBypass) {
persistedState = SAFE_MEDIA_VOLUME_ACTIVE; //这个值只能是disable或者active,不能是inactive,主要用于下次启动。
// The state can already be "inactive" here if the user has forced it before
// the 30 seconds timeout for forced configuration. In this case we don't reset
// it to "active".
if (mSafeMediaVolumeState != SAFE_MEDIA_VOLUME_INACTIVE) {
if (mMusicActiveMs == 0) { //mMusicActiveMs主要用于计数,当安全音量弹框弹出时,如果按了确定,这个值便开始递增,当其达到UNSAFE_VOLUME_MUSIC_ACTIVE_MS_MAX时,则重新使能安全音量
mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_ACTIVE;
enforceSafeMediaVolume(caller);
} else {
//跑到这里则表示已经弹过安全音量警示了,并且按了确定,所以把值设置为inactive
// We have existing playback time recorded, already confirmed.
mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_INACTIVE;
}
}
} else {
persistedState = SAFE_MEDIA_VOLUME_DISABLED;
mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_DISABLED;
}
mMcc = mcc;
//持久化当前安全音量的状态
sendMsg(mAudioHandler,
MSG_PERSIST_SAFE_VOLUME_STATE,
SENDMSG_QUEUE,
persistedState,
0,
null,
0);
}
}
}

由上可知,onConfigureSafeVolume()主要用于配置和使能安全音量功能,并且通过发送MSG_PERSIST_SAFE_VOLUME_STATE来持久化安全音量配置的值,这个持久化的值只能是active或者disabled。


case MSG_PERSIST_SAFE_VOLUME_STATE:
onPersistSafeVolumeState(msg.arg1);
break;
....
....
private void onPersistSafeVolumeState(int state) {
Settings.Global.putInt(mContentResolver,
Settings.Global.AUDIO_SAFE_VOLUME_STATE,
state);
}

安全音量触发


从实际操作可知,安全音量触发条件是:音量增大到指定值。
从调节音量的代码出发,在调用mAudioManager.adjustStreamVolume和mAudioManager.setStreamVolume时,最终会调用到AudioService中的同名方法,在执行该方法的内部:


protected void adjustStreamVolume(int streamType, int direction, int flags,
String callingPackage, String caller, int uid) {
...
...
...
} else if ((direction == AudioManager.ADJUST_RAISE) &&
!checkSafeMediaVolume(streamTypeAlias, aliasIndex + step, device)) {
Log.e(TAG, "adjustStreamVolume() safe volume index = " + oldIndex);
mVolumeController.postDisplaySafeVolumeWarning(flags);
....
...

private void setStreamVolume(int streamType, int index, int flags, String callingPackage,
String caller, int uid) {
....
....
if (!checkSafeMediaVolume(streamTypeAlias, index, device)) {
mVolumeController.postDisplaySafeVolumeWarning(flags);
mPendingVolumeCommand = new StreamVolumeCommand(
streamType, index, flags, device);
} else {
onSetStreamVolume(streamType, index, flags, device, caller);
index = mStreamStates[streamType].getIndex(device);
}
....
....

由以上代码可以看出,其安全音量弹框警告的触发地方就在checkSafeMediaVolume方法附近处,并且都是通过mVolumeController这个远程服务去调用UI显示安全音量弹框警告,但两种调节音量的方法,触发效果略有不同:



  • adjustStreamVolume:当音量步进方向是上升并且checkSafeMediaVolume返回false时,直接弹出警告框;由于警告框占据了焦点,此时无法进行UI操作,并且再按音量+键时,会继续触发这个弹框,导致无法实质性地调整音量;

  • setStreamVolume:当传入的音量形参大于安全音量阈值,会触发checkSafeMediaVolume返回false,弹出安全音量警告框;并且会通过mPendingVolumeCommand保存设置的音量值,待关掉安全音量后再赋回来。


private boolean checkSafeMediaVolume(int streamType, int index, int device) {
synchronized (mSafeMediaVolumeStateLock) {
if ((mSafeMediaVolumeState == SAFE_MEDIA_VOLUME_ACTIVE) &&
(mStreamVolumeAlias[streamType] == AudioSystem.STREAM_MUSIC) &&
((device & mSafeMediaVolumeDevices) != 0) &&
(index > safeMediaVolumeIndex(device))) {
return false;
}
return true;
}
}

以上是安全音量判断条件checkSafeMediaVolume,可以看出其判断主要根据以下条件:



  • mSafeMediaVolumeState是否为active,这个是安全音量功能的开关变量;

  • 音频流是否为STREAM_MUSIC,只针对该音频流做安全音量;

  • 设备类型,默认mSafeMediaVolumeDevices值如下:


    /*package*/ final int mSafeMediaVolumeDevices = AudioSystem.DEVICE_OUT_WIRED_HEADSET
| AudioSystem.DEVICE_OUT_WIRED_HEADPHONE
| AudioSystem.DEVICE_OUT_USB_HEADSET;

由上可知,只针对耳机播放或者USB耳机才做安全音量功能,如有需要系统工程师可自行配置其他设备;



  • 音量大小,只有音量index超过safeMediaVolumeIndex获取的值,才需要弹出安全音量警示框,而safeMediaVolumeIndex的值则是本文开头在config.xml中配置的config_safe_media_volume_index所得出的;


UI部分


上面有提到,当满足安全音量警示框的触发条件时,会通过mVolumeController这个远程服务去调用UI显示安全音量弹框警告,其调用链条有点长,中途略过不表,其最终会走到VolumeDialogImpl.java的showSafetyWarningH,如下:


public class VolumeDialog {
...
private void showSafetyWarningH(int flags) {
if ((flags & (AudioManager.FLAG_SHOW_UI | AudioManager.FLAG_SHOW_UI_WARNINGS)) != 0
|| mShowing) {
synchronized (mSafetyWarningLock) {
if (mSafetyWarning != null) {
return;
}
mSafetyWarning = new SafetyWarningDialog(mContext, mController.getAudioManager()) {
@Override
protected void cleanUp() {
synchronized (mSafetyWarningLock) {
mSafetyWarning = null;
}
recheckH(null);
}
};
mSafetyWarning.show();
}
recheckH(null);
}
rescheduleTimeoutH();
}
...
}

UI配置部分主要在SafetyWarningDialog.java,代码就不贴了,可自行查看,其本质是一个对话框,在弹出时会抢占UI焦点,如果不点击确定或取消,则无法操作其他UI;点击确定后,会调用mAudioManager.disableSafeMediaVolume()来暂时关闭安全音量警告功能,但上面有提到,当点击确定之后其实是启动了一个变量mMusicActiveMs的计数,当这个计数到达一定值(默认是20个小时),安全音量会重新启动;但如果点击了取消,再继续调大音量时,安全音量弹框还是会继续弹出;


disableSafeMediaVolume()


上面有提到,在安全音量弹框弹出后,点击确定可以暂时关闭安全音量警告功能,其实最终会调用到AudioService中的disableSafeMediaVolume(),代码如下:


public void disableSafeMediaVolume(String callingPackage) {
enforceVolumeController("disable the safe media volume");
synchronized (mSafeMediaVolumeStateLock) {
setSafeMediaVolumeEnabled(false, callingPackage);
if (mPendingVolumeCommand != null) {
onSetStreamVolume(mPendingVolumeCommand.mStreamType,
mPendingVolumeCommand.mIndex,
mPendingVolumeCommand.mFlags,
mPendingVolumeCommand.mDevice,
callingPackage);
mPendingVolumeCommand = null;
}
}
}

一方面是调用setSafeMediaVolumeEnabled来暂时关闭安全音量功能,另一方面会把此前临时挂起的设置音量mPendingVolumeCommand重新设置回去。


小结


简单来讲,Android原生的安全音量功能默认强制打开,在插入耳机后,音量调节到指定阈值时,会触发音量警告弹框,该弹框会抢走焦点,不点击确定或取消无法进行其他操作;在点击确定后,默认操作者本人允许设备音量继续往上调,但此时系统会开始一个默认为20分钟的倒计时,在这20分钟内音量随意调节都不会触发安全音量弹框,但20分钟结束后,音量大于阈值时会继续触发安全音量弹框,提醒使用者注意。


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

落地 Kotlin 代码规范,DeteKt 了解一下~

前言 各个团队多少都有一些自己的代码规范,但制定代码规范简单,困难的是如何落地。如果完全依赖人力Code Review难免有所遗漏。 这个时候就需要通过静态代码检查工具在每次提交代码时自动检查,本文主要介绍如何使用DeteKt落地Kotlin代码规范,主要包括...
继续阅读 »

前言


各个团队多少都有一些自己的代码规范,但制定代码规范简单,困难的是如何落地。如果完全依赖人力Code Review难免有所遗漏。


这个时候就需要通过静态代码检查工具在每次提交代码时自动检查,本文主要介绍如何使用DeteKt落地Kotlin代码规范,主要包括以下内容



  1. 为什么使用DeteKt?

  2. IDE接入DeteKt插件

  3. CLI命令行方式接入DeteKt

  4. Gradle方式接入DeteKt

  5. 自定义Detekt检测规则

  6. Github Action集成Detekt检测


为什么使用DeteKt?


说起静态代码检查,大家首先想起来的可能是lint,相比DeteKt只支持Kotlin代码,lint不仅支持KotlinJava代码,也支持资源文件规范检查,那么我们为什么不使用Lint呢?


在我看来,Lint在使用上主要有两个问题:



  1. IDE集成不够好,自定义lint规则的警告只有在运行./gradlew lint后才会在IDE上展示出来,在clean之后又会消失

  2. lint检查速度较慢,尤其是大型项目,只对增量代码进行检查的逻辑需要自定义


DeteKt提供了IDE插件,开启后可直接在IDE中查看警告,这样可以在第一时间发现问题,避免后续检查发现问题后再修改流程过长的问题


同时Detekt支持CLI命令行方式接入与Gradle方式接入,支持只检查新增代码,在检查速度上比起lint也有一定的优势


IDE接入DeteKt插件


如果能在IDE中提示代码中存在的问题,应该是最快发现问题的方式,DeteKt也贴心的为我们准备了插件,如下所示:



主要可以配置以下内容:



  1. DeteKt开关

  2. 格式化开关,DeteKt直接使用了ktlint的规则

  3. Configuration file:规则配置文件,可以在其中配置各种规则的开关与参数,默认配置可见:default-detekt-config.yml

  4. Baseline file:基线文件,跳过旧代码问题,有了这个基线文件,下次扫描时,就会绕过文件中列出的基线问题,而只提示新增问题。

  5. Plugin jar: 自定义规则jar包,在自定义规则后打出jar包,在扫描时就可以使用自定义规则了


DeteKt IDE插件可以实时提示问题(包括自定义规则),如下图所示,我们添加了自定义禁止使用kae的规则:



对于一些支持自动修复的格式问题,DeteKt插件支持自动格式化,同时也可以配置快捷键,一键自动格式化,如下所示:



CLI命令行方式接入DeteKt


DeteKt支持通过CLI命令行方式接入,支持只检测几个文件,比如本次commit提交的文件


我们可以通过如下方式,下载DeteKtjar然后使用


curl -sSLO https://github.com/detekt/detekt/releases/download/v1.22.0-RC1/detekt-cli-1.22.0-RC1.zip
unzip detekt-cli-1.22.0-RC1.zip
./detekt-cli-1.22.0-RC1/bin/detekt-cli --help

DeteKt CLI支持很多参数,下面列出一些常用的,其他可以参见:Run detekt using Command Line Interface


Usage: detekt [options]
Options:
--auto-correct, -ac
支持自动格式化的规则自动格式化,默认为false
Default: false
--baseline, -b
如果传入了baseline文件,只有不在baseline文件中的问题才会掘出来
--classpath, -cp
实验特性:传入依赖的class路径和jar的路径,用于类型解析
--config, -c
规则配置文件,可以配置规则开关及参数
--create-baseline, -cb
创建baseline,默认false,如果开启会创建出一个baseline文件,供后续使用
--input, -i
输入文件路径,多个路径之间用逗号连接
--jvm-target
EXPERIMENTAL: Target version of the generated JVM bytecode that was
generated during compilation and is now being used for type resolution
(1.6, 1.8, 9, 10, 11, 12, 13, 14, 15, 16 or 17)
Default: 1.8
--language-version
为支持类型解析,需要传入java版本
--plugins, -p
自定义规则jar路径,多个路径之间用,或者;连接

在命令行可以直接通过如下方式检查


java -jar /path/to/detekt-cli-1.21.0-all.jar # detekt-cli-1.21.0-all.jar所在路径
-c /path/to/detekt_1.21.0_format.yml # 规则配置文件所在路径
--plugins /path/to/detekt-formatting-1.21.0.jar # 格式化规则jar,主要基于ktlint封装
-ac # 开启自动格式化
-i $FilePath$ # 需要扫描的源文件,多个路径之间用,或者;连接

通过如上方式进行代码检查速度是非常快的,根据经验来说一般就是几秒之内可以完成,因此我们完成可以将DeteKtgit hook结合起来,在每次提交commit的时候进行检测,而如果是一些比较耗时的工具比如lint,应该是做不到这一点的


类型解析


上面我们提到了,DeteKt--classpth参数与--language-version参数,这些是用于类型解析的。


类型解析是DeteKt的一项功能,它允许 Detekt 对您的 Kotlin 源代码执行更高级的静态分析。


通常,Detekt 在编译期间无法访问编译器语义分析的结果,我们只能获取Kotlin源代码的抽象语法树,却无法知道语法树上符号的语义,这限制了我们的检查能力,比如我们无法判断符号的类型,两个符号究竟是不是同一个对象等


通过启用类型解析,Detekt 可以获取Kotlin编译器语义分析的结果,这让我们可以自定义一些更高级的检查。


而要获取类型与语义,当然要传入依赖的class,也就是classpath,比如android项目中常常需要传入android.jarkotlin-stdlib.jar


Gradle方式接入DeteKt


CLI方式检测虽然快,但是需要手动传入classpath,比较麻烦,尤其是有时候自定义规则需要解析我们自己的类而不是kotlin-stdlib.jar中的类时,那么就需要将项目中的代码的编译结果传入作为classpath了,这样就更麻烦了


DeteKt同样支持Gradle插件方式接入,这种方式不需要我们另外再配置classpath,我们可以将CLI命令行方式与Gradle方式结合起来,在本地通过CLI方式快速检测,在CI上通过Gradle插件进行完整的检测


接入步骤


// 1. 引入插件
plugins {
id("io.gitlab.arturbosch.detekt").version("[version]")
}

repositories {
mavenCentral()
}

// 2. 配置插件
detekt {
config = files("$projectDir/config/detekt.yml") // 规则配置
baseline = file("$projectDir/config/baseline.xml") // baseline配置
parallel = true
}

// 3. 自定义规则
dependencies {
detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:1.21.0"
detektPlugins project(":customRules")
}

// 4. 配置 jvmTarget
tasks.withType(Detekt).configureEach {
jvmTarget = "1.8"
}
// DeteKt Task用于检测,DetektCreateBaselineTask用于创建Baseline
tasks.withType(DetektCreateBaselineTask).configureEach {
jvmTarget = "1.8"
}

// 5. 只分析指定文件
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
// include("**/special/package/**") // 只分析 src/main/kotlin 下面的指定目录文件
exclude("**/special/package/internal/**") // 过滤指定目录
}

如上所示,接入主要需要做这么几件事:



  1. 引入插件

  2. 配置插件,主要是配置configbaseline,即规则开关与老代码过滤

  3. 引入detekt-formatting与自定义规则的依赖

  4. 配置JvmTarget,用于类型解析,但不用再配置classpath了。

  5. 除了baseline之外,也可以通过includeexclude的方式指定只扫描指定文件的方式来实现增量检测


通过以上方式就接入成功了,运行./gradlew detektDebug就可以开始检测了,扫描结果可在终端直接查看,并可以直接定位到问题代码处,也可以在build/reprots/路径下查看输出的报告文件:



自定义Detekt检测规则


要落地自己制定的代码规范,不可避免的需要自定义规则,当然我们首先要看下DeteKt自带的规则,是否已经有我们需要的,只需把开关打开即可.


DeteKt自带规则


DeteKt自带的规则都可以通过开关配置,如果没有在 Detekt 闭包中指定 config 属性,detekt 会使用默认的规则。这些规则采用 yaml 文件描述,运行 ./gradlew detektGenerateConfig 会生成 config/detekt/detekt.yml 文件,我们可以在这个文件的基础上制定代码规范准则。


detekt.yml 中的每条规则形如:


complexity: # 大类
active: true
ComplexCondition: # 规则名
active: true # 是否启用
threshold: 4 # 有些规则,可以设定一个阈值
# ...

更多关于配置文件的修改方式,请参考官方文档-配置文件


Detekt 的规则集划分为 9 个大类,每个大类下有具体的规则:



















































规则大类说明
comments与注释、文档有关的规范检查
complexity检查代码复杂度,复杂度过高的代码不利于维护
coroutines与协程有关的规范检查
empty-blocks空代码块检查,空代码应该尽量避免
exceptions与异常抛出和捕获有关的规范检查
formatting格式化问题,detekt直接引用的 ktlint 的格式化规则集
naming类名、变量命名相关的规范检查
performance检查潜在的性能问题
potentail-bugs检查潜在的BUG
style统一团队的代码风格,也包括一些由 Detekt 定义的格式化问题

表格引用自:cloud.tencent.com/developer/a…


更细节的规则说明,请参考:官方文档-规则集说明


自定义规则


接下来我们自定义一个检测KAE使用的规则,如下所示:


//  入口
class CustomRuleSetProvider : RuleSetProvider {
override val ruleSetId: String = "detekt-custom-rules"
override fun instance(config: Config): RuleSet = RuleSet(
ruleSetId,
listOf(
NoSyntheticImportRule(),
)
)
}

// 自定义规则
class NoSyntheticImportRule : Rule() {
override val issue = Issue(
"NoSyntheticImport",
Severity.Maintainability,
"Don’t import Kotlin Synthetics as it is already deprecated.",
Debt.TWENTY_MINS
)

override fun visitImportDirective(importDirective: KtImportDirective) {
val import = importDirective.importPath?.pathStr
if (import?.contains("kotlinx.android.synthetic") == true) {
report(
CodeSmell(
issue,
Entity.from(importDirective),
"'$import' 不要使用kae,推荐使用viewbinding"
)
)
}
}
}

代码其实并不复杂,主要做了这么几件事:



  1. 添加CustomRuleSetProvider作为自定义规则的入口,并将NoSyntheticImportRule添加进去

  2. 实现NoSyntheticImportRule类,主要包括issue与各种visitXXX方法

  3. issue属性用于定义在控制台或任何其他输出格式上打印的ID、严重性和提示信息

  4. visitImportDirective即通过访问者模式访问语法树的回调,当访问到import时会回调,我们在这里检测有没有添加kotlinx.android.synthetic,发现存在则报告异常


支持类型解析的自定义规则


上面的规则没有用到类型解析,也就是说不传入classpath也能使用,我们现在来看一个需要使用类型解析的自定义规则


比如我们需要在项目中禁止直接使用android.widget.Toast.show,而是使用我们统一封装的工具类,那么我们可以自定义如下规则:


class AvoidToUseToastRule : Rule() {
override val issue = Issue(
"AvoidUseToastRule",
Severity.Maintainability,
"Don’t use android.widget.Toast.show",
Debt.TWENTY_MINS
)

override fun visitReferenceExpression(expression: KtReferenceExpression) {
super.visitReferenceExpression(expression)
if (expression.text == "makeText") {
// 通过bindingContext获取语义
val referenceDescriptor = bindingContext.get(BindingContext.REFERENCE_TARGET, expression)
val packageName = referenceDescriptor?.containingPackage()?.asString()
val className = referenceDescriptor?.containingDeclaration?.name?.asString()
if (packageName == "android.widget" && className == "Toast") {
report(
CodeSmell(
issue, Entity.from(expression), "禁止直接使用Toast,建议使用xxxUtils"
)
)
}
}
}
}

可以看出,我们在visitReferenceExpression回调中检测表达式,我们不仅需要判断是否存在Toast.makeTest表达式,因为可能存在同名类,更需要判断Toast类的具体类型,而这就需要获取语义信息


我们这里通过bindingContext来获取表达式的语义,这里的bindingContext其实就是Kotlin编译器存储语义信息的表,详细的可以参阅:K2 编译器是什么?世界第二高峰又是哪座?


当我们获取了语义信息之后,就可以获取Toast的具体类型,就可以判断出这个Toast是不是android.widget.Toast,也就可以完成检测了


Github Action集成Detekt检测


在完成了DeteKt接入与自定义规则之后,接下来就是每次提交代码时在CI上进行检测了


一些大的开源项目每次提交PR都会进行一系列的检测,我们也用Github Action来实现一个


我们在.github/workflows目录添加如下代码


name: Android CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
detekt-code-check:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
cache: gradle

- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: DeteKt Code Check
run: ./gradlew detektDebug

这样在每次提交PR的时候,就都会自动调用该workflow进行检测了,检测不通过则不允许合并,如下所示:



点进去也可以看到详细的报错,具体是哪一行代码检测不通过,如图所示:



总结


本文主要介绍了DeteKt的接入与如何自定义规则,通过IDE集成,CLI命令行方式与Gradle插件方式接入,以及CI自动检测,可以保证代码规范,IDE提示,CI检测三者的统一,方便提前暴露问题,提高代码质量。


如果本文对你有所帮助,欢迎点赞~


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