注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

IdleHandler你会用吗?记一次IdleHandler使用误区,导致ANR

1. 示例 问题抛出,当引入线上ANR抓取工具后,发现了不少IdleHandler带来的问题。堆栈具体见下图 思考为什么idleHandler会带来这样的问题呢,或许你会觉得是单个消息执行时间过长导致的。那么请看示例,项目本身代码较为复杂,简化代码如下: ...
继续阅读 »

1. 示例


问题抛出,当引入线上ANR抓取工具后,发现了不少IdleHandler带来的问题。堆栈具体见下图


image.png


思考为什么idleHandler会带来这样的问题呢,或许你会觉得是单个消息执行时间过长导致的。那么请看示例,项目本身代码较为复杂,简化代码如下



  • 工具类


    /**
* 添加任务到IdleHandler
*
* @param runnable runnable
*/
public static void run(Runnable runnable) {
IUiRunnable uiRunnable = new IUiRunnable(runnable);
Looper.getMainLooper().getQueue().addIdleHandler(uiRunnable);
}


  • 使用工具类


public class MainActivity extends AppCompatActivity {

public static final String TAG = "idleHandler";

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}

@Override
protected void onResume() {
super.onResume();
Log.e(TAG, "onResume: start");
//1 //关键代码处 延迟 3s执行的delay msg
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
Log.e(TAG, "delay: msg");
startService(new Intent(MainActivity.this, MyService.class));
}
}, 3000);
//关键代码处 添加到IdleHandler里的三个任务
UIManager.run(() -> test(1));
UIManager.run(() -> test(2));
UIManager.run(() -> test(3));
}
//延迟任务
private void test(final int i) {

try {
Log.e(TAG, "queueIdle:test start " + i);
Thread.sleep(3000);
Log.e(TAG, "queueIdle:test end " + i);

} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

让我们来猜猜下面代码的输出顺序。再回看一下代码,肯定以为是下面这样对吧?


修复后.png


如果你觉得上面的输出没问题,那就更需要继续读下去了


这里把真实log截出来:


WX20211128-185004@2x.png


what??delay3000ms为什么失效了。而是Idle消息先执行了。


为什么呢?不慌遇到这种问题我们肯定要通过IdleHandler机制的源码,来找答案了。


2. 源码分析


//MessageQueue.java
Message next() {
// Return here if the message loop has already quit and been disposed.
// This can happen if the application tries to restart a looper after quit
// which is not supported.
final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1; // -1 only during first iteration
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}

nativePollOnce(ptr, nextPollTimeoutMillis);

...
// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
//1 执行时机
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
//2 copy副本
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
//3 逐个执行
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}
//4 是否移除当前idleHandler
if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

// Reset the idle handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;

// While calling an idle handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
}

//5 外部调用添加idleHandler
public void addIdleHandler(@NonNull IdleHandler handler) {
if (handler == null) {
throw new NullPointerException("Can't add a null IdleHandler");
}
synchronized (this) {
mIdleHandlers.add(handler);
}
}

鉴于next()方法比较长且相关介绍也比较多,这里不细说。




  • 注释1处,这里是IdleHandler执行时机,即主线程无消息或者未到执行时机(空闲时间)。




  • 注释2处,可以看到mPendingIdleHandlers相当于copy了mIdleHandlers中的内容,由注释5处可以看到我们调用addIdleHandler()添加的任务都存进了mIdleHandlers。




  • 注释3处,这里循环意味着要一次性处理之前添加的全部IdleHandler任务,如果我们短时内调用了多次addIdleHandler(),意味着这些idle msg将被拿出来逐个执行;而且要全部处理掉,才可以继续执行原消息队列的消息,如果idle msg很耗时,便会出现前面的主线程的postDelay任务执行时间就非常不可靠了;同理如果期间有触摸事件发生,那极有可能会因为得不到及时处理而导致ANR发生。赶紧检查下自己的项目中是否有此类问题。




  • 注释4处,控制本次的IdleHandler是会被再次调用还是单次调用呢,是由queueIdle()方法的返回值决定,这点是我们该利用起来的。




上面的工具方法初衷是好的,提供接口暴露给各业务侧在某个需要的时刻将非紧急任务延迟加载,进而减少卡顿,但用起来却没有那么丝滑,甚至导致ANR,希望大家理解后能够避开这个坑。


3.安全用法


IdleHandler再介绍



  • 这里再赘述下IdleHandler作用,我们都知道在做启动性能优化的时候,要尽可能多地减少启动阶段主线程任务。对一些启动阶段非必须且一定要在主线程里完成的任务,我们可以在应用启动完成之后再去加载。正是考虑到这些google官方提供了IdleHandler机制来告诉我们线程空闲时机。


利用IdleHandler设计的工具类


直接使用IdleHandler,肯定不太符合博主这种“大项目”的编码风格,简单封装是必要,方便我们做些功能定制,下面给大家说下项目中的工具类。



  • 正确的方法 将启动过程中非紧急的主线程任务全部放进uiTasks里,然后逐个执行,切记单个消息耗时不要太长。


private static List<Runnable> uiTasks = new ArrayList<>();

public static UIPoolManager addTask(Runnable runnable) {
tasks.add(runnable);
return this;
}

public static UIManager runUiTasks() {
NullHelper.requireNonNull(uiTasks);
IUiTask iUiTask = new IUiTask() {
@Override
public boolean queueIdle() {
if (!uiTasks.isEmpty()) {
Runnable task = uiTasks.get(0);
task.run();
uiTasks.remove(task);
}
//逐次取一个任务执行 避免占用主线程过久
return !uiTasks.isEmpty();
}
};

Looper.myQueue().addIdleHandler(iUiTask);
return this;
}

替换成上面的方法再试一遍。


@Override
protected void onResume() {
super.onResume();
Log.e(TAG, "onResume: start");
//1
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
Log.e(TAG, "delay: msg");
startService(new Intent(MainActivity.this, MyService.class));
}
}, 3000);
UIManager.addTask(() -> test(1));
UIManager.addTask(() -> test(2));
UIManager.addTask(() -> test(3));
UIManager.runUiTasks();
}

得到最初希望的时序结果


修复后.png


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

iOS整体框架介绍

iOS
这是我参与11月更文挑战的第18天,活动详情查看:2021最后一次更文挑战iOS整体框架通常我们称iOS的框架为cocoa框架. 话不多说,官方的整体框架图如下:简单解释一下:Cocoa (Application) Layer(触摸层)Media Layer ...
继续阅读 »

这是我参与11月更文挑战的第18天,活动详情查看:2021最后一次更文挑战

iOS整体框架

通常我们称iOS的框架为cocoa框架. 话不多说,官方的整体框架图如下:

image.png

简单解释一下:

  • Cocoa (Application) Layer(触摸层)
  • Media Layer (媒体层)
  • Core Services Layer(核心服务层)
  • Core OS Layer (核心系统操作层)
  • The Kernel and Device Drivers layer(内核和驱动层)

注:Cocoa (Application) Layer(触摸层)其实包含cocoa Touch layer(触摸层) 和Application Layer (应用层).应用层原本在触摸层上面,因为应用层是开发者自己实现,所以和触摸层合在一起.

其实每一层都包含多个子框架, 如下图:

image.png

简单解释下(瞄一眼就得了):

  • Cocoa Touch Layer:触摸层提供应用基础的关键技术支持和应用的外观。如NotificationCenter的本地通知和远程推送服务,iAd广告框架,GameKit游戏工具框架,消息UI框架,图片UI框架,地图框架,连接手表框架,UIKit框架、自动适配等等

  • Media Layer:媒体层提供应用中视听方面的技术,如图形图像相关的CoreGraphics,CoreImage,GLKit,OpenGL ES,CoreText,ImageIO等等。声音技术相关的CoreAudio,OpenAL,AVFoundation,视频相关的CoreMedia,Media Player框架,音视频传输的AirPlay框架等等

  • Core Services Layer:系统服务层提供给应用所需要的基础的系统服务。如Accounts账户框架,广告框架,数据存储框架,网络连接框架,地理位置框架,运动框架等等。这些服务中的最核心的是CoreFoundationFoundation框架,定义了所有应用使用的数据类型。CoreFoundation是基于C的一组接口,Foundation是对CoreFoundation的OC封装

  • Core OS Layer:系统核心层包含大多数低级别接近硬件的功能,它所包含的框架常常被其它框架所使用。Accelerate框架包含数字信号,线性代数,图像处理的接口。针对所有的iOS设备硬件之间的差异做优化,保证写一次代码在所有iOS设备上高效运行。CoreBluetooth框架利用蓝牙和外设交互,包括扫描连接蓝牙设备,保存连接状态,断开连接,获取外设的数据或者给外设传输数据等等。Security框架提供管理证书,公钥和私钥信任策略,keychain,hash认证数字签名等等与安全相关的解决方案。

想看更详细的可以移步:iOS总体框架介绍和详尽说明

我们只需要知道其中重要的框架就是UIKit和Function框架.下面说说这两个框架.

Function框架

Foundation框架为所有应用程序提供基本的系统服务。应用程序以及 UIKit和其他框架,都是建立在 Foundation 框架的基础结构之上。 Foundation框架提供许多基本的对象类和数据类型,使其成为应用程序开发的基础。

话不多说,我们先来看看Foundation框架,三个图,包括了Foundation所以的类,图中灰色的是iOS不支持的,灰色部分是OS X系统的。

image.png

image.png

image.png

这里只需要知道绝大部分Function框架的类都继承NSObject, 小部分继承NSProxy

对于Foundation框架中的一些基本类的使用方法详情参见:iOS开发系列—Objective-C之Foundation框架

UIKit框架

UIKit框架提供一系列的Class(类)来建立和管理iOS应用程序的用户界面( UI )接口、应用程序对象、事件控制、绘图模型、窗口、视图和用于控制触摸屏等的接口。

UIKit框架的类继承体系图如下图所示:

image.png

在图中可以看出,responder 类是图中最大分支的根类,UIResponder为处理响应事件和响应链定义了界面和默认行为。当用户用手指滚动列表或者在虚拟键盘上输入时,UIKit就生成事件传送给UIResponder响应链,直到链中有对象处理这个事件。相应的核心对象,比如:UIApplication ,UIWindowUIView都直接或间接的从UIResponder继承 。

这里需要知道一点:UIKit框架所有的类都继承NSObject

UIKit框架的各个类的简单介绍戳后面的链接:UIKit框架各个类的简要说明 

收起阅读 »

阿里二面:什么是mmap?

iOS
平时在面试中你肯定会经常碰见的问题就是:RocketMQ为什么快?Kafka为什么快?什么是mmap?这一类的问题都逃不过的一个点就是零拷贝,虽然还有一些其他的原因,但是今天我们的话题主要就是零拷贝。传统IO在开始谈零拷贝之前,首先要对传统的IO方式有一个概念...
继续阅读 »

平时在面试中你肯定会经常碰见的问题就是:RocketMQ为什么快?Kafka为什么快?什么是mmap?

这一类的问题都逃不过的一个点就是零拷贝,虽然还有一些其他的原因,但是今天我们的话题主要就是零拷贝。

传统IO

在开始谈零拷贝之前,首先要对传统的IO方式有一个概念。

基于传统的IO方式,底层实际上通过调用read()write()来实现。

通过read()把数据从硬盘读取到内核缓冲区,再复制到用户缓冲区;然后再通过write()写入到socket缓冲区,最后写入网卡设备。

整个过程发生了4次用户态和内核态的上下文切换4次拷贝,具体流程如下:

作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS开发公众号:编程大鑫,不管你是小白还是大牛都欢迎入驻 ,让我们一起进步,共同发展!

  1. 用户进程通过read()方法向操作系统发起调用,此时上下文从用户态转向内核态
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区
  3. CPU把读缓冲区数据拷贝到应用缓冲区,上下文从内核态转为用户态,read()返回
  4. 用户进程通过write()方法发起调用,上下文从用户态转为内核态
  5. CPU将应用缓冲区中数据拷贝到socket缓冲区
  6. DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回

那么,这里指的用户态内核态指的是什么?上下文切换又是什么?

简单来说,用户空间指的就是用户进程的运行空间,内核空间就是内核的运行空间。

如果进程运行在内核空间就是内核态,运行在用户空间就是用户态。

为了安全起见,他们之间是互相隔离的,而在用户态和内核态之间的上下文切换也是比较耗时的。

从上面我们可以看到,一次简单的IO过程产生了4次上下文切换,这个无疑在高并发场景下会对性能产生较大的影响。

那么什么又是DMA拷贝呢?

因为对于一个IO操作而言,都是通过CPU发出对应的指令来完成,但是相比CPU来说,IO的速度太慢了,CPU有大量的时间处于等待IO的状态。

因此就产生了DMA(Direct Memory Access)直接内存访问技术,本质上来说他就是一块主板上独立的芯片,通过它来进行内存和IO设备的数据传输,从而减少CPU的等待时间。

但是无论谁来拷贝,频繁的拷贝耗时也是对性能的影响。

零拷贝

零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域,这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。

那么对于零拷贝而言,并非真的是完全没有数据拷贝的过程,只不过是减少用户态和内核态的切换次数以及CPU拷贝的次数。

这里,仅仅有针对性的来谈谈几种常见的零拷贝技术。

mmap+write

mmap+write简单来说就是使用mmap替换了read+write中的read操作,减少了一次CPU的拷贝。

mmap主要实现方式是将读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝。

整个过程发生了4次用户态和内核态的上下文切换3次拷贝,具体流程如下:

  1. 用户进程通过mmap()方法向操作系统发起调用,上下文从用户态转向内核态
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区
  3. 上下文从内核态转为用户态,mmap调用返回
  4. 用户进程通过write()方法发起调用,上下文从用户态转为内核态
  5. CPU将读缓冲区中数据拷贝到socket缓冲区
  6. DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write()返回

mmap的方式节省了一次CPU拷贝,同时由于用户进程中的内存是虚拟的,只是映射到内核的读缓冲区,所以可以节省一半的内存空间,比较适合大文件的传输。

sendfile

相比mmap来说,sendfile同样减少了一次CPU拷贝,而且还减少了2次上下文切换。

sendfile是Linux2.1内核版本后引入的一个系统调用函数,通过使用sendfile数据可以直接在内核空间进行传输,因此避免了用户空间和内核空间的拷贝,同时由于使用sendfile替代了read+write从而节省了一次系统调用,也就是2次上下文切换。

整个过程发生了2次用户态和内核态的上下文切换3次拷贝,具体流程如下:

  1. 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
  2. DMA控制器把数据从硬盘中拷贝到读缓冲区
  3. CPU将读缓冲区中数据拷贝到socket缓冲区
  4. DMA控制器把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,sendfile调用返回

sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。

sendfile+DMA Scatter/Gather

Linux2.4内核版本之后对sendfile做了进一步优化,通过引入新的硬件支持,这个方式叫做DMA Scatter/Gather 分散/收集功能。

它将读缓冲区中的数据描述信息--内存地址和偏移量记录到socket缓冲区,由 DMA 根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次CPU拷贝的过程

整个过程发生了2次用户态和内核态的上下文切换2次拷贝,其中更重要的是完全没有CPU拷贝,具体流程如下:

  1. 用户进程通过sendfile()方法向操作系统发起调用,上下文从用户态转向内核态
  2. DMA控制器利用scatter把数据从硬盘中拷贝到读缓冲区离散存储
  3. CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区
  4. DMA控制器根据文件描述符和数据长度,使用scatter/gather把数据从内核缓冲区拷贝到网卡
  5. sendfile()调用返回,上下文从内核态切换回用户态

DMA gathersendfile一样数据对用户空间不可见,而且需要硬件支持,同时输入文件描述符只能是文件,但是过程中完全没有CPU拷贝过程,极大提升了性能。

应用场景

对于文章开头说的两个场景:RocketMQ和Kafka都使用到了零拷贝的技术。

对于MQ而言,无非就是生产者发送数据到MQ然后持久化到磁盘,之后消费者从MQ读取数据。

对于RocketMQ来说这两个步骤使用的是mmap+write,而Kafka则是使用mmap+write持久化数据,发送数据使用sendfile

总结

由于CPU和IO速度的差异问题,产生了DMA技术,通过DMA搬运来减少CPU的等待时间。

传统的IOread+write方式会产生2次DMA拷贝+2次CPU拷贝,同时有4次上下文切换。

而通过mmap+write方式则产生2次DMA拷贝+1次CPU拷贝,4次上下文切换,通过内存映射减少了一次CPU拷贝,可以减少内存使用,适合大文件的传输。

sendfile方式是新增的一个系统调用函数,产生2次DMA拷贝+1次CPU拷贝,但是只有2次上下文切换。因为只有一次调用,减少了上下文的切换,但是用户空间对IO数据不可见,适用于静态文件服务器。

sendfile+DMA gather方式产生2次DMA拷贝,没有CPU拷贝,而且也只有2次上下文切换。虽然极大地提升了性能,但是需要依赖新的硬件设备支持。

收起阅读 »

Swift接入例子-适合多人协作

iOS
在「 Swift接入例子 」中介绍了Swift项目如何接入SOT。但是要求SDK解压到特定目录中,编译配置的路径也是绝对路径,不适合多人协作合开。文本介绍适合多人开发协作的接入方法。 还是以开源的「 SwiftMessages 」Demo为例,该工程全部用Sw...
继续阅读 »

「 Swift接入例子 」中介绍了Swift项目如何接入SOT。但是要求SDK解压到特定目录中,编译配置的路径也是绝对路径,不适合多人协作合开。文本介绍适合多人开发协作的接入方法。


还是以开源的「 SwiftMessages 」Demo为例,该工程全部用Swift语言开发。这里把修改好的版本上传到了git上,分支为 「 sotcollaboration 」SotDebug接入免费版,SotRelease接入网站版,读者只需要进行下面的 Step1.配置编译环境 就可以直接用该分支测试。


现在开始从头讲解,git clone原本的工程后(我的路径为/Applications/SwiftMessages),命令行cd /Applications/SwiftMessages进入根目录,使用版本切换命令:git checkout 1e49de7b3780b699(因本文档制作于21年10月21号,以当日版本为准)。首先进入Demo目录,打开Demo.xcodeproj工程,scheme默认就已经选中了Demo:...


我使用的是Xcode12.4,可以直接编译成功,启动APP能看到画面(模拟器):


......


点击最上面的MESSAGE VIEW控件,会弹出一个错误提示窗口,今天我们就来用SOT热更的方式修改错误提示的文案:...


Step1: 配置编译环境


「 下载SOT的SDK 」,解压到项目目录下 /Applications/SwiftMessages/Demo/sotsdk...


在terminal运行命令:sh /Applications/SwiftMessages/Demo/sotsdk/compile-script/install.sh安装SOT编译工具链,需要输入密码。


用文本编辑器打开 /Applications/SwiftMessages/Demo/sotsdk/project-script/sotconfig.sh,修改EnableSot=1:...新版SDK已经不会再使用sotconfig.sh里的sdkdir,sotbuilder和objbuilder路径了,所以不用修改这些配置了,删掉也可以。


Step2: 增加Configuration


增加两个Configuration,只有切换到这两个Configuration才使用SOT编译模式,平时还是用原来的Configuration做开发,步骤如下:



  1. 选中Demo Project,然后选择Info面板,点击Configurations的下面加号,复制Debug的编译配置,并且命名为SotDebug,用来接入免费版的SOT。再选择复制Release编译配置,命名为SotRelease,用来配置网站版的SOT,注意名字都不要留有空格:...加完就是:...

  2. SwiftMessages也加上这两个Configuration:...


注意:读者应用到自己项目中时,需要把所有的工程都加上这两个Configuration,否则编译会报找不到文件等等的错误。所以加完这两个Configuration的之后,就马上切换到它们去Build和Run一下,看是否有编译错误,如果没有再进行下面的操作,如果有,请检查是否漏了一些工程没有添加上。


Step3: 修改编译选项


添加热更需要的编译选项,添加SOT虚拟机静态库等,步骤如下:




  1. 选中Demo工程,然后选择Demo这个Target,再选择Build Settings:...




  2. Other Linker FlagsSotDebug中添加-sotmodule $(PRODUCT_NAME) sotsdk/libs/libsot_free.a -sotsaved $(SRCROOT)/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/sotsdk/project-script/sotconfig.sh


    SotRelease中添加-sotmodule $(PRODUCT_NAME) sotsdk/libs/libsot_web.a -sotsaved $(SRCROOT)/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/sotsdk/project-script/sotconfig.sh


    每个选项的意义如下:



    • -sotmodule是module的名字,可以直接用$(PRODUCT_NAME),也可以自定义名字,名字不要有空格

    • -sotsaved是编译中间产物保存的目录,补丁自动化生成需要对比前后编译的产物来生成补丁

    • -sotconfig指定了项目sotconfig.sh的路径,该脚本控制sot编译器的工作

    • sotsdk/libs/libsot_free.a是SOT虚拟机静态库的路径,链接的是免费版的虚拟机

    • sotsdk/libs/libsot_web.a是SOT虚拟机静态库的路径,链接的是网站版的虚拟机




  3. Other C Flags以及Other Swift Flags的SotDebug和SotRelease下添加-sotmodule $(PRODUCT_NAME) -sotconfig $(SRCROOT)/sotsdk/project-script/sotconfig.sh,意义跟上一步是一样的,需要保持一致。经过上面两步,相关的编译配置结果如下图:...




  4. Preprocessor Macros添加USE_SOT=1,后面用来控制是否编译调用SDK的代码...




  5. 因为SOT SDK库文件编译时不带Bitcode,所以也需要把Enable Bitcode设为No...




  6. 为了模拟器架构时不编译arm64,给SotRelease增加如下配置...或者把Build Active Architecture Only设为Yes




Step4: 增加拷贝补丁脚本


SDK里提供了一个便利脚本,路径在sdk目录的project-script/sot_package.sh,它会把生成的补丁拷贝到Bundle文件夹下,在每次项目编译成功时调用该脚本,添加步骤如下:


...


脚本内容为:



if [[ "$CONFIGURATION" == "SotDebug" || "$CONFIGURATION" == "SotRelease" ]];then
sh "$SOURCE_ROOT/sotsdk/project-script/sot_package.sh" "$SOURCE_ROOT/sotsdk/project-script/sotconfig.sh" "$SOURCE_ROOT/sotsaved/$CONFIGURATION" Demo
fi

复制代码

...


Based on dependency analysis的勾去掉。




Step5: 链接C++库


SOT需要压缩库和c++标准库的支持,还是在这个页面下,打开Link Binary With Libraries页...


点击加号,分别加入这两,libz.tbdlibc++.tbd...




Step6: 调用SDK API


需要用Swift代码调用OC代码,已经提供了一个样例代码在SDK的swift-call-objc目录中,先把callsot.h和callsot.m拷贝到Demo目录下...


再添加到Demo工程中。点击Xcode软件的File按钮,找到Demo目录下的callsot.h和callsot.m,接着点击Add Files to "Demo",如下图所示:...


点击Add按钮,添加后会弹出询问:是否创建桥接文件。点击按钮Create Bridging Header...


然后可以看到项目中多了3个文件,分别是callsot.h,callsot.m和Demo-Bridging-Header.h:...


打开Demo-Bridging-Header.h,加入一行代码#import "callsot.h"...


打开callsot.m,修改代码为


#import <Foundation/Foundation.h>
#import "callsot.h"
#import "../sotsdk/libs/SotWebService.h"
@implementation CallSot:NSObject
-(void) InitSot
{
#ifdef USE_SOT
#ifdef DEBUG
[SotWebService ApplyBundleShip];
#else

[SotWebService Sync:@"1234567" is_dev:false cb:^(SotDownloadScriptStatus status)
{
if(status == SotScriptStatusSuccess)
{
NSLog(@"SotScriptStatusSuccess");
}
else
{
NSLog(@"SotScriptStatusFailure");
}
}];

#endif
#endif
}
@end
复制代码

注意SotWebService.h的头文件路径不再依赖于绝对路径,并且代码里用了#ifdef USE_SOT宏来隔开API调用代码,不影响正常编译:...


打开AppDelegate.swift,加入两行代码let sot = CallSot()sot.initSot()...


注意:读者在应用到自己项目中时,以上这些配置的路径不要生搬硬套。例如找不到SotWebService.h文件,找不到sotconfig.sh文件等等,读者自己要清楚SDK的目录与自己工程目录的相对关系,灵活调整这些配置的路径。


测试热更-免费版


按上面配置完之后,先测试免费版热更功能


Step1: 热更注入



  1. Build Configuration切换到SotDebug...

  2. 确保sotconfig.sh的配置是,EnableSot=1以及GenerateSotShip=0,先Clean Build Folder一下,然后再Build:...


然后看编译日志的输出,Link日志可以看到run sot link等输出,会告诉你每个文件里哪些函数可以被热更等信息:......


项目编译成功了,该APP可以正常启动。同时它具备了热更能力,可以加载补丁改变程序的代码逻辑,下面介绍如何生成补丁来测试它。




Step2: 生成补丁


上一步进行了热更注入的编译,当时的代码保存到了Demo/sotsaved这个文件夹下,用来和新代码比较生成补丁。生成补丁步骤如下:



  1. 首先启动SOT生成补丁模式,修改sotconfig.shEnableSot=1GenerateSotShip=1

  2. ...

  3. 接下来直接在Xcode里修改源代码,把ViewController.swift文件的”Something is horribly wrong!“改成了”SOT is great“,修改前:...修改后:...

  4. Swift项目生成补丁,每次都需要先Clean项目,再Build项目。然后查看编译日志输出,可以看到生成了补丁并且被脚本拷贝到了Bundle目录下,可以展开Link Demo(x86_64)的编译日志:...点击展开后,可看到生成补丁的Link日志,日志里显示了函数demoBasics被修改了:...

  5. 生成出来的补丁原始文件保存到了Demo/sotsaved/SotDebug/x86_64/ship/ship.sot,还记得之前加了一个script到Build Phase中吗?它会每次编译结束时,会把这个补丁拷贝到了Bundle目录中,并且添加CPU架构到文件名中。可以在Bundle中看到这个补丁,至此完毕。




Step3: 加载补丁


启动APP,API会判断Bundle内是否有补丁,有则加载,加载成功的日志大概如下,提示有一个模块加载了热更补丁:...之后点击最上面的MESSAGE VIEW控件,发现弹出的文案变成了SOT is great:...


如果去Xcode断点调试demoBasics,会发现无法断住了,因为实际执行补丁代码的是SOT虚拟机。


顺便提一嘴,GenerateSotShip=1时,编译APP用的是保存在sotsaved目录下的代码,所以无论怎么修改Xcode里的代码,如果没有把补丁拷贝到Bundle目录里,那么APP都是最后一次GenerateSotShip=0热更注入时的样子。


如果怀疑,可以把拷贝补丁的Script脚本从Build Phases删除,可以发现GenerateSotShip=1怎么改代码都不会生效了。


注意:如果读者接入自己的一个很简单项目进行测试,例如设置某个控件的颜色,热更前是红色,修改后是绿色,发现无法生效。那是因为这样的项目太过于简单,寥寥几行代码。热更前没有访问过绿色的这个全局变量,在热更时也无法访问到了,SOT只能利用原有的能力,无法无中生有。所以不要这样测试,更具体的原因在「 热更能力-语言特性 」说明。通常完整的项目代码比较多,所以就不会有这样的缺陷。


接入网站版


按上面的教程,已经对APP实现了免费版和网站版的接入。它俩区别只是链接的库不一样,具体就是Other Linker Flags根据Configuration区别配置,SotRelease下接入了网站版。但除了APP接入了网站版SDK,还需要用配合网站来管理补丁的发布。


Step1: 注册网站



  1. 第一步当然是注册网站,成为会员。点击跳转注册页面,免费注册,注册需要验证邮箱,然后登录。

  2. 从导航栏进入我的APP:...

  3. 点击创建APP,弹出弹窗填写APP的名字:...

  4. 进入APP页面,点击右上角的创建新版本按钮,会弹出弹窗,需要选择网站版,SDK版本选择1.0,目前只有1.0版本,然后输入版本号,版本号可以是随意字符串,方便区分就行。...

  5. 创建版本成功后,点击版本,进入版本页面,左上角是唯一标识该版本的VersionKey,后面API接口需要这个Key。...


Step2: 修改VersionKey


打开callsot.m,修改网站版的Sync接口,第一个参数填入你在网站创建的版本的VersionKey。...至此,网站版热更就算接入完成了。


Step3: 测试网站热更




  • 网站版生成补丁的步骤免费版是一样的,需要经历热更注入->出包->修改代码->生成补丁,这里不再赘述。


    唯一不同的是,生成出来的补丁要上传到网站上,然后才能通过网络同步到手机上实现热更。通过之前的免费版教程,知道生成的补丁会被拷贝到Bundle目录下,所以去Bundle目录里就能找它,在Xcode导航栏里右键选择Products下的Demo.app,选择Show in Finder:...




  • 右键Demo文件,选择Show Package Contents:...




  • 找到目录下的sotship_arm64.sot,这里用手机测试,cpu是arm64类型,补丁名字带有cpu后缀,这就是补丁了:...




  • 回到网站的版本页面,点击右侧上传补丁按钮:...




  • 弹出页面里,真机的架构一般选择arm64,除非是老的armv7的机器,并把补丁文件拖到框里,点击上传:...




  • 上传成功并且补丁文件无异常(补丁最大支持5MB),则会添加成功,补丁默认是停用状态,需要点击编辑来启用它:...




  • 这里选择全量启用,点击下面的提交按钮,然后补丁就会成功启用了:...




  • 上一步更新了补丁状态,通常很快生效,但CDN有时也需要1到2分钟才能生效。之后手机打开APP,如果成功下载补丁和加载的话,就能看到下面的日志:...这里输出的md5也跟网站上的补丁md5是一致的。




  • 打开APP后,点击最上面的MESSAGE VIEW控件,发现弹出的文案变成了SOT is great:...




注意:使用网站版,需要考虑到网络传输延迟的问题,只有看到了下载补丁和成功加载补丁的日志之后,调用的函数才会使用热修后的函数。例如有的开发问我,首屏代码怎么无法热更生效?那是因为首屏代码调用的时机太早了,SOT去网站上拿补丁,是异步的,不会一直卡住等着,而且在异步线程中等待结果。在补丁没传输回来之前,首屏的代码都已经调用结束了,这种情况下当然调用的还是老的代码了。而免费版没有这个问题,因为免费版是同步加载补丁的,直接去Bundle里加载,不是异步的。


构建热更注入版本和构建补丁必须是同一台机器,同一个Xcode版本。例如上架前APP用Xcode12进行了热更注入,而之后用Xcode13来构建补丁,那么将得到无效甚至错误的补丁。请使用同一个版本Xcode。


Step4: 几点提示



  • 网站版跟免费版主要接入流程差不多,可以用免费版测试,功能通过测试之后再接入网站版。

  • 网站版需要有网络的情况下才能生效,如果手机没有网,即使之前已经下载过了补丁,也无法加载。

  • 网站版费用很低,日活10万的APP,一个月几百块就够了。

  • 网站版补丁和配置都放在CDN上,支持高并发。




非主Target接入热更


上面的教程都是针对主Target,也就是Demo。这个工程还有一个名为SwiftMessages的Framework,也可以热更,下面介绍如何配置。


可以看到SwiftMessages的Mach-O Type是Dynamic Library,通过下图方式查看得到:...


这种类型的话,配置相对麻烦些。还有一种是Static Library,配置起来会简单得多。但本例改成Static Library启动会崩溃,所以按Dynamic Library的方式来介绍。


Step1: 修改编译选项



  1. 选中SwiftMessages.xcodeproject工程,然后选择SwiftMessages这个Target,再选择Build Settings:...

  2. Other Linker FlagsSotDebug中添加-sotmodule $(PRODUCT_NAME) $(SRCROOT)/Demo/sotsdk/libs/libsot_free.a -sotsaved $(SRCROOT)/Demo/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh

  3. Other Linker FlagsSotRelease中添加-sotmodule $(PRODUCT_NAME) $(SRCROOT)/Demo/sotsdk/libs/libsot_web.a -sotsaved $(SRCROOT)/Demo/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh

  4. Other C Flags以及Other Swift FlagsSotDebugSotRelease中,添加-sotmodule $(PRODUCT_NAME) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh,意义跟上一步是一样的,需要保持一致。经过上面两步,相关的编译配置结果如下图:...这一步跟Demo的配置差不多,区别在于有些路径写法不一样,以达到复用Demo配置的目的,读者可以仔细比较一下。

  5. Preprocessor Macros添加USE_SOT=1,后面用来控制是否编译调用SDK的代码...

  6. 需要把Target的Enable Bitcode设为No...

  7. 为了模拟器架构时不编译arm64,给SotRelease增加如下配置...


Step2: 链接C++库


点击Build Phases页面,打开Link Binary With Libraries页,点击加号,分别加入这两,libz.tbdlibc++.tbd...


Step3: 调用SDK API


因为SwiftMessages是动态库,所以需要在它的编译文件中调用SDK的热更初始化接口。跟Demo一样,添加OC文件。先从Demo文件夹中复制callsot.h和callsot.m文件到SwiftMessages文件夹中...


选中SwiftMessages工程,点击Xcode软件的File按钮,接着点击Add Files to "SwiftMessages.xcodeproject",如下图所示:...


选择到SwiftMessages目录,同时选中callsot.h和callsot.m两个文件,勾选下面的Copy items if needed,勾选Add to targets:中的SwiftMessages target,如下图所示:...


点击Add按钮,然后可以看到项目中多了2个文件,分别是callsot.h,callsot.m,修改CallSot类名为CallSotMessage:...


去到右边面板,把文件属性改成public:...


打开callsot.m,做相应路径和类名的修改:...


打开Demo-Bridging-Header.h,加入一行代码#import "SwiftMessages/callsot.h"...


打开AppDelegate.swift,加入两行代码let sot1 = CallSotMessage()sot1.initSot()...


因为这时候有两个Target都可以生成补丁,Demo和SwiftMessages,需要修改拷贝补丁的脚本,加入SwiftMessages:...


Step4: 测试热更




  1. 测试热更的流程跟之前是一模一样的,只是输出的日志可能会有所区别,我们过一遍。EnableSot=1和GenerateSotShip=0热更注入,先Clean后Build,如果去看编译日志的Link SwiftMessages,也可以看到热更注入的信息。




  2. 然后修改MessageView.swift的代码,错误提示的文案会加上“SOT is great”:...




  3. GenerateSotShip=1开启生成补丁模式,Clean后Build,查看Link SwiftMessages日志,有提示该函数被热更:...




  4. 接下来可以看到补丁拷贝脚本日志输出的信息,这里它检测到有两个Target都生成了补丁文件,会把它们两个合成一个,拷贝到Bundle目录下:...




  5. 启动APP,会看到两条加载补丁的日志,因为我们Demo Target和SwiftMessages Target都调用了API接口:...




  6. 点击MESSAGE VIEW控件,可以看到错误提示文案后面多了“SOT is great”,热更成功:


    ...网站版的测试跟以前也是一样的,这里不再重复了。




Step5: 几点提示



  1. Dynamic Library的热更编译改法其实跟主Target,也就是Mach-O Type为Executable的改法是一样,只是这里复用了主Target的一些配置,例如sotsaved目录和sotconfig.sh的路径。增加再多的Target也可以按同样的改法修改它们。

  2. 补丁拷贝脚本只需要主Target有就行了,把要热更的sotmodule对应的名字加上即可,条件就是sotsaved目录必须是同一个。

  3. 如果需要接入网站版,那么每个需要热更的Target都需要调用API跟网站同步,它们的消耗是独立计费的。


Static Library的改法


上面说到Dynamic Library的改法步骤比较多,而且有诸多缺点,如果能把Framework的Mach-O Type改成Static Library是最好的,会少很多步骤和配置。由于本例无法修改,这里简单说一下步骤:



  1. Other Libraian Flags添加-sotmodule $(PRODUCT_NAME) -sotsaved $(SRCROOT)/Demo/sotsaved/$(CONFIGURATION)/$(CURRENT_ARCH) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh ,注意是Other Libraian Flags而不是Other Linker Flags了。还有这里比Dynamic Library加的配置少一个,即没有链接SDK的.a库文件了。

  2. Other C Flags以及Other Swift Flags添加-sotmodule $(PRODUCT_NAME) -sotconfig $(SRCROOT)/Demo/sotsdk/project-script/sotconfig.sh,这步跟之前是一模一样的。

  3. 需要把Target的Enable Bitcode设为No

  4. 修改拷贝补丁的脚本,加入该Target的名字,例如本例加入SwiftMessages,跟之前也是一模一样的:...


然后就配置完了,如果是使用网站版,同步一次消耗,就能实现所有Target的热更,修改简单,对包体影响最小。




总结


本文完整介绍Swift项目如何接入免费版和网站版。


本文的方式是把SDK拷贝到了工程文件夹里,让它可以跟随项目一起进行版本管理,路径也配置成了相对路径,更加灵活。


通过新增Configuration的方式,也做到了不影响原来的开发,Debug和Release相当于没有接入SOT,适合大多数开发平时使用。只需上线前改成SotRelease出包,就能让APP就得到热更能力。


作者:忒修斯科技
链接:https://juejin.cn/post/7033403091550470180
收起阅读 »

Java内存区域异常

一、内存区域划分Java程序执行时在逻辑上按照功能划分为不同的区域,其中包括方法区、堆区、Java虚拟机栈、本地方法栈、程序计数器、执行引擎、本地库接口、本地方法库几个部分,各个区域宏观结构如下,各部分详细功能及作用暂不展开论述。 JVM内存自动管理是Java...
继续阅读 »



一、内存区域划分

Java程序执行时在逻辑上按照功能划分为不同的区域,其中包括方法区、堆区、Java虚拟机栈、本地方法栈、程序计数器、执行引擎、本地库接口、本地方法库几个部分,各个区域宏观结构如下,各部分详细功能及作用暂不展开论述。

JVM内存自动管理是Java语言的一大优良特性,也是Java语言在软件市场长青的一大优势,有了JVM的自动内存管理,程序员不再为令人抓狂的内存泄漏担忧,但深入理解Java虚拟机内存区域依然至关重要,JVM内存自动管理并非永远万无一失,不当的使用也会造成OOM等内存异常,本文尝试列举Java内存溢出相关的常见异常并提供使用建议。

二、栈溢出

  • 方法调用深度过大

我们知道Java虚拟机栈及本地方法栈中存放的是方法调用所需的栈帧,栈帧是一种包括局部变量表、操作数栈、动态链接和方法返回地址的数据结构,每一次方法的调用和返回对应着压栈及出栈操作。Java虚拟机栈及本地方法栈由每个Java执行线程独享,当方法嵌套过深直至超过栈的最大空间时,当再次执行压栈操作,将抛出StackOverflowError异常。为演示栈溢出,假设存在如下代码:

/**
* 最大栈深度
*/
public class MaxInvokeDeep {
  private static int count = 0;

  /**
  * 无终止条件的递归调用,每次调用将进行压栈操作,超过栈最大空间后,将抛出stackOverFlow异常
  */
  public static void increment() {
      count++;
      increment();
  }

  public static void main(String[] args) {
      try {
          increment();
      } catch (Throwable e) {
          System.out.println("invokeDeep:" + count);
          e.printStackTrace();
      }
  }
}

对于示例中的increment()方法,每次方法调用将深度+1,通过不断的栈帧压栈操作,当栈中空间无法再进行扩展时,程序将抛出StackOverflowError异常。Java虚拟机栈的空间大小可以通过JVM参数调整,我们先设置栈空间大小为512K(-Xss512k),程序执行结果如下:(方法调用5355次后触发栈溢出) 我们再将栈空间缩小至256k(-Xss256k),程序执行结果如下:(方法调用2079次时将触发栈溢出): 由此可见,Java虚拟机栈的空间是有限的,当我们进行程序设计时,应尽量避免使用递归调用。生产环境抛出StackOverflowError异常时,可以排查系统中是否存在调用深度过大的方法。方法的不断嵌套调用,不但会占用更多的内存空间,也会影响程序的执行效率。当我们进行程序设计时需要充分考虑并设置合理的栈空间大小,一般情况下虚拟机默认配置即满足大部分应用场景。

  • 局部变量表过大

局部变量表用于存放Java方法中的局部变量,我们需要合理的设置局部变量,避免过多的冗余变量产生,否则可能会导致栈溢出,沿用刚刚的实例代码,我们定义一个创建大量局部变量的重载方法,则在栈空间不变的情况下(-Xss512k),创建大量局部变量的方法将降低栈调用深度,更容易触发StackOverflowError异常,通过分别执行increment及其重载方法,其中代码及执行结果如下:

package org.learn.jvm.basic;

/**
* 最大栈深度
*/
public class MaxInvokeDeep {
  private static int count = 0;

  /**
    * 无终止条件的递归调用,每次调用将进行压栈操作,超过栈最大空间后,将抛出stackOverFlow异常
    */
  public static void increment() {
      count++;
      increment();
  }

  /**
    * 创建大量局部变量,导致局部变量表过大,影响栈调用深度
    *
    * @param a
    * @param b
    * @param c
    */
  public static void increment(long a, long b, long c) {
      long d = 1, e = 2, f = 3, g = 4, h = 5, i = 6,
              j = 7, k = 8, l = 9, m = 10;
      count++;
      increment(a, b, c);
  }

  public static void main(String[] args) {
      try {
          //increment();
          increment(1, 2, 3);
      } catch (Throwable e) {
          System.out.println("invokeDeep:" + count);
          e.printStackTrace();
      }
  }
}

执行increment()时,最大栈深度为5355(栈空间大小-Xss512k)

执行increment(1,2,3)时,最大栈深度为1487(栈空间大小-Xss512k)

通过对比我们知道,创建大量的局部变量将使得局部变量表膨胀从而引发StackOverflowError异常,程序设计时应当尽量避免同一个方法中包含大量局部变量,实在无法避免可以考虑方法的重构及拆分。

三、OOM

对于Java程序员而言,OOM算是比较常见的生产环境异常,OOM往往容易引发线上事故,因此有必要梳理常见可能导致OOM的场景,并尽量规避保障服务的稳定性。

  • 创建大量的类

方法区主要的职责是用于存放类型相关信息,如类名、接口名、父类、访问修饰符、字段描述、方法描述等,Java8以后方法区从永久区移到了Metaspace,若系统中创建过多的类时,可能会引发OOM。Java开发中经常会利用字节码增强技术,通过创建代理类从而实现系统功能,以我们熟知的Spring框架为例,经常通过Cglib运行时创建代理类实现类的动态性,通过设置虚拟机参数,-XX:MaxMetaspaceSize=10M,则示例程序运行一段时间时间后,将触发Full GC并最终抛出OOM异常,因此日常开发过程中要特别留意,系统中创建的类的数量是否合理,以免产生OOM。实例代码如下:

/**
* 大量创建类导致方法区OOM
*/
public class JavaMethodAreaOOM {
  /**
    * VM Args: -XX:MaxMetaspaceSize=10M(设置Metaspace空间为10MB)
    *
    * @param args
    */
  public static void main(String[] args) {
      while (true) {
          Enhancer enhancer = new Enhancer();
          enhancer.setSuperclass(OOMObject.class);
          enhancer.setUseCache(false);
          enhancer.setCallback(new MethodInterceptor() {
              @Override
              public Object intercept(Object obj, Method method,
                                      Object[] args, MethodProxy proxy) throws Throwable {
                  return proxy.invokeSuper(obj, args);
              }
          });
          Object object = enhancer.create();
          System.out.println("hashcode:" + object.hashCode());
      }
  }

  /**
    * 对象
    */
  static class OOMObject {

  }
}

设置虚拟机参数:-Xms1M -Xmx1M -verbose:gc -XX:+PrintGCDetails 限定元数据区为10M,则虚拟机Full GC之后,将引发OOM异常,程序执行结果如下:

  • 创建大对象

Java堆内存空间非常宝贵,若系统中存在数组、复杂嵌套对象等大对象,将会引发FullGc并最终引发OOM异常,因此程序设计时需要合理设置类结构,避免产生过大的对象,示例代码如下:

/**
* 堆内存溢出
*/
public class HeapOOM {
  /**
    *
    */
  static class InnerClass {
      private int value;
      private byte[] bytes = new byte[1024];
  }

  /**
    * VM Args: -Xms1M -Xmx1M -verbose:gc -XX:+PrintGCDetails
    *
    * @param args
    */
  public static void main(String[] args) {
      List<InnerClass> innerClassList = new ArrayList<>();
      while (true) {
          innerClassList.add(new InnerClass());
      }
  }
}

设置虚拟机参数:-Xms1M -Xmx1M -verbose:gc -XX:+PrintGCDetails 限定最大堆内存空间为10M,则虚拟机Full GC之后,将引发OOM异常,程序执行结果如下。

  • 常量池溢出

Java8开始,常量池从永久区移至堆区,当系统中存在大量常量并超过常量池最大容量时,将引发OOM异常。String类的intern()方法内部逻辑是:若常量池中存在字符串常量,则直接返回字符串引用,否则创建常量将其加入常量池中并返回常量池引用。通过每次创建不同的字符串,常量池将因为无法容纳新创建的字符串常量而最终引发OOM,示例代码如下:

/**
* VM Args: -Xms1M -Xmx1M -verbose:gc -XX:+PrintGCDetails
*
* @param args
*/
public static void main(String[] args) {
  Set<String> set = new HashSet<>();
  int i = 0;
  while (true) {
      set.add(String.valueOf(i++).intern());
  }
}

设置虚拟机参数:-Xms1M -Xmx1M -verbose:gc -XX:+PrintGCDetails 限定最大堆内存空间为10M,则虚拟机Full GC之后,将引发OOM异常,程序执行结果如下。

  • 直接内存溢出

直接内存是因NIO技术而引入的独立于堆区的一块内存空间,对于读写频繁的场景,通过直接操作直接内存可以获得更高的执行效率,但其内存空间受制于操作系统本地内存大小,超过最大限制后,也将抛出OOM 异常,以下代码通过UnSafe类,直接操作内存,程序执行一段时间后,将引发OOM异常。

public class DirectMemoryOOM {
  private static final int _1MB = 1024 * 1024;

  /**
    * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
    * @param args
    * @throws IllegalAccessException
    */
  public static void main(String[] args) throws IllegalAccessException {
      Field field = Unsafe.class.getDeclaredFields()[0];
      field.setAccessible(true);
      Unsafe unsafe = (Unsafe) field.get(null);
      while (true) {
          unsafe.allocateMemory(_1MB*1024);
      }
  }
}

设置虚拟机参数:-Xmx20M -XX:MaxDirectMemorySize=10M 限定最大直接内存空间为10M,程序最终执行结果如下:

四、总结

本文通过实际的案例列举了常见的OOM异常,由此我们可以得知,程序设计过程中应当合理的设置栈区、堆区、直接内存的大小,从实际情况出发,合理设计数据结构,从而避免引发OOM故障,此外通过分析引发OOM的原因也有利于我们针对深入理解JVM并对现有系统进行系统调优。

作者:洞幺幺洞
来源:https://juejin.cn/post/7043442191619850277

收起阅读 »

如何用JavaScript实现双向映射?

本文翻译自 《How to create a Bidirectional Map in JavaScript》双向映射是指在键值对中建立双向一一对应关系的一种模式。它既可以通过键名(key)去获取值(value),也可以通过值去获取键名。让我们看下如何在Jav...
继续阅读 »



本文翻译自 《How to create a Bidirectional Map in JavaScript》

双向映射是指在键值对中建立双向一一对应关系的一种模式。它既可以通过键名(key)去获取值(value),也可以通过值去获取键名。让我们看下如何在JavaScript中实现一个双向映射,以及 TypeScript 中的应用。

双向映射背后的计算机科学与数学

首先看一下双向映射的基本定义:

在计算机科学中,双向映射是由一一对应的键值对组成的数据结构,因此在每个方向都可以建立二元关系:每个值也可以对应唯一的键。

百科指路双向映射

计算机科学中的双向映射,源于数学上的双射函数。双射函数是指两个集合中的每个元素,都可以在另一个集合中找到与之匹配的另一个元素,反之也可以通过后者找到匹配的前者,因此也被叫做可逆函数。

百科指路: 双射函数

扩展:

  • 单射(injection):每一个x都有唯一的y与之对应;

  • 满射(surjection):每一个y都必有至少一个x与之对应;

  • 双射(又叫一一对应,bijection):每一个x都有y与之对应,每一个y都有x与之对应。

根据上面的说明,一个简单的双射函数就像这样:

f(1) = 'D';
f(C) = 3;

另外,双射函数需要两个集合的长度相等,否则会失败。

初始化双向映射

我们可以在JavaScript 中创建一个类来初始化键值对:

const bimap = new BidirectionalMap({
 a: 'A',
 b: 'B',
 c: 'C',
})

在类里面,我们将会创建两个列表,一个用来处理正向映射,存放初始化对象的副本;另一个用来处理逆向映射,存放的内容是「键」「值」翻转后的初始化对象。

class BidirectionalMap {
 fwdMap = {}
 revMap = {}

 constructor(map) {
     this.fwdMap = { ...map }
     this.revMap = Object.keys(map).reduce(
        (acc, cur) => ({
             ...acc,
            [map[cur]]: cur,
        }),
        {}
    )
}
}

注意,由于初始对象本身的性质,你不能用数字当 key,但可以作为值来使用。

const bimap = new BidirectionalMap({
 a: 42,
 b: 'B',
 c: 'C',
})

如果不满足于此,也有更强大健壮的实现方式,按照 JavaScript 映射数据类型 中允许使用数字、函数甚至NaN来作为 key 的规范来实现,当然这会更加复杂。

通过双向映射获取元素

现在,我们有了一个包含两个对象的数据结构,它们互为键值对的镜像。我们现在需要一个方法来取出元素,让我们来实现一个 get() 函数:

 get( key ) {
   return this.fwdMap[key] || this.revMap[key]
}

这个方法非常简单: 如果正向映射里存在就返回,否则返回逆向映射,都没有就返回 undefined

试一下获取元素:

console.log(bimap.get('a')) // displays A
console.log(bimap.get('A')  // displays a

给双向映射添加元素

目前映射还无法添加元素,我们创建一个添加方法:

add(pair) {
   this.fwdMap[pair[0]] = pair[1]
   this.revMap[pair[1]] = pair[0]
}

add 函数接收一个双元素数组(在TypeScript 中叫做元组),按不同键值顺序加入到相应对象中。

现在我们可以添加和读取映射中的元素了:

bimap.add(['d', 'D'])
console.log( bimap.get('D') ) // displays d

在TypeScript中安全使用双向映射

为了确保数据类型安全,我们可以在 TypeScript 中进行改写,对输入类型进行检查,例如初始化的映射必须为一个通用对象,添加的元素必须为一个 元组

class BidirectionalMap {
 fwdMap = {}
 revMap = {}

 constructor(map: { [key: string]: string }) {
     this.fwdMap = { ...map }
     this.revMap = Object.keys(map).reduce(
        (acc, cur) => ({
             ...acc,
            [map[cur]]: cur,
        }),
        {}
    )
}

 get(key: string): string | undefined {
     return this.fwdMap[key] || this.revMap[key]
}

 add(pair: [string, string]) {
   this.fwdMap[pair[0]] = pair[1]
   this.revMap[pair[1]] = pair[0]
}
}

这样我们的映射就更加安全和完美了。在这里,我们的 key 和 value 都必须使用字符串。


翻译:sherryhe
来源:https://juejin.cn/post/6976797991277428750

收起阅读 »

消息队列的使用场景是什么样的?

本文从异步、解耦、削峰填谷等核心应用场景,以及消息中间件常用协议、推拉模式对比来解答此问题。什么是消息中间件作为一种典型的消息代理组件(Message Broker),是企业级应用系统中常用的消息中间件,主要应用于分布式系统或组件之间的消息通讯,提供具有可靠、...
继续阅读 »



本文从异步、解耦、削峰填谷等核心应用场景,以及消息中间件常用协议、推拉模式对比来解答此问题。

什么是消息中间件

作为一种典型的消息代理组件(Message Broker),是企业级应用系统中常用的消息中间件,主要应用于分布式系统或组件之间的消息通讯,提供具有可靠、异步和事务等特性的消息通信服务。应用消息代理组件可以降低系统间耦合度,提高系统的吞吐量、可扩展性和高可用性。

分布式消息服务主要涉及五个核心角色,消息发布者(Publisher)、可靠消息组件(MsgBroker)、消息订阅者(Subscriber)、消息类型(Message Type)和订阅关系(Binding),具体描述如下:

  1. 消息发布者,指发送消息的应用系统,一个应用系统可以发送一种或者多种消息类型,发布者发送消息到可靠消息组件 (MsgBroker)。

  2. 可靠消息组件,即 MsgBroker,负责接收发布者发送的消息,根据消息类型和订阅关系将消息分发投递到一个或多个消息订阅者。整个过程涉及消息类型校验、消息持久化存储、订阅关系匹配、消息投递和消息恢复等核心功能。

  3. 消息订阅者,指订阅消息的应用系统,一个应用系统可以订阅一种或者多种消息类型,消息订阅者收到的消息来自可靠消息组件 (MsgBroker)。

  4. 消息类型:一种消息类型由 TOPIC 和 EVENTCODE 唯一标识。

  5. 订阅关系,用来描述一种消息类型被订阅者订阅,订阅关系也被称为 Binding。

核心功能特色

可为不同应用系统间提供可靠的消息通信,降低系统间耦合度并提高整体架构的可扩展性和可用性。

可为不同应用系统间提供异步消息通信,提高系统吞吐量和性能。

发布者系统、消息代理组件以及订阅者系统均支持集群水平扩展,可依据业务消息量动态部署计算节点。

支持事务型消息,保证消息与本地数据库事务的一致性。

远程调用RPC和消息MQ区别

谈到消息队列,有必要看下RPC和MQ的本质区别,从两者的定义和定位来看,RPC(Remote Procedure Call)远程过程调用,主要解决远程通信间的问题,不需要了解底层网络的通信机制;消息队列(MQ)是一种能实现生产者到消费者单向通信的通信模型。核心区别在于RPC是双向直接网络通讯,MQ是单向引入中间载体的网络通讯。单纯去看队列,队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。在队列前面增加限定词“消息”,意味着通过消息驱动来进行整体的架构实现。RPC和MQ本质上是网络通讯的两种不同的实现机制,RPC同步等待结果对比于MQ在异步、解耦、削峰填谷等上的特征显著差异主要有以下几点差异:

  1. 在架构上,RPC和MQ的差异点是,Message有一个中间结点Message Queue,可以把消息存储起来。

  2. 同步调用:对于要立即等待返回处理结果的场景,RPC是首选。

  3. MQ的使用,一方面是基于性能的考虑,比如服务端不能快速的响应客户端(或客户端也不要求实时响应),需要在队列里缓存;另外一方面,它更侧重数据的传输,因此方式更加多样化,除了点对点外,还有订阅发布等功能。

  4. 随着业务增长,有的处理端调用下游服务太多或者处理量会成为瓶颈,会进行同步调用改造为异步调用,这个时候可以考虑使用MQ。

核心应用场景

针对MQ的核心场景,我们从异步、解耦、削峰填谷等特性进行分析,区别于传统的RPC调用。尤其在引入中间节点的情况下,通过空间(拥有存储能力)换时间(RPC同步等待响应)的思想,增加更多的可能性和能力。

异步通信

针对不需要立即处理消息,尤其那种非常耗时的操作,通过消息队列提供了异步处理机制,通过额外的消费线程接管这部分进行异步操作处理。

解耦

在应用和应用之间,提供了异构系统之间的消息通讯的机制,通过消息中间件解决多个系统或异构系统之间除了RPC之外另一种单向通讯的机制。

扩展性

因为消息队列解耦了主流程的处理过程,只要另外增加处理过程即可,不需要改变代码、不需要调整参数,便于分布式扩容。

分布式事务一致性

在2个应用系统之间的数据状态同步,需要考虑数据状态的最终一致性的场景下,利用消息队列所提供的事务消息来实现系统间的数据状态一致性。

削峰填谷

在访问量剧增的情况下,应用仍然需要继续发挥作用,但是这样的突发流量无法提前预知;如果为了能处理这类瞬间峰值访问提前准备应用资源无疑是比较大的浪费。使用消息队列在突发事件下的防脉冲能力提供了一种保障,能够接管前台的大脉冲请求,然后异步慢速消费。

可恢复性

系统的一部分组件失效时,不会影响到整个系统。消息队列降低了应用间的耦合度,所以即使一个处理消息的应用挂掉,加入队列中的消息仍然可以在系统恢复后被处理。

顺序保证

在大多使用场景下,数据处理的顺序都很重要。大部分消息队列本来就是排序的,并且能保证数据会按照特定的顺序来进行处理。

大量堆积

通过消息堆积能力处理数据迁移场景,针对旧数据进行全量迁移的同时开启增量消息堆积,待全量迁移完毕,再开启增量,保证数据最终一致性且不丢失。

数据流处理

分布式系统产生的海量数据流,如:业务日志、监控数据、用户行为等,针对这些数据流进行实时或批量采集汇总,然后导入到大数据实时计算引擎,通过消息队列解决异构系统的数据对接能力。

业界消息中间件对比

详细的对比可以参考:blog.csdn.net/wangzhipeng…

消息中间件常用协议

AMQP协议

AMQP即Advanced Message Queuing Protocol,提供统一消息服务的高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件不同产品,不同开发语言等条件的限制。

优点:可靠、通用

MQTT协议

MQTT(Message Queuing Telemetry Transport,消息队列遥测传输)是IBM开发的一个即时通讯协议,成为物联网的重要组成部分。该协议支持所有平台,几乎可以把所有联网物品和外部连接起来,被用来当做传感器和致动器(比如通过Twitter让房屋联网)的通信协议。

优点:格式简洁、占用带宽小、移动端通信、PUSH、嵌入式系统

STOMP协议

STOMP(Streaming Text Orientated Message Protocol)是流文本定向消息协议,是一种为MOM(Message Oriented Middleware,面向消息的中间件)设计的简单文本协议。STOMP提供一个可互操作的连接格式,允许客户端与任意STOMP消息代理(Broker)进行交互。

优点:命令模式(非topic/queue模式)

XMPP协议

XMPP(可扩展消息处理现场协议,Extensible Messaging and Presence Protocol)是基于可扩展标记语言(XML)的协议,多用于即时消息(IM)以及在线现场探测。适用于服务器之间的准即时操作。核心是基于XML流传输,这个协议可能最终允许因特网用户向因特网上的其他任何人发送即时消息,即使其操作系统和浏览器不同。

优点:通用公开、兼容性强、可扩展、安全性高,但XML编码格式占用带宽大

基于TCP/IP自定义的协议

有些特殊框架(如:redis、kafka、rocketMQ等)根据自身需要未严格遵循MQ规范,而是基于TCP/IP自行封装了一套二进制编解码协议,通过网络socket接口进行传输,实现了MQ的标准规范相关功能。

消息中间件推和拉模式对比

Push推模式:服务端除了负责消息存储、处理请求,还需要保存推送状态、保存订阅关系、消费者负载均衡;推模式的实时性更好;如果push能力大于消费能力,可能导致消费者崩溃或大量消息丢失

Push模式的主要优点是:

  1. 对用户要求低,方便用户获取需要的信息

  2. 及时性好,服务器端即时地向客户端推送更行的动态信息

Push模式的主要缺点是:

  1. 推送的信息可能并不能满足客户端的个性化需求

  2. Push消息大于消费者消费速率额,需要有协调QoS机制做到消费端反馈

Pull拉模式:客户端除了消费消息,还要保存消息偏移量offset,以及异常情况下的消息暂存和recover;不能及时获取消息,数据量大时容易引起broker消息堆积。

Pull拉模式的主要优点是:

  1. 针对性强,能满足客户端的个性化需求

  2. 客户端按需获取,服务器端只是被动接收查询,对客户端的查询请求做出响应

Pull拉模式主要的缺点是:

  1. 实时较差,针对于服务器端实时更新的信息,客户端难以获取实时信息

  2. 对于客户端用户的要求较高,需要维护位点

相关资料

建议学习以下的技术文档,了解更多详细的技术细节和实现原理,加深对消息中间件的理解和应用,同时可以下载开源的源代码,本地调试相应的代码,加深对技术原理的理解和概念的掌握,以及在实际生产中更多的掌握不同的消息队列应用的场景下,高效和正确地使用消息中间件。

RocketMQ资料: github.com/apache/rock…

Kafka资料: kafka.apache.org/documentati…

阿里云RocketMQ文档: help.aliyun.com/document_de…


作者:阿里巴巴淘系技术
来源:https://juejin.cn/post/7025955365812437028

收起阅读 »

业务实战中经典算法的应用

有网友提问:各种机器学习算法的应用场景分别是什么(比如朴素贝叶斯、决策树、K 近邻、SVM、逻辑回归最大熵模型)?这些在一般工作中分别用到的频率多大?一般用途是什么?需要注意什么?根据问题,核心关键词是基础算法和应用场景,比较担忧的点是这些基础算法能否学有所用...
继续阅读 »



有网友提问:各种机器学习算法的应用场景分别是什么(比如朴素贝叶斯、决策树、K 近邻、SVM、逻辑回归最大熵模型)?

这些在一般工作中分别用到的频率多大?一般用途是什么?需要注意什么?

根据问题,核心关键词是基础算法和应用场景,比较担忧的点是这些基础算法能否学有所用?毕竟,上述算法是大家一上来就接触的,书架上可能还放着几本充满情怀的《数据挖掘导论》《模式分类》等经典书籍,但又对在深度学习时代基础算法是否有立足之地抱有担忧。

网上已经有很多内容解答了经典算法的基本思路和理论上的应用场景,这些场景更像是模型的适用范围,这与工业界算法实际落地中的场景其实有很大区别。

从工业界的角度看,业务价值才是衡量算法优劣的金钥匙,而业务场景往往包含业务目标、约束条件和实现成本。如果我们只看目标,那么前沿的算法往往占据主导,可如果我们需兼顾算法运行复杂度、快速迭代试错、各种强加的业务限制等,经典算法往往更好用,因而就占据了一席之地。

针对这个问题,淘系技术算法工程师感知同学写出本文详细解答。

在实际业务价值中,算法模型的影响面大致是10%

在工业界,算法从想法到落地,你不是一个人在战斗。我以推荐算法为例,假设我们现在接到的任务是支持某频道页feeds流的推荐。我们首先应该意识到对业务来说,模型的影响面大致是10%,其他几个重要影响因子是产品设计(40%)、数据(30%)、领域知识的表示和建模(20%)。

这意味着,你把普通的LR模型升级成深度模型,即使提升20%,可能对业务的贡献大致只有2%。当然,2%也不少,只是这个折扣打的让人脑壳疼。

当然,上面的比例划分并不是一成不变的,在阿里推荐算法元年也就是2015年起,个性化推荐往往对标运营规则,你要是不提升个20%都不好意思跟人打招呼。那么一个算法工程师的日常除了接需求,就是做优化:业务给我输入日志、特征和优化目标,剩下的事情就交给我吭哧吭哧。

可随着一年年的水涨船高,大家所用模型自然也越来越复杂,从LR->FTRL->WDL->DeepFM->MMOE,大家也都沿着前辈们躺过的路径一步步走下去。这时候你要是问谁还在用LR或是普通的决策树,那确实会得到一个尴尬的笑容。

但渐渐的,大家也意识到了,模型优化终究是一个边际收益递减的事情。当我们把业务方屏蔽在外面而只在一个密闭空间中优化,天花板就已经注定。于是渐渐的,淘系的推荐慢慢进入第二阶段,算法和业务共建阶段。业务需求和算法优化虽然还是分开走,但已经开始有融合的地方。

集团对算法工程师的要求也在改变:一个高大上的深度模型,如果不能说清楚业务价值,或带来特别明显提升,那么只能认为是自嗨式的闭门造车。这时,一个优秀的算法工程师,需要熟悉业务,通过和业务反复交流中,能够弄清楚业务痛点。

注意,业务方甚至可能当局者迷,会提出既要又要还要的需求给你,而你需要真正聚焦到那个最值得做的问题上。然后,才是对问题的算法描述。做到这一步你会发现,并不是你来定哪个模型牛逼,而是跟着问题走来选择模型。这个模型的第一版极大可能是一个经典算法,因为,你要尽快跑通链路,快速验证你的这个idea是有效的。后面的模型迭代提升,只是时间问题。

经典算法在淘系的应用场景示例:TF-IDF、K近邻、朴素贝叶斯、逻辑回归等

因而现阶段,在淘系的大多数场景中,并不是算法来驱动业务,而是配合业务一起来完成增长。一个只懂技术的算法工程师,最多只能拿到那10%的满分。为了让大家有体感,这里再举几个小例子:

比如业务问题是对用户进行人群打标,人群包括钓鱼控、豆蔻少女、耳机发烧友、男神style等。在实操中我们不仅需考虑用户年龄、性别、购买力等属性,还需考虑用户在淘系的长期行为,从而得到一个多分类任务。如果模型所用的的特征是按月访问频次,那么豆蔻少女很可能网罗非常多的用户,因为女装是淘系行为频次最多的类目。

比如,某用户对耳机发烧友和豆蔻少女一个月内都有4次访问,假设耳机发烧友人均访问次数是3.2次,而豆蔻少女是4.8次,那么可知该用户对耳机发烧友的偏好分应更高。因此,模型特征不仅应该使用用户对该人群的绝对行为频次,还需参照大盘的水位给出相对行为频次。

这时,入选吴军老师《数学之美》的TF-IDF算法就派上用场了。通过引入TF-IDF构建特征,可以显著提高人群标签的模型效果,而TF-IDF则是非常基础的文本分类算法。

在淘系推荐场景,提升feeds流的点击率或转化率往往是一个常见场景。可业务总会给你惊喜:比如商品的库存只有一件(阿里拍卖),比如推荐的商品大多是新品(天猫新品),比如通过小样来吸引用户复购正品,这些用户大多是第一次来(天猫U先),或者是在提升效率的同时还需兼顾类目丰富度(很多场景)。

在上述不同业务约束背景,才是我们真实面对的应用场景。面对这种情况首先是定方向,比如在阿里拍卖中的问题可描述为“如何在浅库存约束下进行个性化推荐”。假如你判断这是一个流量调控问题,就需要列出优化目标和约束条件,并调研如何用拉格朗日乘子法求解。重要的是,最终的结果还需要和个性化推荐系统结合。详见:阿里拍卖全链路导购策略首次揭秘

面对上述应用场景,你需明白你的战略目标是证明浅库存约束下的推荐是一个流量调控问题,并可以快速验证拿到效果。采用一个成熟经典的方法先快速落地实验,后续再逐步迭代是明智的选择。

又比如,K近邻算法似乎能简单到能用一张图或一句话来描述。可它在解决正负样本不均衡问题中就能派上用场。上采样是说通过将少数类(往往是正样本)的数据复制多份,但上采样后存在重复数据集可能会导致过拟合。过拟合的一种原因是在局部范围正负样本比例存在差异,如下图所示:

我们只需对C类局部样本进行上采样,这其中就运用到了K近邻算法。本例中的经典算法虽然只是链路的一部分,甚至只是配角儿,但离了它不行。

朴素贝叶斯虽然简单了点,但贝叶斯理论往后的发展,包括贝叶斯网络、因果图就不那么simple了。比如不论是金融业务中的LR评分卡模型,或是推荐算法精排中的深度模型,交叉特征往往由人工经验配置,这甚至是算法最不自动的一个环节。

使用贝叶斯网络中的structure learning,并结合业务输入的行业知识,构建出贝叶斯概率图,并从中发现相关特征做交叉,会比人工配置带来一定提升,也具有更好的可解释性。这就是把业务领域知识和算法模型结合的一个很好的例子。可如果贝叶斯理论不扎实,很难走到这一步。

不论深度学习怎么火,LR都是大多场景的一个backup。比如在双十一大促投放场景中,如果在0:00~0:30这样的峰值期,所有场景都走深度模型,机器资源肯定不足,这时候就需要做好一个使用LR或FTRL的降级预案。

如何成为业界优秀的算法工程师?

在淘系,算法并不是孤立的存在,算法工程师也不只是一个闭关的剑客。怎么切中业务痛点,快速验证你的idea,怎么进行合适的算法选型,这要求你有很好的算法基本功,以及较广的算法视野。一个模型无论搞的多复杂最终的回答都是业务价值,一个有良好基本功、具备快速学习能力,且善于发掘业务价值的算法工程师,将有很大成长空间。

好吧,假设上面就是我们的职业目标,可怎么实现呢。元(ye)芳(jie),你怎么看?其实只有一个句话,理论和实践相结合

理论

对算法基本思路的了解,查查知乎你可能只需要20分钟,可你忘记它可能也只需要2周。这里的理论是你从内而外对这个算法的感悟,比如说到决策树,你脑海里好像就能模拟出它的信息增益计算、特征选择、节点分裂的过程,知道它的优和劣。最终,都是为了在实践中能快速识别出它就是最适合解决当前问题的模型。

基本功弄扎实是一个慢活儿,方法上可以是看经典原著、学习视频分享或是用高级语言实现。重要的是心态,能平心静气,不设预期,保持热情;另外,如果你有个可以分享的兴趣小组,那么恭喜你。因为学习书籍或看paper的过程其实挺枯燥的,但如果有分享的动力你可以走的更远。

实践

都知道实践出真知,可实践往往也很残酷。因为需求和约束多如牛毛,问题需要你来发现,留给你的时间窗口又很短暂。也就是,算法是一个偏确定性、有适用边界、标准化的事情;而业务则是发散的、多目标的、经验驱动的事情。

你首先是需要有双发现的眼睛,找到那个最值得发力的点,这个需要数据分析配合业务经验,剥丝抽茧留下最主要的优化目标、约束条件,尽量的简化问题;其次是有一张能说会道的嘴,要不然业务怎么愿意给你这个时间窗口让你尝试呢;最后是,在赌上你的人设之后,需要尽快在这个窗口期做出效果,在这个压力之下,考验你算法基本功的时候就到了。

结语

最后,让大家猜个谜语:它是一个贪心的家伙,计算复杂度不大,可以动态的做特征选择,不论特征是离散还是连续;它还可以先剪枝或后剪枝避免过拟合;它融合了信息熵的计算,也可以不讲道理的引入随机因子;它可以孤立的存在,也完全可以做集成学习,还可以配合LR一起来解决特征组合的问题;对小样本、正负样本不均、在线数据流的学习也可以支持;所得到则是有很强可解释性的规则。它,就是决策树。

每一个基础算法,都好比是颗带有智慧的种子,这不是普通的锦上添花,而是石破天惊的原创思维。有时不妨傻傻的回到过去的年代,伴着大师原创的步伐走一走,这种原创的智慧对未来长期的算法之路,大有裨益。神经网络从种下到开花,将近50年;贝叶斯也正在开花结果的路上。下一个,将会是谁呢?


作者:阿里巴巴淘系技术
来源:https://juejin.cn/post/7025964095530598436

收起阅读 »

android 水波纹控件,仿京东语音评价动画

先上效果gradle 引用implementation 'com.maxcion:multwaveviewlib:1.0.0'https://github.com/Likeyong/MultWaveView第一种样式存在三层水波纹,所以要生成3个Wave对象,...
继续阅读 »



先上效果

gradle 引用

implementation 'com.maxcion:multwaveviewlib:1.0.0'

项目地址

https://github.com/Likeyong/MultWaveView

  • 第一种效果是多重水波纹的效果, 三层水波纹,每层水播放可以设置不同的颜色、波高与波宽

  • 第二种效果是 单层水波纹,并且支持从底部慢慢上涨的效果。 这里也可以设置为多层水波纹上涨效果,颜色、波高、波宽以及背景图都是可以定制

  • 第三种效果就是模仿京东语音评价动画,最底部的seekbar 是用来模拟语音输出的声音大小,声音越大波高越大,并且每条水波纹的粗细以及速度都不同

第一种和第二种效果的背景图是这样的, 如果设置了背景图,那么整个控件的大小就是背景图大小与xml中设置的大小无关

List<Wave> waves = new ArrayList<>();
       Wave wave1 = new Wave(0, 150, 1, Color.parseColor("#00FFFF"), 3);
       Wave wave2 = new Wave(1f / 8, 150, 1, Color.parseColor("#6600FFFF"), 3);
       Wave wave3 = new Wave(1f / 4, 150, 1, Color.parseColor("#4400FFFF"), 3);
       waves.add(wave1);
       waves.add(wave2);
       waves.add(wave3);

       waveView.start(WaveArg.build()
              .setWaveList(waves)
              .setAutoRise(false)
              .setIsStroke(false)
              .setTransformBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo))

第一种样式存在三层水波纹,所以要生成3个Wave对象,参数说明如下

设置完水波纹数据后,就可以开始动画了

waveView.start(WaveArg.build()
              .setWaveList(waves)//设置水波纹数据
              .setAutoRise(false)//设置水波纹是否自动上升
              .setIsStroke(false)//设置水波纹是实心的还是线条模式
              .setTransformBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo))
               //设置背景图

      );

第二种样式:只有一层水波纹但是需要自动上涨,所以只用生成一条wave对象

List<Wave> waves = new ArrayList<>();
       Wave wave1 = new Wave(0, 150, 1, Color.parseColor("#00FFFF"), 3);
       waves.add(wave1);

waveView2.start(WaveArg.build()
              .setWaveList(waves)
              .setAutoRise(true)//设置自动上涨
              .setIsStroke(false)
              .setTransformBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo))

      );

第三种样式,仅线条模式,并且三条水波纹粗细不一样,速度不一样,水波纹的粗细和速度都是通过Wave的构造函数设置的

List<Wave> waves = new ArrayList<>();
       Wave wave1 = new Wave(0, 150, 1, Color.parseColor("#000000"), 3);
       Wave wave2 = new Wave(0, 150, 3, Color.parseColor("#000000"), 3);
       Wave wave3 = new Wave(0, 150, 2, Color.parseColor("#000000"), 3);
       waves.add(wave1);
       waves.add(wave2);
       waves.add(wave3);

waveView3.start(WaveArg.build()
              .setWaveList(waves)
              .setAutoRise(false)
              .setIsStroke(true)

      );

这样写我们的三条粗细、速度都不同的水波纹动画就出来了,接下来就是根据声音大小来调整波高,通过 waveView.setWaveHeightMultiple(mult), 因为语音一直输入,声音大小也是一直变化的,科大讯飞的语音转写SDK中就有声音大小的回调,然后将回调出来的声音大小通过setWaveHeightMultiple,就可以实现波高动态改变了

作者:maxcion
来源:https://www.jianshu.com/p/9a770b0e68ff

收起阅读 »

Vue图片懒加载

1、问题在vue项目中,如果图片是从服务器端加载到页面上,图片较大的时候,就会存在一部分一部分加载的情况,会显示非常卡顿,影响体验。2、实现(1)、图片懒加载首先将图片的src链接设为一张我们已经准备好的图片(比如类似加载中的图片),并将其真正的图片地址存储在...
继续阅读 »

1、问题

在vue项目中,如果图片是从服务器端加载到页面上,图片较大的时候,就会存在一部分一部分加载的情况,会显示非常卡顿,影响体验。

2、实现

(1)、图片懒加载

首先将图片的src链接设为一张我们已经准备好的图片(比如类似加载中的图片),并将其真正的图片地址存储在img标签的自定义属性中。当js监听到该图片元素进入可视窗口时,即将自定义属性中的地址存储到src属性中,达到懒加载的效果。这样就可以缓解服务器压力,并且提高用户体验。

(2)、安装vue-lazyload
npm i vue-lazyload -S
(3)、在main.js中引入
import VueLazyload from "vue-lazyload";
Vue.use(VueLazyload,{
  preLoad: 1.3,
  loading: require('../src/assets/loading.gif'),
  attempt: 1
})

其中../src/assets/loading.gif是我本地的正在加载图片gif路径。

3、查看效果

在LazyLoad.vue中引入一张网络图片,在浏览器中限制网速,模拟图片加载缓慢的情况。

LazyLoad.vue

<template>
<div>
<img v-lazy=url1 alt="">
</div>
</template>
<script>
export default {
data (){
return{
url1: 'https://w.wallhaven.cc/full/pk/wallhaven-pkgkkp.png'
}
}
}
</script>

效果:

图片加载中:

图片加载完成:


常用参数:


作者:小绵杨Yancy
来源:https://blog.csdn.net/ZHANGYANG_1109/article/details/121868420

收起阅读 »

大白话讲解JavaScript 执行机制,一看就懂

JavaScript的运行机制所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。 为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是...
继续阅读 »



JavaScript的运行机制

1.JavaScript为什么是单线程?
JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

2、执行机制相关知识点

  • 同步任务

  • 异步任务

举个栗子
有一天,张三要去做饭 这时候他要做两件事 分别是蒸米饭 和 炒菜 ,现在有两种方式去完成这个任务

A. 先去蒸米饭 然后等蒸米饭好了 再去抄菜 ---同步任务
B. 先去蒸米饭 然后等蒸米饭的过程中 再去抄菜 ---异步任务

同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。

具体来说,异步执行的运行机制如下:(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。

总结: 同步任务在主线程执行,形成一个执行栈,执行栈中的所有同步任务执行完毕,就会去读取任务队列,就是对应的异步任务。

JavaScript的宏任务与微任务
除了广义上的定义,我们可以将任务进行更精细的定义,分为宏任务微任务

宏任务(macro-task):包括整体代码script脚本的执行,setTimeout,setInterval,ajax,dom操作,还有如 I/O 操作、UI 渲染等。

微任务(micro-task):Promise回调 node 中的 process.nextTick 、对 Dom 变化监听的 MutationObserver。

主线程都从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)

我们解释一下这张图:

1、同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。
2、当指定的事情完成时,Event Table会将这个函数移入Event Queue。
3、主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
上述过程会不断重复,也就是常说的Event Loop(事件循环)。(Event Loop是javascript的执行机制)

优先级
setTimeout()、setInterval()
setTimeout() 和 setInterval() 产生的任务是 异步任务,也属于 宏任务。
setTimeout() 接受两个参数,第一个是回调函数,第二个是推迟执行的毫秒数。
如果将第二个参数设置为0或者不设置,意思 并不是立即执行,而是指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在"任务队列"的尾部添加一个事件,因此要等到同步任务和"任务队列"现有的事件都处理完,才会得到执行。(画重点)
所以说,setTimeout() 和 setInterval() 第二个参数设置的时间并不是绝对的,它需要根据当前代码最终执行的时间来确定的

Promise
Promise 相对来说就比较特殊了,在 new Promise() 中传入的回调函数是会 立即执行 的,但是它的 then() 方法是在 执行栈之后,任务队列之前 执行的,它属于 微任务。

process.nextTick
process.nextTick 是 Node.js 提供的一个与"任务队列"有关的方法,它产生的任务是放在 执行栈的尾部,并不属于 宏任务 和 微任务,因此它的任务 总是发生在所有异步任务之前。

setImmediate
setImmediate 是 Node.js 提供的另一个与"任务队列"有关的方法,它产生的任务追加到"任务队列"的尾部,它和 setTimeout(fn, 0) 很像,但优先级都是 setTimeout 优先于 setImmediate。
有时候,setTimeout 的执行顺序会在 setImmediate 的前面,有时候会在 setImmediate 的后面,这并不是 node.js 的 bug,这是因为虽然 setTimeout 第二个参数设置为0或者不设置,但是 setTimeout 源码中,会指定一个具体的毫秒数(node为1ms,浏览器为4ms),而由于当前代码执行时间受到执行环境的影响,执行时间有所起伏,如果当前执行的代码小于这个指定的值时,setTimeout 还没到推迟执行的时间,自然就先执行 setImmediate 了,如果当前执行的代码超过这个指定的值时,setTimeout 就会先于 setImmediate 执行。

通过上面的介绍,我们就可以得出一个代码执行的优先级:
同步代码(宏任务) > process.nextTick > Promise(微任务)> setTimeout(fn)、setInterval(fn)(宏任务)> setImmediate(宏任务)> setTimeout(fn, time)、setInterval(fn, time),其中time>0

面试回答
面试中该如何回答呢? 下面是我个人推荐的回答:
首先js 是单线程运行的,在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。
在执行同步代码的时候,如果遇到了异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务
当同步事件执行完毕后,再将异步事件对应的回调加入到与当前执行栈中不同的另一个任务队列中等待执行。
任务队列可以分为宏任务对列和微任务对列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务对列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行。
当微任务对列中的任务都执行完成后再去判断宏任务对列中的任务。

面试遇到的问题总结
1、同步和异步的区别是什么?分别举一个同步和异步的例子
同步会阻塞代码执行,而异步不会。alert是同步,setTimeout是异步

2、为何需要异步呢?
如果第一个示例中间步骤是一个 ajax 请求,现在网络比较慢,请求需要5秒钟。如果是同步,这5秒钟页面就卡死在这里啥也干不了了。

最后,前端 JS 脚本用到异步的场景主要有两个:

  • 定时 setTimeout setInverval

  • 网络请求,如 ajax 加载

  • 事件绑定

3、写出下图执行顺序

执行顺序是2431
在 new Promise() 中传入的回调函数是 立即执行 的,但是它的 then() 方法是在 执行栈之后,任务队列之前 执行的,
.then是回调函数,链式回调,会被放在挂起,等待执行栈的内容执行完后(输出4)再回调(输出3),最后执行异步的1

作者:我写的代码绝对没有问题
来源:https://www.jianshu.com/p/22641c97e351

收起阅读 »

9个问题带你一起了解什么是元宇宙,如何在现实生活中实现

2021 最火的新概念,莫过于元宇宙。2021 年 10 月 29 日,Facebook 宣布改名 Meta;2021 年 11 月 1 日,“元宇宙第一股” Roblox 经过短暂调整,宣布重新上线。元宇宙概念的热度可见一斑,国内外都在研究,我们程序员能否借...
继续阅读 »

2021 最火的新概念,莫过于元宇宙。2021 年 10 月 29 日,Facebook 宣布改名 Meta;2021 年 11 月 1 日,“元宇宙第一股” Roblox 经过短暂调整,宣布重新上线。元宇宙概念的热度可见一斑,国内外都在研究,我们程序员能否借用元宇宙概念进行技术延申,今天这篇文章或许会给到你一些启发。


一、什么是元宇宙?


1)元宇宙概念的提出


元宇宙在很长一段时间内仅存在于文学与影视作品中。元宇宙(Metaverse)由Meta和Verse两个词根组成,Meta表示“超越”“元”,verse表示“宇宙Universe”。Metaverse一词最早来自1992年的科幻小说《雪崩》。小说描绘人们在虚拟现实世界中通过控制自己的数字化身相互竞争以提升社会地位。在其后的接近30年间,元宇宙的概念在《黑客帝国》《头号玩家》《西部世界》等影视作品,《模拟人生》等游戏中有所呈现。在这一阶段,元宇宙的概念比较模糊,更多地被理解为平行的虚拟世界。


image.png


2)元宇宙八大要素


根据元宇宙概念上市公司Roblox的定义,元宇宙应具备身份、朋友、沉浸感、低延迟、多元化、随地、经济系统、文明等八大要素。元宇宙的表现形式大多以游戏为起点,并逐渐整合互联网、数字化娱乐、社交网络等功能,长期来看甚至可以整合社会经济与商业活动。


image.png


3)元宇宙第一股Roblox上市


2021年3月10日,Roblox采取直接挂牌模式(DPO)在纽约证券交易所上市。Roblox认为元宇宙用于描述虚拟宇宙中持久的、共享的、三维虚拟空间的概念。


4)元宇宙总结了前期科技的发展方向


在迸发元宇宙概念前,5G基础设施、用于智能终端的显示屏、AI芯片等技术不断发展演进,同时工业互联网、产业互联网、数字孪生、VR游戏等概念均不断成熟。而在元宇宙概念诞生后,可以很好地总结这一时期大部分技术的发展方向。。


image.png


二、元宇宙的架构?


51aspx认为,元宇宙的载体与内容这两个概念十分宽泛,主要分三部分:



  • 元宇宙的底层由基础设施与终端硬件设备组成:包括但不限于人机交互、3D引擎、GIS、设计工具、游戏渲染、画面渲染、隐私计算、AI、操作系统、工业互联网、内容分发、应用商店以及智能合约;

  • 在此基础上,元宇宙还需要大量的软件与技术协同:包括但不限于:基础设施端的5G、6G、云计算、区块链节点、边缘计算节点、DPU;用户端的路由器、传感器、芯片、VR头显、显示器、脑机接口;

  • 基于此,元宇宙可以衍生出相应的应用,并基于元宇宙各类应用发展出潜在的内容载体。


image.png


三、元宇宙的发展模式?


1)元宇宙的发展是循序渐进的过程,技术端、内容端、载体端都在不断演变



  • 技术端,区块链技术在不断演进;

  • 内容端,元宇宙概念的游戏不断增加,生态不断加强,用户数也随之增长。以Roblox、Sandbox为代表的UGC元宇宙概念游戏得益于玩家的参与而不断丰富自己游戏的内容;

  • 载体端,通信技术、虚拟现实、芯片等底层技术也在不断演进。


四、元宇宙的渗透路径?


这个问题也可以理解成我们距离元宇宙的距离,东方证券张颖表示,元宇宙的内容短期将集中于游戏端与艺术端(NFT艺术藏品),长期来看,元宇宙的渗透路径预计将为“游戏/艺术-工作-生活”。


1)游戏端:以Roblox为代表


Roblox平台主要由三个产品构成:



  • 客户端:允许用户探索3D数字世界的应用程序。(面向用户);

  • 工作室:允许开发人员和创作者构建、发布和操作Roblox客户端访问的3D体验和其他内容的工具群。(面向开发者)

  • Roblox云:为共同体验平台提供动力的服务和基础设施。


Roblox的主要营收来源为用户的游戏内支出。玩家需要充值换取游戏中的代币Robux获取Roblox的各种功能,这也是Roblox的营收来源。


并且,Roblox的激励机制十分明确,除掉25%支付给APPStore的营收以及用于平台各种费用的营收(约26%),剩下约49%的营收基本由公司和开发者平分。



其实,游戏UGC平台的概念可以追溯至魔兽争霸3:魔兽争霸3WE地图编辑器支持开发者创造出许多RTS(即时战略游戏)、MOBA(多人在线战术竞技)的游戏地图和游戏类型。但魔兽3对于开发者的奖励机制缺失,导致整体商用化程度不高。



2)艺术端:NFT构建元宇宙经济基础


非同质化代币(NFT)具有不可互换性、独特性、不可分性、低兼容性以及物品属性。并且产品流通渠道单一,市场透明度、价格发现能力均有较高提升空间。


目前,多家互联网大厂正试水NFT领域:2021年6月,阿里巴巴发售支付宝付款码皮肤NFT,2021年8月,腾讯围绕NFT进行一系列战略布局。



3)工作端:Facebook与英伟达的布局


Infinite office是Facebook元宇宙战略中重要环节。2020年9月,Facebook宣布推出VR虚拟办公应用InfiniteOffice,支持用户们创建虚拟办公空间,提高工作效率。


英伟达推出了NVIDIA Omniverse,一个专为虚拟协作和物理属性准确的实时模拟打造的开放式平台。并且已经开始投入使用,宝马公司正在内部推进NVIDIAOmniverse平台,以协调全球31座工厂的生产。而根据英伟达官网披露的信息,NVIDIA Omniverse将宝马的生产规划效率提高30%。


4)生活端:面向体验场景


东方证券认为,元宇宙的未来在于探索其应用场景的共性。这些应用场景均需考量用户的体验,元宇宙未来的商业模式与智能手机类似,即通过体验感增加用户的使用时间,进而提高用户粘性。这些时间(体验)成为元宇宙中各项服务的基础。



五、元宇宙时代有哪些确定性趋势?


元宇宙主要的载体(基础设施)主要包括如下几部分


1)网络(通信)


5G作为具有高速率、低时延和大连接特点的新一代宽带移动通信技术,是实现人机物互联的网络基础设施。


2)芯片(算力)


元宇宙的内容、网络、区块链、图形显示等功能均需要更为强大的算力。


云端算力方面,DPU芯片(数据处理芯片)通过分流、加速和隔离各种高级网络、存储和安全服务,为云、数据中心或边缘等环境中的各种工作负载提供安全的加速基础设施。


终端算力方面,异构芯片可以让SoC中的CPU、GPU、FPGA、DPU、ASIC等芯片协同工作,不断提升算力以提升用户体验。



3)云与边缘计算


云计算与边缘计算为用户提供所需的计算资源,降低用户触达元宇宙的门槛。


4)AI


AI在元宇宙中应用渗透较广泛。AI可以帮助创建元宇宙资产、艺术品和其他内容(AIGC),并可以改进我们用来构建所有这些内容的软件和流程。


六、元宇宙在产业端如何发挥价值?


全球范围内许多互联网企业、工业软件企业就工业元宇宙的相关技术已有长期的布局,其中包括数字孪生、工业互联网、仿真测试、数字化工厂、CAD、CAE、EDA等工业软件。


1)英伟达:Omniverse平台


Omniverse的架构包括Connect、Nucleus、Kit、Simulation、RTXrenderer等五个部分,他们与第三方数字内容创建工具(DCC)以及基于Omniverse的微服务构成了Omniverse的生态。



黄仁勋在参加Computex2021线上会议表示未来虚拟世界与现实世界将产生交叉融合,元宇宙与NFT将在其中扮演重要角色。其中Omniverse平台主要面向建筑、工程和施工;制造业;媒体和娱乐以及超级计算场景。



根据英伟达官网对于Omniverse平台的介绍,通过Omniverse平台,用户可以完成实时虚拟协作、模拟现实的设计、模拟环境以及搭建未来工厂等操作。


2)微软:数字孪生探索


2021年9月,微软CEO Satya Nadella在Inspire2021演讲中提出全新“企业元宇宙”概念。微软的元宇宙计划中期望元宇宙可以打破现在的通信和业务流程之间的障碍,把他们融合在一起,让工业场景更为便捷。在宣布“企业元宇宙”概念之前,微软就已通过Azure数字孪生及AI等技术建立了工业元宇宙的底座。



Azure数字孪生是一个物联网(IoT)平台,可用于创建真实物品、地点、业务流程和人员的数字表示形式。通用电气航空的数字集团使用Azure数字孪生构建了一个实时和自动演变的模型,因此客户将随时可以访问其飞机的更新、准确和可用的数据模型。并且,通过内置数字可追溯性,可以实时记录每架飞机上每一个实物资产和部件。



3)能科股份:布局仿真与测试


能科股份主要业务包括智能制造、智能电气两个板块,其中公司智能制造业务基于数字孪生理念,整合业内先进工业软件和数字化IOT设备,虚拟世界内定义生产力中台并为客户开发个性化的工业微应用,物理世界内建立数字化、智能化的生产线和测试台,满足制造业企业产品全生命周期的数据与业务协同需求,帮助企业实现其自主创新、运营成本、生产效率、不良品率和客户满意度等业务目标。



4)阿里云:数字工厂“新基建”


根据德国工程师协会的定义,数字工厂(DF)是由数字化模型、方法和工具构成的综合网络,包含仿真和3D虚拟现实可视化,通过连续的没有中断的数据管理集成在一起。


阿里云工业互联网平台助力制造企业数字化转型,打造工厂内、供应链、产业平台全面协同的新基建,将工厂的设备、产线、产品、供应链、客户紧密地连接协同起来,为企业提供可靠的基础平台和上层丰富的工业应用,结合全面的产业支撑,助力企业完成数字化转型。



七、元宇宙是否需要去中心化?


1)个人信息可携带权


在个人信息可携带权的时代,用户成为关键参与者,由用户主动发起个人信息数据传输并自行上传,从而实践个人数据可携带权,去中心化成为必不可少的条件。


2)去中心化不等于没有中心、没有监管


在去中心化概念下,仍有较为高级的节点参与治理或运营,这与分布式架构完全舍弃中心的概念不同。在去中心化概念下,有效的监管和治理仍可存在。



3)去中心化如何践行?参考DAO


去中心化自治组织(DecentralizedAutonomousOrganization,DAO)是基于区块链核心思想理念,由达成同一个共识的群体自发产生的共创、共建、共治、共享的协同行为衍生出来的一种组织形态,是区块链解决信任问题后的附属产物。


DAO将组织的管理和运营规则以智能合约的形式编码在区块链上,从而在没有集中控制或第三方干预的情况下自主运行。DAO具有充分开放、自主交互、去中心化控制、复杂多样以及涌现等特点,可成为应对不确定、多样、复杂环境的有效组织。


八、元宇宙是否需要NFT?


1)什么是NFT?区块链的主流资产之一。


NFT代表不可替代的代币,是可以用来表示独特物品所有权的代币。NFT让艺术品、收藏品甚至房地产等事物标记化。他们一次只能拥有一个正式所有者,并且他们受到以太坊等区块链的保护,没有人可以修改所有权记录或复制/粘贴新的NFT。


NFT具有不可互换性、独特性、不可分性、低兼容性以及物品属性,可应用于流动性挖矿、艺术品交易、游戏/VR以及链下资产NFT化等场景,大幅提升数据流转效率。



2)NFT应用:一种潜在的元宇宙经济模式


NFT由于自身的数字稀缺性被率先运用于收藏、艺术品以及游戏场景。51aspx认为,NFT在元宇宙中将扮演关键角色。


九、元宇宙时代,互联网形态是否会发生变化?


51aspx认为,基于元宇宙的发展,互联网的协议可能发生改变,互联网会针对于打造可信化的数字底座进行演进。而区块链技术也在攻克自身的缺陷:交易吞吐量低、与外界沟通困难等。


未来会发展成什么样还是未知的,但是目前的理念技术还是很值得我们去探讨。关于相应的源码可以点击查看51aspx.com


文章系转载:wallstreetcn.com/articles/36…


设计排版:51aspx.com


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

掀起的“元宇宙”热潮,能给我们带来什么?

近日超火的“元宇宙”,被说成是互联网的未来,它以各种姿势吸金,让创业族们看到闪着金光的未来。然而,关于元宇宙究竟是什么? 小编相信大多数人都有听过这一热词,但是都不清楚它究竟是什么? 那么,元宇宙究竟是什么呢?为什么互联网科技和数字科技等各大巨头纷纷入局元宇宙...
继续阅读 »

近日超火的“元宇宙”,被说成是互联网的未来,它以各种姿势吸金,让创业族们看到闪着金光的未来。然而,关于元宇宙究竟是什么? 小编相信大多数人都有听过这一热词,但是都不清楚它究竟是什么?


那么,元宇宙究竟是什么呢?为什么互联网科技和数字科技等各大巨头纷纷入局元宇宙?


AR.png 


准确来说,元宇宙到目前为止,尚无公认的定义 ,因为元宇宙不是一个新的概念,它算是一个原有的经典概念的重组词,是在扩展现实(XR)、区块链、云计算、数字孪生等新技术下的概念具化。同时也是集互联网、物联网、大数据、5G等为一体的运用,是虚拟世界与现实世界的结合,元宇宙的运用在未来会有很大的发展空间。


比特币.png


 


继续深入发掘,会了解到元宇宙一词诞生于1992年出版的科幻小说《雪崩》,小说里提到了“Met aver se(元宇宙)”和“Avat ar(化身)”两个概念。在小说描绘的虚拟世界里,人们拥有自己的虚拟替身,这个虚拟世界就叫元宇宙。当然,关于元宇宙的争论还在继续,而且我们能够从不同的角度分析能够得出差异性极大的结论报告,但是关于元宇宙的特征情况已获得了业界的认可。


在今年8月份以来,元宇宙概念火遍全球,甚至日本社交巨头GREE宣布将开展元宇宙业务、英伟达发布会上出场了十几秒的“数字替身”、微软在Inspire全球合作伙伴大会上宣布了企业元宇宙解决方案等


但实际上,不仅有各大科技巨头在争相布局元宇宙赛道,同时还有一些国家的政府相关部门也积极参与其中。5月18日,韩国科学技术和信息通信部发起成立了以现代、SK集团、LG集团等200多家韩国本土企业和组织的名叫“元宇宙联盟”的组织,其目的是想要打造国家级别以及增强线下现实平台,并在未来向社会提供公共虚拟服务;今年都的7月13日,日本发布了《关于虚拟空间行业未来可能性与课题的调查报告》,总结了日本虚拟空间行业相关问题,同时也期望能够占据主导地位在全球虚拟空间行业里;今年8月31日,韩国财政部发布了下一年的预算情况报告,计划斥资2000万美元用于元宇宙平台开发。


以上种种报告显示,元宇宙深受科技巨头、政府部门的青睐,那么究竟是什么原因元宇宙可以做到“人见人爱”的这种现象呢?


从企业的角度来看,目前还处于初级阶段的元宇宙,无论是基础的技术还是基本的应用场景,与未来的成熟形态相比较还是有一定的差距,但这也意味着元宇宙相关产业可拓展的空间巨大。 因此,如果想要守住市场,数字科技领域初创企业要获得超速的机会,就要学会提前布局,甚至还要加强马力。


从政府层面来看,元宇宙不仅代表着重要的新兴产业,同时也是需要被重视的社会会治理领域。 伴随着元宇宙这一新兴产业的不断发展,随之而来的将会是一系列的革命性发展和挑战。


元宇宙.png 


同时,元宇宙也算一个具有极致开放的、复杂且巨大的系统,它涵盖了全部的网络空间、多个基础的硬件设备和最为基本的现实条件,是由多类型建设者共同构建的超大型数字科学技术的应用生态。


因此,我们再来从技术的角度来看,技术局限性是元宇宙目前发展的最大瓶颈,XR、区块链、人工智能等相应底层技术距离元宇宙落地应用的需求仍有较大差距。需要做大量的基础研究才能支撑元宇宙产业的成熟。对此,我们不仅要谨防元宇宙成为炒作噱头,还要鼓励各大相关的企业更进一步加强技术创新能力提高产业技术的成熟度。


BI“元宇宙”也是人们热议的话题,大数据有望最先受益于元宇宙放量,就以国内著名BI厂商Smartbi为例,Smartbi持续完善数据产业链,以“Smartb一站式大数据分析平台”为核心引擎,打造大数据运营管理能力,拓展场景应用,加速企业数据化转型。


数据分析.png


数据可视化,简单来说就是将一种将相对复杂的表达形式、抽象的数据通过可视化转化为更容易理解的图形显示的一种形式。数据可视化可以更为生动形象地展现出数据所表达的内在价值,同时也十分方便企业利用数据智能更好地开展业务。 关于数据可视化,Smartbi支持完整的ECharts图库及多种图形,包括瀑布、热力图、树图等数l十种动态交互的图形,可视化功能十分地灵活,同时页面也展现的十分清晰明了,该功能也深受客户喜爱。


数据安全.png


 


数据安全是每个企业的重中之重,我们这里说的数据安全是狭义的,指的是采用现代信息存储手段对数据进行主动防护。 Smartbi在数据的收集、存储、使用、加工、传输、公开等各个环节,都提供了安全、可控的保护手段。其安全管理体系就是通过对多种权限进行控制,从而保障了数据资源的使用安全。另外还提供了定期备份、水印设置、分享设置等功能,大大降低了数据破坏、外泄的风险。


最后,我们可以肯定的是,在技术的不断提高进步和人类不断增多的生活需求的共同推动下,元宇宙场景的实现,元宇宙产业的成熟,只是一个时间问题。它对于我们之后的发展,无论是经济方面还是其他方面都是有着巨大的机遇和革命性作用


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

背包问题_概述(动态规划)

写在前 问题描述 注意:0-1 背包问题无法使用贪心算法来求解,也就是说不能按照先添加性价比最高的物品来达到最优,这是因为这种方式可能造成背包空间的浪费,从而无法达到最优。基本思路 第 i 件物品没添加到背包,最大价值:dp[i][j] = dp[i - 1]...
继续阅读 »




写在前

问题描述

有N件物品和一个最多能被重量为W 的背包。一个物品只有两个属性:重量和价值。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

注意:0-1 背包问题无法使用贪心算法来求解,也就是说不能按照先添加性价比最高的物品来达到最优,这是因为这种方式可能造成背包空间的浪费,从而无法达到最优。

基本思路

这里有两个可变量体积和价值,我们定义dp[i][j]表示前i件物品体积不超过j能达到的最大价值,设第 i 件物品体积为 w,价值为 v,根据第 i 件物品是否添加到背包中,可以分两种情况讨论:

  • 第 i 件物品没添加到背包,最大价值:dp[i][j] = dp[i - 1][j]

  • 第 i 件物品添加到背包中:dp[i][j] = dp[i - 1][j - w] + v

第 i 件物品可添加也可以不添加,取决于哪种情况下最大价值更大。因此,0-1 背包的状态转移方程为: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w] + v)

代码实现

// W 为背包总重量
// N 为物品数量
// weights 数组存储 N 个物品的重量
// values 数组存储 N 个物品的价值
public int knapsack(int W, int N, int[] weights, int[] values) {
   // dp[i][0]和dp[0][j]没有价值已经初始化0
   int[][] dp = new int[N + 1][W + 1];
   // 从dp[1][1]开始遍历填表
   for (int i = 1; i <= N; ++i) {
       // 第i件物品的重量和价值
       int w = weights[i - 1], v = values[i - 1];
       for (int j = 1; j <= W; ++j) {
           if (j < w) {
               // 超过当前状态能装下的重量j
               dp[i][j] = dp[i - 1][j];
          } else {
               dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i]] + values[i]);
          }
      }
  }
   return dp[N][W];
}

dp[i][j]的值只与dp[i-1][0,...,j-1]有关,所以我们可以采用动态规划常用的方法(滚动数组)对空间进行优化(即去掉dp的第一维)。因此,0-1 背包的状态转移方程为: dp[j] = max(dp[j], dp[j - w] + v)

特别注意:为了防止上一层循环的dp[0,...,j-1]被覆盖,循环的时候 j 只能逆向遍历。优化空间复杂度:

ps:滚动数组:即让数组滚动起来,每次都使用固定的几个存储空间,来达到压缩,节省存储空间的作用。

public int knapsack(int W, int N, int[] weights, int[] values) {
   int[] dp = new int[W + 1];
   for (int i = 1; i <= N; i++) {
       int w = weights[i - 1], v = values[i - 1];
       for (int j = W; j >= 1; --j) {
           if (j >= w) {
               dp[j] = Math.max(dp[j], dp[j - w] + v);
          }
      }
  }
   return dp[W];
}

ps:01背包内循环理解:还原成二维的dp就很好理解,一维的dp是二维dp在空间上进行复用的结果。dp[i]=f(dp[i-num]),等式的右边其实是二维dp上一行的数据,应该是只读的,在被读取前不应该被修改。如果正序的话,靠后的元素在读取前右边的dp有可能被修改了,倒序可以避免读取前被修改的问题。

作者:_code_x
来源:https://www.jianshu.com/p/b789ec845641

收起阅读 »

Android端小到不行的分页加载库

RecyclerView几乎在每个app里面都有被使用,但凡使用了列表就会采用分页加载进行数据请求和加载。android 官方也推出了分页库,但是感觉只有kotlin一起使用才能体会到酸爽。Java 版本的也有很多很强大的第三方库,BaseRecyclerVi...
继续阅读 »

RecyclerView几乎在每个app里面都有被使用,但凡使用了列表就会采用分页加载进行数据请求和加载。android 官方也推出了分页库,但是感觉只有kotlin一起使用才能体会到酸爽。Java 版本的也有很多很强大的第三方库,BaseRecyclerViewAdapterHelper这个库是我用起来最顺手的分页库,里面也包含了各式各样强大的功能:分组、拖动排序、动画,因为功能强大,代码量也相对比较大。 但是很多时候我们想要的就是分页加载,所以参照BaseRecyclerViewAdapterHelper写下了这个分页加载库,只有分页功能。(可以说照搬,也可以说精简,但是其中也加入个人理解)。
这个库相对BaseRecyclerViewAdapterHelper只有两个优点:

  • 代码量小

  • BaseRecyclerViewAdapterHelper 在数据不满一屏时仍然显示加载更多以及页面初始化时都会显示loadmoewView(虽然提供了api进行隐藏,但是看了很长时间注释和文档都没了解该怎么使用),而这个库在初次加载和不满一屏数据时不会显示loadmoreView

gradle引用

implementation 'com.maxcion:pageloadadapter:1.0.0'

项目地址:https://github.com/Likeyong/PageLoadAdapter

如果看不到gif,就到掘金吧 https://juejin.cn/post/6944242618658193415

单列分页加载

混合布局分页加载

Recyclerview 多type分页加载

单列分页加载

//一定要在PageLoadRecyclerVewAdapter<String> 的泛型参数里面指定数据源item格式
public class SimpleAdapter extends PageLoadRecyclerVewAdapter<String> {
   public SimpleAdapter(List<String> dataList) {
       super(dataList);
  }

   //这里进行 数据绑定
   @Override
   protected void convert(BaseViewHolder holder, String item) {
       holder.setText(R.id.text, item);
  }

   //这里返回布局item id
   @Override
   protected int getItemLayoutId() {
       return R.layout.item_simple;
  }
}

第一步 adapter实现好了,现在需要打开adapter的分页加载功能

public class SingleColumnActivity extends BaseActivity<String> implements IOnLoadMoreListener {


   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_single_column);
       RecyclerView rv = findViewById(R.id.rv);
       //实例化adapter
       mAdapter = new SimpleAdapter(null);
       //给adapter 设置loadmoreview
       mAdapter.setLoadMoreView(new CommonLoadMoreView());
       //设置滑动到底部时进行更多加载的回调
       mAdapter.setOnLoadMoreListener(this);
       rv.setAdapter(mAdapter);
       rv.setLayoutManager(new LinearLayoutManager(this));
       request();
  }



   @Override
   public void onLoadMoreRequested() {

       request();
  }

   //这个函数不用管
   @Override
   protected List<String> convertRequestData(List<String> originData) {
       return originData;
  }


}

第二步,RecyclerView也打开了分页加载功能,第三部就是根据接口返回的数据判断到底是 加载失败了、加成成功了还是加载结束(没有更多数据需要加载)

protected void request() {
       NetWorkRequest.request(mAdapter.getDataSize() / PAGE_SIZE + 1, mFailCount, new NetWorkRequest.Callback() {
           @Override
           public void onSuccess(List<String> result) {
               List<T> finalResult = convertRequestData(result);
               if(result.size() >= PAGE_SIZE){// 接口返回了满满一页的数据,这里数据加载成功
                   if (mAdapter.getDataSize() == 0){
                       //当前列表里面没有数据,代表是初次请求,所以这里使用setNewData()

                       mAdapter.setNewData(finalResult);
                  }else {
                       //列表里面已经有数据了,这里使用addDataList(),将数据添加到列表后面
                       mAdapter.addDataList(finalResult);
                  }
                   //这里调用adapter。loadMoreComplete(true) 函数通知列表刷新footview, 这里参数一定要传true
                   mAdapter.loadMoreComplete(true);
              }else {
                   //如果接口返回的数据不足一页,也就代表没有足够的数据了,那么也就没有下一页数据,所以这里
                   //认定分页加载结束
                   //这里的参数也一定要传true
                   mAdapter.loadMoreEnd(true);
              }
          }

           @Override
           public void onFail() {
               mFailCount++;
               //请求失败 通知recyclerview 刷新footview 状态
               mAdapter.loadMoreFail(true);
          }
      });
  }

上面是我写的模拟接口请求,不用在意其他代码,只要关注onSuccess 和onFail 两个回调里面的逻辑。

混合布局的支持

在电商行业经常能看到商品列表中,同一个列表,有的商品占满整整一行,有的一行显示2-3个商品。这种实现方案就是通过GridLayoutManager 的SpanSizeLookup 来控制每个item占几列的。

RecyclerView rv = findViewById(R.id.rv);
       mAdapter = new SimpleAdapter(null);
       mAdapter.setLoadMoreView(new CommonLoadMoreView());
       mAdapter.setOnLoadMoreListener(this);
     //这里我们将列表设置最多两列
       GridLayoutManager layoutManager = new GridLayoutManager(this, 2);
       layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
           @Override
           public int getSpanSize(int position) {
             //根据position 设置每个item应该占几列
             //如果当前的position是3的整数倍 我们就让他占满2列,其他的只占1列
               return position % 3 == 0 ? 2 : 1 ;
          }
      });
       rv.setLayoutManager(layoutManager);
       rv.setAdapter(mAdapter);

RecyclerView多Type支持

如果要使用多type, 在写Adapter的时候要继承PageLoadMultiRecyclerViewAdapter<T, BaseViewHolder>,其中T 是数据源item类型,这个类型必须实现 IMultiItem 接口,并在getItemType()函数中返回当前item对应的type

public class MultiPageLoadAdapter extends PageLoadMultiRecyclerViewAdapter<MultiData, BaseViewHolder> {
   public MultiPageLoadAdapter(List<MultiData> dataList) {
       super(dataList);
       //构造函数里面将 每种type 和 type 对应的布局进行绑定
       addItemLayout(MultiData.TYPE_TEXT, R.layout.item_simple);
       addItemLayout(MultiData.TYPE_IMAGE, R.layout.item_multi_image);
       addItemLayout(MultiData.TYPE_VIDEO, R.layout.item_multi_video);
  }

   @Override
   protected void convert(BaseViewHolder holder, MultiData item) {
       //在convert中针对不同的type 进行不同的bind逻辑
       switch (holder.getItemViewType()){
           case MultiData.TYPE_VIDEO:
               holder.setText(R.id.text, item.content);
               break;

           case MultiData.TYPE_IMAGE:
               holder.setText(R.id.text, item.content);
               break;

           case MultiData.TYPE_TEXT:
               holder.setText(R.id.text, item.content);
           default:
               break;
      }
  }
}

引入方式也和上面两种方式一样

RecyclerView recyclerView = findViewById(R.id.rv);
       mAdapter = new MultiPageLoadAdapter(null);
       mAdapter.setLoadMoreView(new CommonLoadMoreView());
       mAdapter.setOnLoadMoreListener(this);
       recyclerView.setLayoutManager(new LinearLayoutManager(this));
       recyclerView.setAdapter(mAdapter);

作者:maxcion
来源:https://www.jianshu.com/p/aa3054b4d03c

收起阅读 »

12个有用的JavaScript数组技巧

数组是Javascript最常见的概念之一,它为我们提供了处理数据的许多可能性,熟悉数组的一些常用操作是很有必要的。1、数组去重1、from()叠加new Set()方法字符串或数值型数组的去重可以直接使用from方法。var plants = ['Satur...
继续阅读 »

数组是Javascript最常见的概念之一,它为我们提供了处理数据的许多可能性,熟悉数组的一些常用操作是很有必要的。

1、数组去重

1、from()叠加new Set()方法

字符串或数值型数组的去重可以直接使用from方法。

var plants = ['Saturn', 'Earth', 'Uranus', 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter'];
var uniquePlants = Array.from(new Set(plants));
console.log(uniquePlants); // [ 'Saturn', 'Earth', 'Uranus', 'Mercury', 'Venus', 'Mars', 'Jupiter' ]

2、spread操作符(…)

扩展运算符是ES6的一大创新,还有很多强大的功能。

var plants = ['Saturn', 'Earth', 'Uranus', 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter'];
var uniquePlants = [...new Set(plants)];
console.log(uniquePlants); // [ 'Saturn', 'Earth', 'Uranus', 'Mercury', 'Venus', 'Mars', 'Jupiter' ]

2、替换数组中的特定值

splice() 方法向/从数组中添加/删除项目,然后返回被删除的项目。该方法会改变原始数组。特别需要注意插入值的位置!

// arrayObject.splice(index,howmany,item1,.....,itemX)

var plants = ['Saturn', 'Uranus', 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter'];
var result = plants.splice(2, 1, 'www.shanzhonglei.com')
console.log(plants); // ['Saturn','Uranus','www.shanzhonglei.com','Mercury','Venus','Earth','Mars','Jupiter']
console.log(result); // ['Mercury']

3、没有map()的映射数组

我们先介绍一下map方法。map()方法返回一个新数组,数组中的元素为原始数组元素调用函数处理后的值,它会按照原始数组元素顺序依次处理元素。注意: map()不会改变原始数组,也不会对空数组进行检测。
下面我们来实现一个没有map的数组映射:

// array.map(function(currentValue,index,arr), thisValue)

var plants = [
  { name: "Saturn" },
  { name: "Uranus" },
  { name: "Mercury" },
  { name: "Venus" },
]
var plantsName = Array.from(plants, ({ name }) => name);
console.log(plantsName); // [ 'Saturn', 'Uranus', 'Mercury', 'Venus' ]

4、空数组

如果要清空一个数组,将数组的长度设置为0即可,额,这个有点简单。

var plants = ['Saturn', 'Earth', 'Uranus', 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter'];
plants.length = 0;
console.log(plants); // []

5、将数组转换为对象

如果要将数组转换为对象,最快的方法莫过于spread运算符(…)。

var plants = ['Saturn', 'Earth', 'Uranus', 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter'];
var plantsObj = {...plants }
console.log(plantsObj); // {'0': 'Saturn','1': 'Earth', '2': 'Uranus','3': 'Mercury','4': 'Venus','5': 'Earth','6': 'Mars','7': 'Jupiter'}

6、用数据填充数组

如果我们需要用一些数据来填充数组,或者需要一个具有相同值的数据,我们可以用fill()方法。

var plants = new Array(8).fill('8');
console.log(plants); // ['8', '8', '8','8', '8', '8','8', '8']

7、合并数组

当然你会想到concat()方法,但是哦,spread操作符(…)也很香的,这也是扩展运算符的另一个应用。

var plants1 = ['Saturn', 'Earth', 'Uranus', 'Mercury'];
var plants2 = ['Venus', 'Earth', 'Mars', 'Jupiter'];
console.log([...plants1, ...plants2]); // ['Saturn', 'Earth','Uranus', 'Mercury','Venus', 'Earth','Mars', 'Jupiter']

8、两个数组的交集

要求两个数组的交集,首先确保数组不重复,然后使用filter()方法和includes()方法。

var plants1 = ['Saturn', 'Earth', 'Uranus', 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter'];
var plants2 = ['Saturn', 'Earth', 'Uranus'];
var alonePlants = [...new Set(plants1)].filter(item => plants2.includes(item));
console.log(alonePlants); // [ 'Saturn', 'Earth', 'Uranus' ]

9、删除数组中的假值

我们时常需要在处理数据的时候要去掉假值。在Javascript中,假值是false, 0, " ", null, NaN, undefined。

var plants = ['Saturn', 'Earth', null, undefined, false, "", NaN, 'Uranus', 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter'];
var trueArr = plants.filter(Boolean);
console.log(trueArr); // ['Saturn', 'Earth','Uranus', 'Mercury','Venus', 'Earth','Mars', 'Jupiter']

10、获取数组中的随机值

我们可以根据数组长度获得一个随机索引号。

var plants = ['Saturn', 'Earth', 'Uranus', 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter'];
console.log(plants[Math.floor(Math.random() * (plants.length + 1))])

11、lastIndexOf()方法

lastIndexOf()可以帮助我们查找元素最后一次出现的索引。

var plants = ['Saturn', 'Earth', 'Uranus', 'Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter'];
console.log(plants.lastIndexOf('Earth')) // 5

12、将数组中的所有值相加

reduce()方法接收一个函数作为累加器,数组中的每个值(从左到右)开始缩减,最终计算为一个值。

// array.reduce(function(total, currentValue, currentIndex, arr), initialValue)

var nums = [1, 2, 3, 4, 5];
var sum = nums.reduce((x, y) => x + y);
console.log(sum); // 15

作者:前端技术驿站
来源:https://www.jianshu.com/p/651338c88bb4

收起阅读 »

字节面试被虐后,是时候搞懂 DNS 了

前几天面了字节 👦🏻:“浏览器从输入URL到显示页面发生了什么?” 👧🏻:%^&@#^&(这我怎么可能没有准备?从网络到渲染说了一通后) 👦🏻:“你刚刚提到了 DNS,那说说 DNS 的查询过程吧” 👧🏻:“DNS 查询是一个递归 + 迭代的...
继续阅读 »

前几天面了字节



👦🏻:“浏览器从输入URL到显示页面发生了什么?”


👧🏻:%^&@#^&(这我怎么可能没有准备?从网络到渲染说了一通后)


👦🏻:“你刚刚提到了 DNS,那说说 DNS 的查询过程吧”


👧🏻:“DNS 查询是一个递归 + 迭代的过程...”


👦🏻:“那具体的递归和迭代过程是怎样的呢?”


👧🏻:“...”



当时我脑子里有个大概的过程,但是细节就记不起来了,所以今天就来梳理一下 DNS 相关的内容,如有不妥之处,还望大家指出。


什么是 DNS


DNS 即域名系统,全称是 Domain Name System。当我们在浏览器输入一个 URL 地址时,浏览器要向这个 URL 的主机名对应的服务器发送请求,就得知道服务器的 IP,对于浏览器来说,DNS 的作用就是将主机名转换成 IP 地址。下面是摘自《计算机网络:自顶向下方法》的概念:



DNS 是:



  1. 一个由分层的 DNS 服务器实现的分布式数据库

  2. 一个使得主机能够查询分布式数据库的应用层协议



也就是,DNS 是一个应用层协议,我们发送一个请求,其中包含我们要查询的主机名,它就会给我们返回这个主机名对应的 IP;


其次,DNS 是一个分布式数据库,整个 DNS 系统由分散在世界各地的很多台 DNS 服务器组成,每台 DNS 服务器上都保存了一些数据,这些数据可以让我们最终查到主机名对应的 IP。


所以 DNS 的查询过程,说白了,就是去向这些 DNS 服务器询问,你知道这个主机名的 IP 是多少吗,不知道?那你知道去哪台 DNS 服务器上可以查到吗?直到查到我想要的 IP 为止。


分布式、层次数据库


什么是分布式?

这个世界上没有一台 DNS 服务器拥有因特网上所有主机的映射,每台 DNS 只负责部分映射。


什么是层次?

DNS 服务器有 3 种类型:根 DNS 服务器、顶级域(Top-Level Domain, TLD)DNS 服务器和权威 DNS 服务器。它们的层次结构如下图所示:



DNS 的层次结构.jpeg



图片来源:《计算机网络:自顶向下方法》



  • 根 DNS 服务器


首先我们要明确根域名是什么,比如 http://www.baidu.com,有些同学可能会误以为 com 就是根域名,其实 com 是顶级域名,http://www.baidu.com 的完整写法是 http://www.baidu.com.,最后的这个 . 就是根域名。


根 DNS 服务器的作用是什么呢?就是管理它的下一级,也就是顶级域 DNS 服务器。通过询问根 DNS 服务器,我们可以知道一个主机名对应的顶级域 DNS 服务器的 IP 是多少,从而继续向顶级域 DNS 服务器发起查询请求。



  • 顶级域 DNS 服务器


除了前面提到的 com 是顶级域名,常见的顶级域名还有 cnorgedu 等。顶级域 DNS 服务器,也就是 TLD,提供了它的下一级,也就是权威 DNS 服务器的 IP 地址。



  • 权威 DNS 服务器


权威 DNS 服务器可以返回主机 - IP 的最终映射。


关于这几个层次的服务器之间是怎么交互的,接下来我们会讲到 DNS 具体的查询过程,结合查询过程,大家就不难理解它们之间的关系了。


本地 DNS 服务器


之前对 DNS 有过了解的同学可能会发现,上一节的 DNS 层次结构,为什么没有提到本地 DNS 服务器?因为严格来说,本地 DNS 服务器并不属于 DNS 的层次结构,但它对 DNS 层次结构是至关重要的。那什么是本地 DNS 服务器呢?


每个 ISP 都有一台本地 DNS 服务器,比如一个居民区的 ISP、一个大学的 ISP、一个机构的 ISP,都有一台或多台本地 DNS 服务器。当主机发出 DNS 请求时,该请求被发往本地 DNS 服务器,本地 DNS 服务器起着代理的作用,并负责将该请求转发到 DNS 服务器层次结构中。


接下来就让我们通过一个简单的例子,看看 DNS 的查询过程是怎样的,看看客户端、本地 DNS 服务器、DNS 服务器层次结构之间是如何交互的。


递归查询、迭代查询


如下图,假设主机 m.n.com 想要获取主机 a.b.com 的 IP 地址,会经过以下几个步骤:



DNS.png





  1. 首先,主机 m.n.com 向它的本地 DNS 服务器发送一个 DNS 查询报文,其中包含期待被转换的主机名 a.b.com




  2. 本地 DNS 服务器将该报文转发到根 DNS 服务器;




  3. 该根 DNS 服务器注意到 com 前缀,便向本地 DNS 服务器返回 com 对应的顶级域 DNS 服务器(TLD)的 IP 地址列表。


    意思就是,我不知道 a.b.com 的 IP,不过这些 TLD 服务器可能知道,你去问他们吧;




  4. 本地 DNS 服务器则向其中一台 TLD 服务器发送查询报文;




  5. 该 TLD 服务器注意到 b.com 前缀,便向本地 DNS 服务器返回权威 DNS 服务器的 IP 地址。


    意思就是,我不知道 a.b.com 的 IP,不过这些权威服务器可能知道,你去问他们吧;




  6. 本地 DNS 服务器又向其中一台权威服务器发送查询报文;




  7. 终于,该权威服务器返回了 a.b.com 的 IP 地址;




  8. 本地 DNS 服务器将 a.b.com 跟 IP 地址的映射返回给主机 m.n.comm.n.com 就可以用该 IP 向 a.b.com 发送请求啦。





bqb4.jpeg



“你说了这么多,递归呢?迭代呢?”


这位同学不要捉急,其实递归和迭代已经包含在上述过程里了。


主机 m.n.com 向本地 DNS 服务器 dns.n.com 发出的查询就是递归查询,这个查询是主机 m.n.com 以自己的名义向本地 DNS 服务器请求想要的 IP 映射,并且本地 DNS 服务器直接返回映射结果给到主机。


而后继的三个查询是迭代查询,包括本地 DNS 服务器向根 DNS 服务器发送查询请求、本地 DNS 服务器向 TLD 服务器发送查询请求、本地 DNS 服务器向权威 DNS 服务器发送查询请求,所有的请求都是由本地 DNS 服务器发出,所有的响应都是直接返回给本地 DNS 服务器


那么问题来了,所有的 DNS 查询都必须遵循这种递归 + 迭代的模式吗?


当然不是。


从理论上讲,任何 DNS 查询既可以是递归的,也可以是迭代的。下图的所有查询就都是递归的,不包含迭代。



DNS2.png



看到这里,大家可能会有个疑问,TLD 一定知道权威 DNS 服务器的 IP 地址吗?


emmm...



bqb7.png



还真不一定,有时 TLD 只是知道中间的某个 DNS 服务器,再由这个中间 DNS 服务器去找到权威 DNS 服务器。这种时候,整个查询过程就需要更多的 DNS 报文。


DNS 缓存


为了让我们更快的拿到想要的 IP,DNS 广泛使用了缓存技术。DNS 缓存的原理非常简单,在一个 DNS 查询的过程中,当某一台 DNS 服务器接收到一个 DNS 应答(例如,包含某主机名到 IP 地址的映射)时,它就能够将映射缓存到本地,下次查询就可以直接用缓存里的内容。当然,缓存并不是永久的,每一条映射记录都有一个对应的生存时间,一旦过了生存时间,这条记录就应该从缓存移出。


事实上,有了缓存,大多数 DNS 查询都绕过了根 DNS 服务器,需要向根 DNS 服务器发起查询的请求很少。


面试感想


这次面试收获还蛮大的,有些东西以为自己懂了,以为自己能说清楚,但到了真的要说的时候,又没有办法完整地梳理出来,描述起来磕磕绊绊,在面试中会很减分。


所以不要偷懒,不要抱有侥幸心理,踏实学。共勉。



作者:我是陆小北
链接:https://juejin.cn/post/6990344840181940261

收起阅读 »

H5页面中调用微信和支付宝支付

最近在工作中,有个H5页面需要实现微信支付和支付宝支付的功能,现在已经完成,抽个时间写出来,分享给有需要的人。 第一步:先判断当前环境 判断用户所属环境,根据环境不同,执行不同的支付程序。 if (/MicroMessenger/.test(window.na...
继续阅读 »

最近在工作中,有个H5页面需要实现微信支付和支付宝支付的功能,现在已经完成,抽个时间写出来,分享给有需要的人。


第一步:先判断当前环境


判断用户所属环境,根据环境不同,执行不同的支付程序。


if (/MicroMessenger/.test(window.navigator.userAgent)) {
// alert('微信');
} else if (/AlipayClient/.test(window.navigator.userAgent)) {
//alert('支付宝');
} else {
//alert('其他浏览器');
}

第二步:如果是微信环境,需要先进行网页授权


网页授权的详细介绍可以查看微信相关文档。这里不做介绍。


第三步:


1、微信支付


微信支付有两种方法:
1:调用微信浏览器提供的内置接口WeixinJSBridge
2:引入微信jssdk,使用wx.chooseWXPay方法,需要先通过config接口注入权限验证配置。
我这里使用的是第一种,在从后台拿到签名、时间戳这些数据后,直接调用微信浏览器提供的内置接口WeixinJSBridge即可完成支付功能。


getRequestPayment(data) {
function onBridgeReady() {
WeixinJSBridge.invoke(
"getBrandWCPayRequest", {
"appId": data.appId, //公众号ID,由商户传入
"timeStamp": data.timeStamp, //时间戳,自1970年以来的秒数
"nonceStr": data.nonceStr, //随机串
"package": data.package,
"signType": data.signType, //微信签名方式:
"paySign": data.paySign //微信签名
},
function(res) {
alert(JSON.stringify(res));
// get_brand_wcpay_request
if (res.err_msg == "get_brand_wcpay_request:ok") {
// 使用以上方式判断前端返回,微信团队郑重提示:
//res.err_msg将在用户支付成功后返回ok,但并不保证它绝对可靠。
}
}
);
}
if (typeof WeixinJSBridge == "undefined") {
if (document.addEventListener) {
document.addEventListener(
"WeixinJSBridgeReady",
onBridgeReady,
false
);
} else if (document.attachEvent) {
document.attachEvent("WeixinJSBridgeReady", onBridgeReady);
document.attachEvent("onWeixinJSBridgeReady", onBridgeReady);
}
} else {
onBridgeReady();
}
},

2、支付宝支付


支付宝支付相对于微信来说,前端这块工作更简单 ,后台会返回给前端一个form表单,我们要做的就是把这个表单进行提交即可。相关代码如下:


this.$api.alipayPay(data).then((res) => {
// console.log('支付宝参数', res.data)
if (res.code == 200) {
var resData =res.data
const div = document.createElement('div')
div.id = 'alipay'
div.innerHTML = resData
document.body.appendChild(div)
document.querySelector('#alipay').children[0].submit() // 执行后会唤起支付宝
}

}).catch((err) => {
})

作者:故友
链接:https://juejin.cn/post/7034033584684204068

收起阅读 »

Android系统启动-Zygote进程

本篇文章基于Android6.0源码分析 相关源码文件: /system/core/rootdir/init.rc /system/core/rootdir/init.zygote64.rc /frameworks/base/cmds/app_proces...
继续阅读 »

本篇文章基于Android6.0源码分析



相关源码文件:


/system/core/rootdir/init.rc
/system/core/rootdir/init.zygote64.rc

/frameworks/base/cmds/app_process/App_main.cpp
/frameworks/base/core/jni/AndroidRuntime.cpp

/frameworks/base/core/java/com/android/internal/os/
- ZygoteInit.java
- Zygote.java
- ZygoteConnection.java

/frameworks/base/core/java/android/net/LocalServerSocket.java
/system/core/libutils/Threads.cpp

Zygote进程启动前的概述


通过init.rc的文件解析会启动zygote相关的服务从而启动zygote进程。通过import导入决定启动哪种类型的zygote服务脚本,这里分为32位和64位架构的zygote服务脚本


import /init.${ro.zygote}.rc

在/system/core/rootdir目录中有四个zygote相关的服务脚本


init.zygote32.rc // 支持32位的zygote
init.zygote32_64.rc // 即支持32位也支持64位的zygote,其中以32位为主,64位为辅
init.zygote64.rc // 支持64位的zygote
init.zygote64_32.rc // 即支持64位也支持32位的zygote,其中以64位为主,32位为辅


下面我们分析只分析64位的zygote服务脚本的Android初始化语言:


service zygote /system/bin/app_process64 -Xzygote /system/bin --zygote --start-system-server
class main
socket zygote stream 660 root system
onrestart write /sys/android_power/request_state wake
onrestart write /sys/power/state on
onrestart restart media
onrestart restart netd
writepid /dev/cpuset/foreground/tasks


zygote进程的执行程序为/system/bin/app_process64中,其中参数为:-Xzygote /system/bin --zygote --start-system-server,classname为main 。除了在Init进程解析时创建Zygote进程,在servicemanager、surfaceflinger、systemserver进程被杀时Zygote进程也会进行重启。


其中/system/bin/app_process64的映射的执行文件为:/frameworks/base/cmds/app_process/app_main.cpp


Zygote进程启动


image.png
如图1所示,zygote进程启动时会先启动app_main类的main()方法:


  // 参数argv为 :  -Xzygote /system/bin --zygote --start-system-server
int main(int argc, char* const argv[])
{
// 创建一个AppRuntime实例,AppRuntime 继承 AndoirdRuntime
AppRuntime runtime (argv[0], computeArgBlockSize(argc, argv));
//忽略第一个参数
argc--;
argv++;


// 解析参数并对变量赋值
bool zygote = false;
bool startSystemServer = false;
bool application = false;
String8 niceName;
String8 className;

++i; // Skip unused "parent dir" argument.
while (i < argc) {
const char * arg = argv [i++];
if (strcmp(arg, "--zygote") == 0) {
// 参数中有--zygote
zygote = true;
niceName = ZYGOTE_NICE_NAME;
} else if (strcmp(arg, "--start-system-server") == 0) {
// 参数中有--start-system-server
startSystemServer = true;
} else if (strcmp(arg, "--application") == 0) {
application = true;
} else if (strncmp(arg, "--nice-name=", 12) == 0) {
niceName.setTo(arg + 12);
} else if (strncmp(arg, "--", 2) != 0) {
className.setTo(arg);
break;
} else {
--i;
break;
}
}


if (zygote) {
//如果zygote为true,则调用AndroidRuntime的start方法,并传入了"com.android.internal.os.ZygoteInit"参数
runtime.start("com.android.internal.os.ZygoteInit", args, zygote);
} else if (className) {
runtime.start("com.android.internal.os.RuntimeInit", args, zygote);
} else {
fprintf(stderr, "Error: no class name or --zygote supplied.\n");
app_usage();
LOG_ALWAYS_FATAL("app_process: no class name or --zygote supplied.");
return 10;
}
}

在app_main的mian()方法中,主要是根据zygote的脚本的参数进行解析,在解析到有--zygote字符后,则确定执行AndroidRuntime.start方法,并且第一个参数传为com.android.internal.os.ZygoteInit


AndroidRuntime.start()


在此方法中,主要做了三件事:
· 创建虚拟机实例
· JNI方法的注册
· 调用参数的main()方法


  // 这里的className为:com.android.internal.os.ZygoteInit
void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote)
{

/* start the virtual machine */
JniInvocation jni_invocation;
jni_invocation.Init(NULL);
JNIEnv * env;
// 1. 创建虚拟机
if (startVm(& mJavaVM, &env, zygote) != 0) {
return;
}
onVmCreated(env);
// 2. JNI方法注册
if (startReg(env) < 0) {
ALOGE("Unable to register all android natives\n");
return;
}

// 解析classname参数
//将"com.android.internal.os.ZygoteInit"转换为"com/android/internal/os/ZygoteInit"
char * slashClassName = toSlashClassName(className);
jclass startClass = env->FindClass(slashClassName);

if (startClass == NULL) {
} else {
// 得到ZygoteInit的main方法
jmethodID startMeth = env->GetStaticMethodID(startClass, "main",
"([Ljava/lang/String;)V");
if (startMeth == NULL) {
} else { env ->
// 3. 执行ZygoteInit的main方法
CallStaticVoidMethod(startClass, startMeth, strArray);
}
}
free(slashClassName);

}

对start方法进行了一些删减后,主要是通过startVm 创建虚拟机,通过startReg(env)进行JNI方法注册,最后解析className参数,去执行ZygoteInit.main方法。下面将逐一分析这三种状态。


1. 创建虚拟机实例


startVm


int AndroidRuntime::startVm(JavaVM** pJavaVM, JNIEnv** pEnv, bool zygote)
{
// ...
// JNI检测功能
bool checkJni = false;
property_get("dalvik.vm.checkjni", propBuf, "");
if (strcmp(propBuf, "true") == 0) {
checkJni = true;
} else if (strcmp(propBuf, "false") != 0) {
/* property is neither true nor false; fall back on kernel parameter */
property_get("ro.kernel.android.checkjni", propBuf, "");
if (propBuf[0] == '1') {
checkJni = true;
}
}
ALOGD("CheckJNI is %s\n", checkJni ? "ON" : "OFF");
if (checkJni) {
addOption("-Xcheck:jni");
}

// /虚拟机产生的trace文件,主要用于分析系统问题,路径默认为/data/anr/traces.txt
parseRuntimeOption("dalvik.vm.stack-trace-file", stackTraceFileBuf, "-Xstacktracefile:");

//对于不同的软硬件环境,这些参数往往需要调整、优化,从而使系统达到最佳性能
parseRuntimeOption("dalvik.vm.heapstartsize", heapstartsizeOptsBuf, "-Xms", "4m");
parseRuntimeOption("dalvik.vm.heapsize", heapsizeOptsBuf, "-Xmx", "16m");

parseRuntimeOption(
"dalvik.vm.heapgrowthlimit",
heapgrowthlimitOptsBuf,
"-XX:HeapGrowthLimit="
);
parseRuntimeOption("dalvik.vm.heapminfree", heapminfreeOptsBuf, "-XX:HeapMinFree=");
parseRuntimeOption("dalvik.vm.heapmaxfree", heapmaxfreeOptsBuf, "-XX:HeapMaxFree=");
parseRuntimeOption(
"dalvik.vm.heaptargetutilization",
heaptargetutilizationOptsBuf,
"-XX:HeapTargetUtilization="
);
// ...
// 初始化虚拟机
if (JNI_CreateJavaVM(pJavaVM, pEnv, & initArgs) < 0) {
ALOGE("JNI_CreateJavaVM failed\n");
return -1;
}

return 0;
}


startVm方法里面有很多代码,但主要分为三步,第一步是检测,第二步是软硬件参数的设置,第三步是初始化虚拟机。


2. JNI方法的注册


startReg


  int AndroidRuntime::startReg(JNIEnv* env)
{
androidSetCreateThreadFunc((android_create_thread_fn) javaCreateThreadEtc);

ALOGV("--- registering native functions ---\n");
env->PushLocalFrame(200);
//进程JNI方法的注册
if (register_jni_procs(gRegJNI, NELEM(gRegJNI), env) < 0) { env ->
PopLocalFrame(NULL);
return -1;
}
env->PopLocalFrame(NULL);
return 0;
}

// 这里的array[]是gRegJNI,它是一个映射了很多方法的数组
static int register_jni_procs(const RegJNIRec array[], size_t count, JNIEnv* env)
{
// 执行很多映射的方法
for (size_t i = 0; i < count; i++) {
if (array[i].mProc(env) < 0) {
return -1;
}
}
return 0;
}

startReg方法是对JNI方法的注册,它通过一个有很多宏定义的数组,并执行数组定义的方法,进行对JNI和Java层方法一一映射。


3. 调用ZygoteInit.main方法


在AndroidRuntime.start()方法的最后,通过反射执行了其ZygoteInit.main()方法。


    if (startClass == NULL) {
} else {
// 得到ZygoteInit的main方法
jmethodID startMeth = env->GetStaticMethodID(startClass, "main",
"([Ljava/lang/String;)V");
if (startMeth == NULL) {
} else { env ->
// 3. 执行ZygoteInit的main方法
CallStaticVoidMethod(startClass, startMeth, strArray);
}
}

通过反射去执行ZygoteInit.main方法,也是第一次进入java语言的世界。所以AndroidRuntime的start方法做了三件事,一是初始化虚拟机,二是JNI方法的注册,三是通过反射执行ZygoteInit.main方法。


ZygoteInit.main


Zygote进程用于创建管理framework层的SystemServer进程,还用于创建App进程,就是应用App启动创建进程时,是由Zygote进程创建的,并且Zygote创建子进程将使用copy on write的技术,就是子进程直接继承父进程的现有的资源,在子进程对于共有的资源是读时共享,写时复制
ZygoteInit.main方法中主要做了四件事:
· 注册服务端的socket,用于接收创建子进程的信息
· 提前预加载类和资源,用于子进程共享
· 创建SystemServer进程,其管理着framework层
· 循环监听服务socket,创建子进程


  public static void main(String argv[])
{
try {
// 创建服务端Soctet,用于接收创建子进程信息
registerZygoteSocket(socketName);
// 提前预加载类和资源
preload();
// gc操作
gcAndFinalize();
// 创建SystemServer进程
if (startSystemServer) {
startSystemServer(abiList, socketName);
}
// 用服务socket监听创建进程信息,并创建子进程
runSelectLoop(abiList);

closeServerSocket();
} catch (MethodAndArgsCaller caller) {
caller.run();
} catch (RuntimeException ex) {
Log.e(TAG, "Zygote died with exception", ex);
closeServerSocket();
throw ex;
}
}


通过registerZygoteSocket方法去创建服务端的socket,preload()方法去提前加载类和资源,startSystemServer方法去创建SystemServer进程去管理framework层,runSelectLoop方法循环监听创建子进程。


1. 注册服务端Socket


registerZygoteSocket


  private static void registerZygoteSocket(String socketName)
{
if (sServerSocket == null) {
int fileDesc;
final String fullSocketName = ANDROID_SOCKET_PREFIX + socketName;
try {
String env = System . getenv (fullSocketName);
fileDesc = Integer.parseInt(env);
} catch (RuntimeException ex) {
throw new RuntimeException (fullSocketName + " unset or invalid", ex);
}

try {
// 创建服务端的socket
FileDescriptor fd = new FileDescriptor();
fd.setInt$(fileDesc);
sServerSocket = new LocalServerSocket (fd);
} catch (IOException ex) {
throw new RuntimeException (
"Error binding to local socket '" + fileDesc + "'", ex);
}
}
}

创建一个服务端的socket用于接口多个客户端的信息接收,在后面的runSelectLoop方法用于监听服务端的socket信息,以便创建子进程。


2. 预加载资源


preload


  static void preload()
{
preloadClasses(); //预加载位于/system/etc/preloaded-classes文件中的类
preloadResources(); //预加载资源,包含drawable和color资源
preloadOpenGL(); //预加载OpenGL
preloadSharedLibraries(); //预加载"android","compiler_rt","jnigraphics"这3个共享库
preloadTextResources(); //预加载 文本连接符资源
WebViewFactory.prepareWebViewInZygote(); //仅用于zygote进程,用于内存共享的进程
}

preloadClasses()方法通过Class.forName()反射的方法去加载类,preloadResources方法主要是加载位于com.android.internal.R.array.preloaded_drawables和com.android.internal.R.array.preloaded_color_state_lists的资源。
提前加载资源的好处是,在复制创建子进程时,提前加载好的资源可以给子进程直接使用,不用第二次创建,但不好的地方是每个创建的子进程都有拥有很多资源,而不管是否需要。


3. 启动SystemServer进程


startSystemServer


  private static boolean startSystemServer(String abiList, String socketName)
throws MethodAndArgsCaller, RuntimeException
{
// 通过数组保存创建systemserver进程的信息
String args [] = {
"--setuid=1000",
"--setgid=1000",
"--setgroups=1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1018,1021,1032,3001,3002,3003,3006,3007",
"--capabilities=" + capabilities + "," + capabilities,
"--nice-name=system_server",
"--runtime-args",
"com.android.server.SystemServer",
};
ZygoteConnection.Arguments parsedArgs = null;

int pid;

try {
parsedArgs = new ZygoteConnection . Arguments (args);
ZygoteConnection.applyDebuggerSystemProperty(parsedArgs);
ZygoteConnection.applyInvokeWithSystemProperty(parsedArgs);

// 创建systemserver进程
pid = Zygote.forkSystemServer(
parsedArgs.uid, parsedArgs.gid,
parsedArgs.gids,
parsedArgs.debugFlags,
null,
parsedArgs.permittedCapabilities,
parsedArgs.effectiveCapabilities
);
} catch (IllegalArgumentException ex) {
throw new RuntimeException (ex);
}

/* pid==0 则是子进程,就是systemserver */
if (pid == 0) {
if (hasSecondZygote(abiList)) {
waitForSecondaryZygote(socketName);
}

// 完成system_server进程剩余的工作
handleSystemServerProcess(parsedArgs);
}

return true;
}


通过Zygote.forkSystemServer去创建SystemServer进程,其进程是管理着framework的,我们将在下一篇分析SystemServer进程进程的启动。


4. 循环等待孵化进程


runSelectLoop


  private static void runSelectLoop(String abiList) throws MethodAndArgsCaller
{
// FileDescriptor数组
ArrayList<FileDescriptor> fds = new ArrayList<FileDescriptor>();
// ZygoteConnection数组
ArrayList<ZygoteConnection> peers = new ArrayList<ZygoteConnection>();
//sServerSocket是socket通信中的服务端,即zygote进程。保存到fds[0]
fds.add(sServerSocket.getFileDescriptor());
peers.add(null);

while (true) {
// StructPollfd数组,并将相应位置fds的值赋值
StructPollfd[] pollFds = new StructPollfd[fds.size()];
for (int i = 0; i < pollFds.length; ++i) {
pollFds[i] = new StructPollfd ();
pollFds[i].fd = fds.get(i);
pollFds[i].events = (short) POLLIN;
}
try {
//处理轮询状态,当pollFds有事件到来则往下执行,否则阻塞在这里
Os.poll(pollFds, -1);
} catch (ErrnoException ex) {
throw new RuntimeException ("poll failed", ex);
}
for (int i = pollFds.length - 1; i >= 0; --i) {

//采用I/O多路复用机制,当接收到客户端发出连接请求 或者数据处理请求到来,则往下执行;
// 否则进入continue,跳出本次循环。
if ((pollFds[i].revents & POLLIN) == 0) {
continue;
}
if (i == 0) {
ZygoteConnection newPeer = acceptCommandPeer (abiList);
peers.add(newPeer);
fds.add(newPeer.getFileDesciptor());
} else {
//i>0,则代表通过socket接收来自对端的数据,并执行相应操作
boolean done = peers.get (i).runOnce();
if (done) {
peers.remove(i);
fds.remove(i);
}
}
}
}
}

在runSelectLoop方法中有一个轮询的状态,如果有事件接收则会去执行runOnce()的方法操作:


  boolean runOnce() throws ZygoteInit.MethodAndArgsCaller
{

String args [];
Arguments parsedArgs = null;
FileDescriptor[] descriptors;

try {
//读取socket客户端发送过来的参数列表
args = readArgumentList();
descriptors = mSocket.getAncillaryFileDescriptors();
} catch (IOException ex) {
Log.w(TAG, "IOException on command socket " + ex.getMessage());
closeSocket();
return true;
}

if (args == null) {
// EOF reached.
closeSocket();
return true;
}


try {
//将binder客户端传递过来的参数,解析成Arguments对象格式
parsedArgs = new Arguments (args);
...
// fork创建一个新的进程
pid = Zygote.forkAndSpecialize(
parsedArgs.uid, parsedArgs.gid, parsedArgs.gids,
parsedArgs.debugFlags, rlimits, parsedArgs.mountExternal, parsedArgs.seInfo,
parsedArgs.niceName, fdsToClose, parsedArgs.instructionSet,
parsedArgs.appDataDir
);
} catch (ErrnoException ex) {
logAndPrintError(newStderr, "Exception creating pipe", ex);
} catch (IllegalArgumentException ex) {
logAndPrintError(newStderr, "Invalid zygote arguments", ex);
} catch (ZygoteSecurityException ex) {
logAndPrintError(
newStderr,
"Zygote security policy prevents request: ", ex
);
}

try {
if (pid == 0) {
// 处理子进程
IoUtils.closeQuietly(serverPipeFd);
serverPipeFd = null;
handleChildProc(parsedArgs, descriptors, childPipeFd, newStderr);

// should never get here, the child is expected to either
// throw ZygoteInit.MethodAndArgsCaller or exec().
return true;
} else {
// 父进程
IoUtils.closeQuietly(childPipeFd);
childPipeFd = null;
return handleParentProc(pid, descriptors, serverPipeFd, parsedArgs);
}
} finally {
IoUtils.closeQuietly(childPipeFd);
IoUtils.closeQuietly(serverPipeFd);
}
}


所以在runSelectLoop方法中,通过客户端的socket不断的和服务端的socket通信的监听,通过调用起runOnce方法去不断的创建新的进程。


总结


Zygote进程的启动过程主要有:



  1. 创建虚拟机和JNI方法的注册

  2. 注册服务Socket和提前加载系统类和资源

  3. 创建SystemServer进程

  4. 循环等待孵化进程

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

NDK系列:JNI基础

1 Java、JNI、C/C++中的数据类型之间的映射关系 JNI是接口,Java与C/C++交互会有一个数据类型的对应,而JNI为此提供了一套中间类型。 2 JNI动态注册与静态注册 2.1 静态注册 步骤: 编写Java类,比如StaticRegiste...
继续阅读 »

1 Java、JNI、C/C++中的数据类型之间的映射关系


JNI是接口,Java与C/C++交互会有一个数据类型的对应,而JNI为此提供了一套中间类型。


2 JNI动态注册与静态注册


2.1 静态注册


步骤:



  • 编写Java类,比如StaticRegister.java;


package register.staticRegister;

public class StaticRegister {
public static native String func();//注意native关键字
public static void main(String[] args) {
System.out.println(func());
}
}


  • 在.java源文件目录下,命令行输入“javac StaticRegister.java”生成StaticRegister.class文件;

  • 在StaticRegister.class所属包所在目录下,命令行执行“javah register.staticRegister.StaticRegister”(完整类名无后缀),在包所在目录生成register_staticRegister_StaticRegister.h头文件;


image.png


image.png



  • 如果是JDK 1.8或以上,以上步骤可简化为一步:在StaticRegister.java目录下,命令行执行 javac -h . StaticRegister.java,直接在当前目录下得到.class文件和.h文件;

  • 创建CLion项目并拷贝register_staticRegister_StaticRegister.h文件到项目目录;

  • 在CLion项目中添加jni.h头文件和jni_md.h头文件,这两个头文件是JDK自带的,在C:\Program Files\Java\jdk1.8.0_144\include目录下,将这两个头文件拷贝到CLion项目目录;

  • 在register_staticRegister_StaticRegister.h中修改#include 为#include "jni.h"

  • 我们其实可以看到,register_staticRegister_StaticRegister.h文件里面就是一个Java方面native方法的一个JNI声明,格式为JNIEXPORT 关键字一 jstring 返回值的JNI类型 JNICALL 关键字二 Java_register_staticRegister_StaticRegister_func Java_全类名_方法名
    (JNIEnv *, jclass);如下;


/* DO NOT EDIT THIS FILE - it is machine generated */
#include "jni.h"
/* Header for class register_staticRegister_StaticRegister */

#ifndef _Included_register_staticRegister_StaticRegister
#define _Included_register_staticRegister_StaticRegister
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: register_staticRegister_StaticRegister
* Method: func
* Signature: ()Ljava/lang/String;
*/

JNIEXPORT jstring JNICALL Java_register_staticRegister_StaticRegister_func
(JNIEnv *, jclass)
;

#ifdef __cplusplus
}
#endif
#endif


  • 编写头文件register_staticRegister_StaticRegister.h对应的register_staticRegister_StaticRegister.c源文件,拷贝并实现register_staticRegister_StaticRegister.h下的函数,如下:


#include "register_staticRegister_StaticRegister.h"

JNIEXPORT jstring JNICALL Java_register_staticRegister_StaticRegister_func
(JNIEnv *env, jclass jobj)
{
return (*env)->NewStringUTF(env,"Hi Java, this is JNI");
};


  • 编写CMakeLists.txt文件,这是库的配置文件。最重要的是最后两个add_library(),其余的都是自动生成的。add_library()声明库的名字、类型和包含的.c&.h文件。SHARED关键字表示创建的库是动态库.dll,STATIC关键字表示创建的库是静态库.a。*注意:库本身有动态库和静态库之分,Java native方法也有静态注册和动态注册之分,二者没有关系。*这里将动态库命名为StaticRegisterLib


cmake_minimum_required(VERSION 3.15)
project(JNI_C)

set(CMAKE_CXX_STANDARD 14)

add_library(JNI_C SHARED library.cpp library.h)
add_library(StaticRegisterLib SHARED register_staticRegister_StaticRegister.c register_staticRegister_StaticRegister.h)


  • 此时CLion项目结构如下图,Build Project生成动态链接库,得到libStaticRegisterLib.dll


image.png


image.png


image.png



  • 在最开始的Java源文件中,添加静态代码块,使用System.load()方法加载该动态链接库,如下:


package register.staticRegister;

public class StaticRegister {

static {
System.load("E:\\_Projects_\\JNI_Projects\\JNI_C\\cmake-build-debug\\libStaticRegisterLib.dll");
}

public static native String func();
public static void main(String[] args) {
System.out.println(func());
}
}


  • 在Java侧运行,得到如下效果,Java成功调用了dll中的方法,静态注册完毕。


image.png



  • 上述过程,我们在JNI中使用Java_PACKAGENAME_CLASSNAME_METHODNAME与Java侧的方法进行匹配,这种方式我们称之为静态注册


2.2 动态注册


步骤:



  • 编写Java类,比如DynamicRegister.java,如下;


package register.dynamicRegister;

public class DynamicRegister {
public static native String func1(String s);
public static native int func2(int[] a);
public static void main(String[] args) {
System.out.println(func1("Hi JNI"));
int[] a = {1,2,3};
System.out.println("该数组有"+func2(a)+"个元素");
}
}


  • 在CLion项目中添加jni.h头文件和jni_md.h头文件,这两个头文件是JDK自带的,在C:\Program Files\Java\jdk1.8.0_144\include目录下,将这两个头文件拷贝到CLion项目目录;

  • 新建CLion项目,新建C/C++源文件dynamicRegister.c。在该.c文件中,实现两个函数,这两个函数将是native方法在JNI的实现,如下:


#include "jni.h"

jstring f1(JNIEnv *env, jclass jobj){
return (*env)->NewStringUTF(env,"Hi Java");
}
//注意JNI侧数组形参的写法以及如何求数组长度
jint f2(JNIEnv *env, jclass jobj, jintArray arr){
int len = (*env)->GetArrayLength(env,arr);
return len;
}


  • 到目前,f1(),f2()与Java侧native方法func1(),func2()还没有任何关联,我们需要手动**管理关联**;

  • 首先,我们新建一个以JNINativeMethod结构体为元素的数组,如下:


static const JNINativeMethod mMethods[] = {
{"func1","(Ljava/lang/String;)Ljava/lang/String;",(jstring *)f1},
{"func2","([I)I",(jint *)f2},
};


  • 以上数组中每一个元素,都是JNI侧实现方法与Java侧native方法的关联,前两个是Java侧native方法的描述,最后一个是JNI侧函数实现的描述,格式为:


{"Java侧的native方法名","方法的签名",函数指针}


  • 我们需要实现jni.h中的JNI_OnLoad()方法,该方法的实现方法是一个模板,如下:


JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void *reserved)
{
JNIEnv* env = NULL;
//获得 JNIEnv
int r = (*vm)->GetEnv(vm, (void**) &env, JNI_VERSION_1_4);
if( r != JNI_OK){
return -1;
}
jclass mainActivityCls =
(*env)—>FindClass(env,"register/dynamicRegister/DynamicRegister");
// 最后参数是需要注册的native方法的个数,如果小于0则注册失败。
r = (*env)->RegisterNatives(env, mainActivityCls, mMethods, 2);
if(r != JNI_OK ){
return -1;
}
return JNI_VERSION_1_4;
}


  • 注意!第一:以上FindClass(env,"register/dynamicRegister/DynamicRegister")中的字符串是Java侧DynamicRegister类的全类名,注意此处的写法"/";第二:RegisterNatives(env, mainActivityCls, mMethods, 2)中的最后一个参数是需要动态注册的方法个数,手动添加注册或者删除注册都需要对应变化,当然可以直接把mMethod[]的长度传进去,一劳永逸,如下:


int cnt = sizeof(mMethods)/ sizeof(mMethods[0]);
r = (*env)->RegisterNatives(env, mainActivityCls, mMethods, cnt);


  • 在最开始的Java源文件中,添加静态代码块,使用System.load()方法加载该动态链接库,如下:


package register.dynamicRegister;

public class DynamicRegister {
static {
System.load("E:\\_Projects_\\JNI_Projects\\JNI_C\\cmake-build-debug\\libDynamicRegisterLib.dll");
}
public static native String func1(String s);
public static native int func2(int[] a);
public static void main(String[] args) {
System.out.println(func1("Hi JNI"));
int[] a = {1,2,3};
System.out.println("该数组有"+func2(a)+"个元素");
}
}


  • Build Project生成动态链接库,得到libDynamicRegisterLib.dll

  • Java侧运行,效果如下:


image.png



  • 动态注册完毕。


3 system.load()与system.loadLibrary()


System.load()
System.load()参数必须为库文件的绝对路径,可以是任意路径,例如: System.load("C:\Documents and
Settings\TestJNI.dll"); //Windows
System.load("/usr/lib/TestJNI.so"); //Linux


System.loadLibrary()
System.loadLibrary()参数为库文件名,不包含库文件的扩展名
System.loadLibrary("TestJNI"); //加载Windows下的TestJNI.dll本地库
System.loadLibrary("TestJNI"); //加载Linux下的libTestJNI.so本地库


注意:TestJNI.dll 或 libTestJNI.so 必须是在JVM属性java.library.path所指向的路径中。
loadLibary需要[配置当前项目的java.library.path路径]


3 JNI上下文与Java签名


3.1 JNI上下文环境


3.1.1 JNIEnv


JNIEnv类型实际上代表了Java环境,通过JNIEnv*指针,JNI函数可以对Java侧的代码进行操作。例如,创建Java类的对象,调用Java对象的方法,获取对象中的属性等。JNIEnv的指针会被传入到JNI侧的native方法的实现函数中,来对Java端的代码进行操作。例如:


jstring f1(JNIEnv *env, jclass jobj){
return (*env)->NewStringUTF(env,"Hi Java");
}

3.1.2 区分jobject与jclass


在JNI侧声明Java native方法的实现的时候,会有两个默认形参(除开native方法自己的传入参数),分别是JNIEnv指针,另外一个是jobject/jclass,这两个的区别在于:



  • jobject:如果Java侧的native方法是非静态的,那么传给JNI的第二个参数是类的对象,所有类的对象在JNI侧都是jobject类型。

  • jclass:如果Java侧的native方法是静态的,那么传给JNI的第二个参数是类的运行时类,所有运行时类在JNI侧都是jclass类型。

显然,这段JNI代码是native方法在JNI侧的实现,其中先后



  1. 创建了一个jintArray;

  2. 调用了Java侧JNICallJavaMethod类的构造方法;

  3. JNICallJavaMethod类的非静态方法;

  4. JNICallJavaMethod类的静态方法;

  5. JNICallJavaMethodNative类的非静态方法。


我们分别分析,以下代码就是上述代码的分别分析。


调用构造函数


步骤:



  1. 加载类,被调用方法所在类的运行时类,即jclass

  2. 获取方法ID,即jmethodID

  3. 创建一个类的实例,即jobject

  4. 调用方法。


注意:



  1. MethodName形参直接传""即可;

  2. 构造函数在Java侧没有返回值,连void都不是;但是,在JNI侧的方法签名中,返回值是void。比如,一个类的默认构造函数的签名是**"()V"**;

  3. 凡是JNI方法的*GetXxx()*过程,都必须进行异常处理,即使用前判断是否为NULL。


代码:


//todo:调用另一个类的构造函数
jclass jclz1 = NULL;
jclz1 = (*env)->FindClass(env, "JNICallJava/JNICallJavaMethod");
if(jclz1 == NULL){
printf("JNI Side : jclz is NULL.");
return ji;
}
jmethodID jmethodId1 = NULL;
jmethodId1 = (*env)->GetMethodID(env, jclz1, "", "()V");
if(jmethodId1 == NULL){
printf("JNI Side : jmethodId1 is NULL.");
return ji;
}
jobject jobj1 = (*env)->NewObject(env, jclz1, jmethodId1);
(*env)->CallVoidMethod(env, jobj1, jmethodId1);

调用非静态方法


步骤:



  1. 加载类,被调用方法所在类的运行时类,即jclass

  2. 获取方法ID,即jmethodID

  3. 创建一个类的实例,即jobject

  4. 调用方法。


注意:



  1. 因为该非静态方法与上述构造方法同属一个类,所以此时可以省去加载运行时类的步骤一,直接用已经获取到的jclass;

  2. Java侧来说,一个类的对象可以调用多个方法;但是JNI侧的jobject是与jmethodID一一对应的。所以,即使JNI侧调用的不同方法属于同一个类,也需要创建不同的jobject,不能共用;从创建jobject的JNI函数可以看出来:


//jobject与jmethodID是一一对应的关系:
jobject jobj1 = (*env)->NewObject(env, jclz1, jmethodId1);

代码:


//todo:调用另一个类的非静态方法
jmethodID jmethodId2 = NULL;
jmethodId2 = (*env)->GetMethodID(env, jclz1, "func", "(I)I");
if(jmethodId2 == NULL){
return ji;
}
jobject jobj2 = (*env)->NewObject(env, jclz1, jmethodId2);
jint i1 = (*env)->CallIntMethod(env, jobj2, jmethodId2, 5);
printf("JNI Side : func returns %d.\n", i1);

调用静态方法


步骤:



  1. 加载类,被调用方法所在类的运行时类,即jclass

  2. 获取方法ID,即jmethodID

  3. 调用方法。


注意:



  1. 因为该非静态方法与上述构造方法同属一个类,所以此时可以省去加载运行时类的步骤一,直接用已经获取到的jclass;

  2. 因为静态方法的调用不需要对象实例,所以调用Java静态方法时,不需要jobject。


代码:


//todo:调用另一个类的静态方法
jmethodID jmethodId3 = NULL;
jmethodId3 = (*env)->GetStaticMethodID(env, jclz1, "staticFunc", "([I)I");
if(jmethodId3 == NULL){
printf("JNI Side : jmethodId3 is NULL.");
return ji;
}
jint i2 = (*env)->CallStaticIntMethod(env, jclz1, jmethodId3, jArr);
printf("JNI Side : staticFunc returns %d.\n", i2);

调用native方法所在类的方法


这里以非静态方法为例,因为Java侧方法与native方法在同一个类中,而JNI侧实现native方法时,会传入一个jclass/jobject,分别对应Java侧的native方法声明是static native/native。此时我们可以直接使用传入的jclass,或者利用**(*env)->GetObjectClass(env,jobj)**获取到运行时类。关键在于,调用哪个方法,首先需要加载该方法所在的类到JVM运行时环境中


代码:


//todo:调用与native方法同属一个类的方法
jclass jclz0 = NULL;
jclz0 = (*env)->GetObjectClass(env, jobj);
if(jclz0 == NULL){
printf("JNI Side : jclz0 is NULL.");
return ji;
}
jmethodID jmethodId4 = NULL;
jmethodId4 = (*env)->GetMethodID(env, jclz0,"func2","()Ljava/lang/String;");
if(jmethodId4 == NULL){
printf("JNI Side : jmethodId4 is NULL.");
return ji;
}
//todo:接收Java方法返回的字符串,并在JNI侧打印
jstring jstr = (jstring)(*env)->CallObjectMethod(env, jobj, jmethodId4);
char *ptr_jstr = (char *)(*env)->GetStringUTFChars(env,jstr,0);
printf("JNI Side : func2 returns %s\n",ptr_jstr);

JNI调用Java方法答疑


JNI侧如何创建整形数组

步骤:



  1. 声明数组名字与数组长度,即jArr、4

  2. 获取数组元素类型(jint型)的指针,通过调用(*env)->GetIntArrayElements(env, jArr, NULL)

  3. 利用指针,为元素赋值;

  4. 释放指针资源,数组得以保留。


代码:


//todo:JNI侧创建一个int array
jintArray jArr = (*env)->NewIntArray(env, 4);//步骤1
jint *arr = (*env)->GetIntArrayElements(env, jArr, NULL);//步骤2
arr[0] = 0;//步骤3
arr[1] = 10;
arr[2] = 20;
arr[3] = 30;
(*env)->ReleaseIntArrayElements(env,jArr,arr,0);//步骤4

Java侧方法返回String,JNI调用时如何打印返回值?

步骤:



  1. 定义jstring变量,并用(jstring)强转jobject;

  2. 定义字符型指针,并用 (char *)强转;

  3. 打印。


//todo:接收Java方法返回的字符串,并在JNI侧打印
jstring jstr = (jstring)(*env)->CallObjectMethod(env, jobj, jmethodId4);
char *ptr_jstr = (char *)(*env)->GetStringUTFChars(env,jstr,0);
printf("JNI Side : func2 returns %s\n",ptr_jstr);

JNI侧与Java侧的控制台打印顺序

结论是:


JNI侧的控制台打印一定出现在Java侧程序运行结束之后。


我们可以调试看现象:


image.png


两遍I am constructor?

:在调用构造方法和非静态方法的两个调用过程中,都需要通过(*env)->NewObject(env, jclz, jmethodId)创建与jmethodID一一对应的jobject,所以调用了两次构造函数。


两遍func is called?

:待解答!


能否脱离native方法的实现来调用Java侧方法?

:可以,JNI是Java跨平台的实现机制,是Java与原生代码交互的机制。上述的过程我们一般都是在JNI侧的native方法实现中进行的,因为native方法的JNI实现中就有JNIEnv*指针,是获取JNIEnv*最容易的方式,并非唯一方式。如何获取JNIEnv*?待解答!


4.3 JNI处理从Java传来的字符串


Java与C字符串的区别



  • Java内部使用的是utf-16 16bit 的编码方式;

  • JNI里面使用的utf-8 unicode编码方式,英文是1个字节,中文3个字节;

  • C/C++ 使用ASCII编码,中文的编码方式GB2312编码,中文2个字节。


image.png


实战代码


//Java:
package JNICallJava;

public class GetSetJavaString {
static {
System.load("E:\\_Projects_\\JNI_Projects\\JNI_C\\cmake-build-debug\\libGetSetJavaStringLib.dll");
}
public static native String func(String s);
public static void main(String[] args) {
String str = func("--Do you enjoy coding?");
System.out.println(str);
}
}

//C:
#include "stdio.h"
#include "jni.h"
JNIEXPORT jstring JNICALL Java_JNICallJava_GetSetJavaString_func
(JNIEnv *,jclass,jstring)
;//没有用专门的.h文件,此声明可写可不写。

JNIEXPORT jstring JNICALL Java_JNICallJava_GetSetJavaString_func
(JNIEnv *env,jclass jclz,jstring jstr)
{
const char *chr = NULL;//字符指针定义与初始化分开
jboolean iscopy;//判断jstring转成char指针是否成功
chr = (*env)->GetStringUTFChars(env,jstr,&iscopy);//&iscopy位置一般直接传入NULL就好
if(chr == NULL){
return NULL;//异常处理
}
char buf[128] = {0};//申请空间+初始化
sprintf(buf,"%s\n--Yes, I do.",chr);//字符串拼接
(*env)->ReleaseStringUTFChars(env,jstr,chr);//编程习惯,释放内存
return (*env)->NewStringUTF(env,buf);
}

//CMakeLists.txt
add_library(GetSetJavaStringLib SHARED GetSetJavaString.c)

运行结果


image.png


异常处理


上述代码实例中,GetStringUTFChars()方法将JNI的jstring变量转换成C语言能操作的char指针,这个过程可能失败,其实任何转换过程都可能失败,这些过程的目标变量的定义和初始化都需要分开进行,并通过判空进行异常处理


C语言字符串拼接


在C语言中,没有String,字符串都是字符指针。其拼接过程不像Java等语言那么简单,分为以下过程:



  1. malloc申请空间

  2. 初始化

  3. 拼接字符串

  4. 释放内存


灵活的静态注册



  • 此实战代码中,我们没有想一般的静态注册一样使用Java native产生的.h文件,而是直接在实现JNI方法之前写了一个JNI静态注册,这也是可行的,甚至这个提前的声明注册也是可以不写的。此时我们在CMakeLists.txt中的add_library()中值包含了该.c文件。核心在于add_library()中一定要包含native方法在JNI的实现函数,.h文件更多是Java命令生成的教你怎么写JNI实现的一个辅助,无关紧要。

  • JNI无视Java侧的访问控制权限,但会区别静态或非静态。


5 JNI引用


5.1 三种引用


只有当JNI返回值是jobject的引用,才是三种引用之一。


比如(*env)->GetMethodID()返回值就不是引用,是一个结构体。


局部引用



  • 绝大部分JNI方法返回的是局部引用;

  • 局部引用的作用域或者生命周期始于创建它的本地方法,终止于本地方法的返回;

  • 通常在局部引用不再使用时,可以显式使用**DeleteLocalRef()**方法来提前释放它所指向的对象,一边GC回收;

  • 局部引用时线程相关的,只能在创建他的线程里面使用,通过全局变量缓存并使用在其他线程是不合法的。


全局引用


调用NewGlobalRef()基于局部引用创建,会阻止GC回收所引用的对象。可以跨方法、跨线程使用。JVM不会自动释放,必须调用DeleteGlobalRef()手动释放。


弱全局引用


调用NewWeakGlobalRef()基于局部引用创建,不会阻止GC回收所引用的对象。可以跨方法、跨线程使用。JVM不会自动释放,必须调用DeleteWeakGlobalRef()手动释放。


5.2 野指针


上一次创建的东西在程序结束的被回收了,但是静态局部变量未释放,不为NULL。


作业1:写代码实现访问java 非静态和静态方法,返回值必须是object类型
作业2:写代码体会野指针异常


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

跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统

前言 跟我学flutter系列:跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制企...
继续阅读 »
前言

跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget

在以flutter为底的app项目中,用户登录,退出等认证必须做在flutter项目里,那么采用何种状态管理,来全局管理用户认证呢?
今天我就借助flutter_bloc这个库来搭建一套可以复用的成熟用户认证系统


搭建前夕准备


一、我们需要了解现有app有多少认证事件,那么常规来说,流程如下:


1、启动app,判断有无token,有token则跳转首页获取数据,无token则跳转需要授权页面如登录页 

2、登录页登录,登陆后保存token,跳转首页 

3、退出登录,删除token跳转需要授权页


那么总结起来就有三种事件

1、启动事件 

2、登录事件 

3、退出登录事件


二、那么有了认证事件,我们还需要有几个认证状态,有哪些状态呢,我来梳理一下:


1、在app启动后,需要初始化用户状态,那么用户当前是一个身份需要初始化的状态 

2、如果有token,或者用户登录后那么用户就是一个已认证的状态 

3、如果用户退出登录,那么用户当前是未认证的状态


三、咱们还需要做一个用户认证接口,接口主要是为了解耦,为了后期扩展能力、接口需要有哪些内容呢继续梳理一下:


1、是否有token,token是决定app是否认证的关键 

2、删除token,退出登录需要删除 

3、保存token,登录需要保存 

4、跳转授权页面 

5、跳转非授权页面


准备好如上工作,那么我们开始搭建用户认证系统吧


1、先编写认证事件:


part of 'authentication_bloc.dart';

//App认证事件,一般来说有三种,启动认证,登录认证,退出认证
abstract class AuthenticationEvent extends Equatable {
const AuthenticationEvent();

@override
List get props => [];
}
//App启动事件
class AppStart extends AuthenticationEvent{}
//App登录事件
class LogIn extends AuthenticationEvent{
final String token;

LogIn(this.token);

@override
List get props => [token];

@override
String toString() =>"LoggedIn { token: $token }";
}
//App退出事件
class LogOut extends AuthenticationEvent{}

2、编写认证状态


part of 'authentication_bloc.dart';

/// 认证状态 
abstract class AuthenticationState extends Equatable {
const AuthenticationState();
@override
List get props => [];
}

/// - uninitialized - 身份验证未初始化
class AuthenticationUninitialized extends AuthenticationState {}

/// - authenticated - 认证成功
class AuthenticationAuthenticated extends AuthenticationState {}

/// - unauthenticated - 未认证
class AuthenticationUnauthenticated extends AuthenticationState {}

3、编写外部接口



abstract class IUserAuthentication{

bool hasToken();

void saveToken(String token);

void deleteToken();

void authPage();

void unAuthPage();
}

4、有了如上的内容咱们就可以编写核心逻辑bloc了


import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'i_user_authentication.dart';

part 'authentication_event.dart';
part 'authentication_state.dart';

class AuthenticationBloc extends Bloc {

final IUserAuthentication iUserAuthentication;

/// 初始化认证是未认证状态
AuthenticationBloc(this.iUserAuthentication) : super(AuthenticationUninitialized());

@override
Stream mapEventToState(
AuthenticationEvent event,
)
async*
{
if(event is AppStart){
// 判断是否有Token
if(iUserAuthentication.hasToken()){
yield AuthenticationAuthenticated();
} else {
yield AuthenticationUnauthenticated();
}
}else if(event is LogIn){
iUserAuthentication.saveToken(event.token);
yield AuthenticationAuthenticated();
}else if(event is LogOut){
iUserAuthentication.deleteToken();
yield AuthenticationUnauthenticated();
}
}
}

为了使用方便咱们需要做一个工具类来支撑外部使用



import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'authentication_bloc.dart';

class Authentication{

static TransitionBuilder init({
TransitionBuilder? builder,
})
{
return (BuildContext context, Widget? child) {
var widget = BlocListener(
listener: (context, state) {
var bloc = BlocProvider.of(context);
if (state is AuthenticationAuthenticated) {
bloc.iUserAuthentication.authPage();
} else if (state is AuthenticationUnauthenticated) {
bloc.iUserAuthentication.unAuthPage();
}
},
child: child,
);
if (builder != null) {
return builder(context, widget );
} else {
return widget;
}
};
}
}

使用


在项目中如何使用呢?? 

1、接口事件类 

2、bloc初始化 

3、监听初始化


代码如下:
接口实现类


class Auth implements IUserAuthentication{
static final String userTokenN = 'userToken';

Auth(){
_userMMKV = MMKVStore.appMMKV(name: "123");
}
@override
void authPage() {
RouterName.navigateTo(LibRouteNavigatorObserver.instance.navigator!.context, RouterName.home,clearStack: true);

}
late MMKV _userMMKV;

@override
void deleteToken() {
_userMMKV.removeValue(userTokenN);
}

@override
bool hasToken() {
return _userMMKV.decodeString(userTokenN)!=null;
}

@override
void saveToken(String token) {
_userMMKV.encodeString(userTokenN, token);
}

@override
void unAuthPage() {
RouterName.navigateTo(LibRouteNavigatorObserver.instance.navigator!.context, RouterName.login,replace: true);
}

}

2、初始化


MultiBlocProvider(
providers: [
//AuthenticationBloc bloc初始化
BlocProvider(create: (_) => AuthenticationBloc(Auth())),
],
child: MaterialApp(
...
builder: Authentication.init() //监听初始化
),
);

3、事件调用


1、退出按钮调用,BlocProvider.of(context).add(LogOut()) 

2、登录页面调用,BlocProvider.of(context).add(LogIn("123")) 

3、SplashPage页面调用 BlocProvider.of(context).add(AppStart())


大功告成


如上搭建的一个用户认证系统,可以抽离项目做成package,再下次开发其他项目时候,就可以直接使用。方便快捷。

收起阅读 »

Kotlin 1.5 新特性 Inline classes,还不了解一下?

Kotlin 1.5 如约而来了。 如果你正在使用Android Studio 4.2.0 、IntelliJ IDEA 2020.3 或更高的版本,近期就会收到 Kotlin 1.5 的Plugin推送了。作为一个大版本,1.5带来了不少新特性,其中最主要的...
继续阅读 »

Kotlin 1.5 如约而来了。


如果你正在使用Android Studio 4.2.0 、IntelliJ IDEA 2020.3 或更高的版本,近期就会收到 Kotlin 1.5 的Plugin推送了。作为一个大版本,1.5带来了不少新特性,其中最主要的要数inline class了。


早在kotlin 1.3 就已经有了 inline class 的alpha版本。到 1.4.30 进入 beta,如今在 1.5.0 中 终于迎来了 Stable 版本。早期的实验版本的 inline 关键字 在 1.5 中被废弃,转而变为 value关键字


//before 1.5
inline class Password(private val s: String)

//after 1.5 (For JVM backends)
@JvmInline
value class Password(private val s: String)

个人很认同从 inline 变为 value 的命名变化,这使得其用途更为明确:



inline class 主要就是用途就是更好地 "包装" value



有时为了语义更有辨识度,我们会使用自定义class包装一些基本型的value,这虽然提高了代码可读性,但额外的包装会带来潜在的性能损失,基本型的value由于被在包装在其他class中,无法享受到jvm的优化(由堆上分配变为栈上分配)。 而 inline class 在最终生成的字节码中被替换成其 “包装”的 value, 进而提高运行时的性能。


// For JVM backends
@JvmInline
value class Password(private val s: String)

如上,inline class 构造参数中有且只能有一个成员变量,即最终被inline到字节码中的value。


val securePassword = Password("Don't try this in production")

如上,Password实例在字节码中被替换为String类型"Don't try this in production"


PS:如何安装 Kotlin 1.5



  1. 首先更新IDE的 Kotlin Plugin,如果没收到推送,可以手动方式升级:


Tools > Kotlin > Configure Kotlin Plugin Updates



  1. 配置languageVersion & apiVersion


compileKotlin {
kotlinOptions {
languageVersion = "1.5"
apiVersion = "1.5"
}
}



经 inline 处理后代码




inline classes 转化为字节码后究竟是怎样的呢?


fun check(password: Password) {
//...
}

fun main() {
val securePassword = Password("Don't try this in production")
check(securePassword)
}

对于Password这个inline class, 字节码反编译的产物如下:


   public static final void check_XYhEtbk/* $FF was: check-XYhEtbk*/(@NotNull String password) {
Intrinsics.checkNotNullParameter(password, "password");
}

public static final void main() {
String securePassword = Password.constructor-impl("Don't try this in production");
check-XYhEtbk(securePassword);
}

// $FF: synthetic method
public static void main(String[] var0) {
main();
}


  • securePassword 的类型由Password替换为String

  • check方法改名为check_XYhEtbk,签名类型也有 Password 替换 String


可见,无论是变量类型或是函数参数类型,所有的inline classes都被替换为其包装的类型。


名字被混淆处理(check_XYhEtbk)主要有两个目的



  1. 防止重载函数的参数经过 inline 后出现相同签名的情况

  2. 防止从Java侧调用到参数经过 inline 后的方法




Inline class 的成员




inline class 具备普通class的所有特性,例如拥有成员变量、方法、初始化块等


@JvmInline
value class Name(val s: String) {
init {
require(s.length > 0) { }
}

val length: Int
get() = s.length

fun greet() {
println("Hello, $s")
}
}

fun main() {
val name = Name("Kotlin")
name.greet() // `greet()`作为static方法被调用
println(name.length) // property getter 也是一个static方法
}

但是,inline class 的成员不能有自己的幕后属性,只能作为代理使用。 inline class的创建的对象在字节码中会被消除,所以这个实例无法拥有自己的状态以及行为,对inline class 实例的方法调用,在实际运行时会变为一格静态方法调用。




Inline class 的继承




interface Printable {
fun prettyPrint(): String
}

@JvmInline
value class Name(val s: String) : Printable {
override fun prettyPrint(): String = "Let's $s!"
}

fun main() {
val name = Name("Kotlin")
println(name.prettyPrint()) // prettyPrint()也是一个 static方法调用
}

inline class 可以实现任意inteface, 但不能继承自class。因为在运行时将无处安放其父类的属性或状态。如果你试图继承另一个Class,IDE会提示错误:Inline class cannot extend classes




自动拆装箱




inline class 在字节码中并非总被消除,有时也是需要存在的。例如当出现在泛型中、或者以 Nullable 类型出现时,此时它会根据情况自动与被包装类型进行转换,实现像Integerint那样的自动拆装箱


@JvmInline
value class WrappedInt(val value: Int)

fun take(w: WrappedInt?) {
if (w != null) println(w.value)
}

fun main() {
take(WrappedInt(5))
}

如上,take 接受一个 Nulable 的 WrappedInt 后进行 print 处理


public static final void take_G1XIRLQ(@Nullable WrappedInt w) {
if (Intrinsics.areEqual(w, (Object)null) ^ true) {
int var1 = w.unbox_impl();
System.out.println(var1);
}
}

public static final void main() {
take_G1XIRLQ(WrappedInt.box_impl(WrappedInt.constructor_impl(5)));
}

字节码中,take的参数并没有变为Int,而仍然是原始类型 WrappedInt。因此,在 take 的调用处,需要通过box_impl 做装箱处理, 而在take的实现中,通过 unbox_impl 拆箱后再进行print


同理,在泛型方法或者泛型容器中使用 inline class 时,需要通过装箱保证传入其原始类型:


genericFunc(color)         // boxed
val list = listOf(color) // boxed
val first = list.first() // unboxed back to primitive

反之,从容器获取 item 时,需要拆箱为被包装类型。



关于自动拆装箱在开发中无需太在意,只要知道有这个特性存在即可。





对比其他类型




与 type aliases 的区别 ?


inline class 与 type aliases 在概念上有点相似,都会在编译后被替换为被代理(包装)的类型, 区别在于



  • inline class 本身是实际存在的Class 只是在字节码中被消除了并被替换为被包装类型

  • type aliases仅仅是个别名,它的类型就是被代理类的类型。


typealias NameTypeAlias = String

@JvmInline
value class NameInlineClass(val s: String)

fun acceptString(s: String) {}
fun acceptNameTypeAlias(n: NameTypeAlias) {}
fun acceptNameInlineClass(p: NameInlineClass) {}

fun main() {
val nameAlias: NameTypeAlias = ""
val nameInlineClass: NameInlineClass = NameInlineClass("")
val string: String = ""

acceptString(nameAlias) // OK: NameTypeAlias等同String,可以传递
acceptString(nameInlineClass) // Not OK: NameInlineClass 与 String是两个类,不能等同

// 反之亦然:
acceptNameTypeAlias(string) // OK: 传入String也是可以的
acceptNameInlineClass(string) // Not OK: String不等同于NameInlineClass
}

与 data class 的区别 ?


inline class 与 data class 在概念上也很相似,都是对一些数据的包装,但是区别很明显



  • inline class 只能有一个成员属性,其主要目的是通过一个额外类型的包装让代码更易用

  • data clas 可以有多个成员属性,其主要目的是更高效地处理一组相关数据的集合




使用场景


上面说到, inline class 的目的是通过包装让代码更易用,这个易用性体现在诸多方面:


场景1:提高可读性


fun auth(userName: String, password: String) { println("authenticating $userName.") }

如上, auth的两个参数都是String,缺乏辨识度,即使像下面这样传错了也难以发觉


auth("12345", "user1") //Error

@JvmInline value class Password(val value: String)
@JvmInline value class UserName(val value: String)

fun auth(userName: UserName, password: Password) { println("authenticating $userName.")}

fun main() {
auth(UserName("user1"), Password("12345"))
//does not compile due to type mismatch
auth(Password("12345"), UserName("user1"))
}

使用 inline class 使的参数更具辨识度,避免发生错误


场景2:类型安全(缩小扩展函数作用域)


inline fun <reified T> String.asJson() = jacksonObjectMapper().readValue<T>(this)

String类型的扩展方法asJson可以转化为指定类型T


val jsonString = """{ "x":200, "y":300 }"""
val data: JsonData = jsonString.asJson()

由于扩展函数是top-level的,所有的String类型都可以访问,造成污染


"whatever".asJson<JsonData> //will fail

通过inline class可以将Receiver类型缩小为指定类型,避免污染


@JvmInline value class JsonString(val value: String)

inline fun <reified T> JsonString.asJson() = jacksonObjectMapper().readValue<T>(this.value)

如上,定义JsonString,并为之定义扩展方法。


场景3:携带额外信息


/**
* parses string number into BigDecimal with a scale of 2
*/
fun parseNumber(number: String): BigDecimal {
return number.toBigDecimal().setScale(2, RoundingMode.HALF_UP)
}

fun main() {
println(parseNumber("100.12212"))
}

如上,parseNumber的功能是将任意字符串解析成数字并保留小数点后两位。


如果我们希望通过一个类型将解析前后的值都保存下来然后分别打印,可能首先想到的使用Pair或者data class。但是当这两个值之间是有换算关系时,其实也可以用inline class实现。如下


@JvmInine value class ParsableNumber(val original: String) {
val parsed: BigDecimal
get() = original.toBigDecimal().setScale(2, RoundingMode.HALF_UP)
}

fun getParsableNumber(number: String): ParsableNumber {
return ParsableNumber(number)
}

fun main() {
val parsableNumber = getParsableNumber("100.12212")
println(parsableNumber.parsed)
println(parsableNumber.original)
}

ParsableNumber的包装类型是String,同时通过parsed携带了解析后的值。如前文提到的那样,字节码中,parsed getter 会以static方法的形式存在,因此虽然携带了更多信息,但实际上并不存在这样一个包装类实例:


@NotNull
public static final String getParsableNumber(@NotNull String number) {
Intrinsics.checkParameterIsNotNull(number, "number");
return ParsableNumber.constructor_impl(number);
}

public static final void main() {
String parsableNumber = getParsableNumber("100.12212");
BigDecimal var1 = ParsableNumber.getParsed_impl(parsableNumber);
System.out.println(var1);
System.out.println(parsableNumber);
}



最后




Inline class 是个好工具,在提高代码的可读性、易用性的同时,不会造成性能的损失。 早期由于一直处于试验状态没有被大家所熟知, 随着如今在 Kotlin 1.5 中的转正,相信未来一定会被在更广泛地使用、发掘更多应用场景。


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

Flutter 2021 中的按钮

在本文中,我们将介绍令人惊叹的 Flutter 按钮,它们可以帮助所有初学者或专业人士为现代应用程序设计漂亮的 UI。 首先让我告诉你关于 Flutter 中按钮的一件重要事情,在flutter最新版本中以下Buttons在fluter中被废弃了:废弃的推荐的...
继续阅读 »

在本文中,我们将介绍令人惊叹的 Flutter 按钮,它们可以帮助所有初学者或专业人士为现代应用程序设计漂亮的 UI。


首先让我告诉你关于 Flutter 中按钮的一件重要事情,在flutter最新版本中以下Buttons在fluter中被废弃了:

废弃的推荐的替代
RaisedButtonElevatedButton
OutlineButtonOutlinedButton
FlatButtonTextButton

那么让我们来探索一下 Flutter 中的按钮。



Elevated Button


StadiumBorder


image.png


ElevatedButton(
onPressed: (){},
child: Text('Button'),
style: ElevatedButton.styleFrom(
shadowColor: Colors.green,
shape: StadiumBorder(),
padding: EdgeInsets.symmetric(horizontal: 35,vertical: 20)),
)

image.png


RoundedRectangleBorder


ElevatedButton(
onPressed: (){},
child: Text('Button'),
style: ElevatedButton.styleFrom(
shadowColor: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),

CircleBorder


image.png


ElevatedButton(
onPressed: () {},
child: Text('Button'),
style: ElevatedButton.styleFrom(
shape: CircleBorder(),
padding: EdgeInsets.all(24),
),
)

BeveledRectangleBorder


image.png


ElevatedButton(
onPressed: () {},
child: Text('Button'),
style: ElevatedButton.styleFrom(
shape: BeveledRectangleBorder(
borderRadius: BorderRadius.circular(12)
),
),
)

Outlined Button


StadiumBorder
image.png


OutlinedButton(
onPressed: () {},
child: Text('Button'),
style: OutlinedButton.styleFrom(
shape: StadiumBorder(),
),
)

RoundedRectangleBorder


image.png


OutlinedButton(
onPressed: () {},
child: Text('Button'),
style: OutlinedButton.styleFrom(
shape: BeveledRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
)

CircleBorder


image.png


OutlinedButton(
onPressed: () {},
child: Text('Button'),
style: OutlinedButton.styleFrom(
shape: CircleBorder(),
padding: EdgeInsets.all(24),
),
)

BeveledRectangleBorder


image.png


OutlinedButton(
onPressed: () {},
child: Text('Button'),
style: OutlinedButton.styleFrom(
shape: BeveledRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
)

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

一个录音项目的开发总结(一)

iOS
最近,工作之余,自己做了一个项目,项目的一期主要功能是音频录制和播放,音频格式包含m4a、mp3、wav三种格式,录制过程中要支持变音,还要能获取到metering以绘制录音过程的声音强弱变化图,播放功能包括音频波形图的绘制以及音频播放。 在做之前,我对iOS...
继续阅读 »

最近,工作之余,自己做了一个项目,项目的一期主要功能是音频录制和播放,音频格式包含m4a、mp3、wav三种格式,录制过程中要支持变音,还要能获取到metering以绘制录音过程的声音强弱变化图,播放功能包括音频波形图的绘制以及音频播放。


在做之前,我对iOS中的录音方面的知识了解甚少, 以至于走了很多弯路,虽然浪费了很多时间,但是也从中学到了很多知识,最终完成了项目的编码。


以下,主要介绍录音方面开发总结,播放方面后续记录。


在iOS中如果想实现录音功能,那么有四种方式可以实现:



  1. AVAudioRecorder :这是最简单的录音方式,只需要配置好录音格式就能得到相应的文件,但是相应的,这种方式无法得到录音过程中的音频源数据,无法实现变音功能和录制mp3文件。

  2. AVAudioEngine:AVAudioEngine功能强大,能实现录音、播放、混响、变音等功能,当我发现我这个类时,我高兴坏了,看了很多文档,结果做demo时,发现这个类外强中干,譬如你不能改变AudioEngine默认的inputNode、outputNode、mainMixNode的数据format,即使你千辛万苦找方法成功改变了数据格式,输出的数据也会狠狠地扇你一巴掌,告诉你高兴太早了,而且inputNode、outputNode、mainMixNode不会自动转换数据格式......如果想录制m4a、mp3还是需要其他方式才能实现。

  3. AudioQueue & AudioFile:AudioQueue在录音过程中有个回调方法,抛出编码后的音频数据,如果想处理音频数据,譬如变音、转码,可以在这个回调中实现,而且能获取到metering数据; AudioFile用于存储回调方法中抛出的音频数据,这个音频数据要与AudioFile创建时配置的inFormat一致。如果想了解audioqueue,可以看看这个文档

  4. AudioUnit &  ExtAudioFile:AudioUnit在录音过程中也会有回调,AudioUnit只支持pcm录制,所以要辅以ExtAudioFile来实现其他音频格式的录制,ExtAudioFile是个宝藏类,这个类可以实现音频格式的自动转换。


如果只是单纯的录用,那么使用AVAudioRecorder就可以非常简单的实现了,如果想对音频数据做处理,那么对于录音过程中抛出的数据请务必是pcm,只要能拿到pcm源数据,任何能做到的音频处理只要你想,都能实现。


iOS中,系统支持的录音编码格式为:


官方参考文档点击查看



wav文件是无损编码格式pcm,m4a文件编码格式AAC,mp3文件iOS系统不支持录制,系统支持mp3文件播放,有解码器但是无相应编码器,如果想录制mp3 文件只能依托于第三方的编码库,我采用的是lame库。


变音


iOS系统中本身是支持变音功能的,AudioUnit中有一个属性kNewTimePitchParam_Pitch,可以改变声音的音色,但是这个属性是MixerUnit的属性,录音的OutputUnit不支持这个属性,要想将 混音MixerUnit和录音OutputUnit连接到一块,需要AUGraph类去连接,但是AUGraph已弃用。。。系统推荐去使用AVAudioEngine类,这个类也是个坑,我在做demo时无法实现m4a文件的录制,就放弃了。


所以变音我采用的也是三方类SoundTouch,SoundTouch支持pcm编码、音频数据是lettle-endian,所以录音过程中抛出的音频buffer的format必须是pcm的,不然无法实现边录音边变音。


这样看起来AudioUnit貌似更适合这个项目一些,但是AudioUnit无法获取到录音过程中的metering数据,真是个令人悲伤的事情,AudioUnit很强大,如果AUGraph不被弃用,可能我会用它来录音。


最终我的实现方案是:


1、wav文件录制:AudioQueue录音、 AudioFile存储文件,AudioQueue和AudioFile的dataFormat为:


AudioStreamBasicDescription mDataFormat;

mDataFormat.mSampleRate = 44100;
mDataFormat.mChannelsPerFrame = 1;
mDataFormat.mReserved = 0;
mDataFormat.mBitsPerChannel = 16;
mDataFormat.mFramesPerPacket = 1;
mDataFormat.mSampleRate = self.model.sampleRate;
mDataFormat.mBytesPerFrame = mDataFormat.mBytesPerPacket = 2;
mDataFormat.mFormatID = kAudioFormatLinearPCM;
mDataFormat.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked;


AudioFile的fileType为kAudioFileCAFType


2、mp3文件:AudioQueue录音, lame转码, FILE文件存储, AudioQueue的编码格式为:


AudioStreamBasicDescription mDataFormat;

mDataFormat.mSampleRate = 44100;
mDataFormat.mChannelsPerFrame = 1;
mDataFormat.mReserved = 0;
mDataFormat.mBitsPerChannel = 16;
mDataFormat.mFramesPerPacket = 1;
mDataFormat.mSampleRate = self.model.sampleRate;
mDataFormat.mBytesPerFrame = mDataFormat.mBytesPerPacket = 2;
mDataFormat.mFormatID = kAudioFormatLinearPCM;
mDataFormat.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger
| kLinearPCMFormatFlagIsPacked
|kAudioFormatFlagIsNonInterleaved


3、m4a文件:AudioQueue录音,  ExtAudioFile转码加存储,AudioQueue和ExtAudioFile的kExtAudioFileProperty_ClientDataFormat属性的编码格式为:


AudioStreamBasicDescription mDataFormat;

mDataFormat.mSampleRate = 44100;
mDataFormat.mChannelsPerFrame = 1;
mDataFormat.mReserved = 0;
mDataFormat.mBitsPerChannel = 16;
mDataFormat.mFramesPerPacket = 1;
mDataFormat.mSampleRate = self.model.sampleRate;
mDataFormat.mBytesPerFrame = mDataFormat.mBytesPerPacket = 2;
mDataFormat.mFormatID = kAudioFormatLinearPCM;
mDataFormat.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked;


ExtAudioFile文件的音频编码格式为:


AudioStreamBasicDescription outputFormat;outputFormat.mSampleRate = 44100;

outputFormat.mFormatID = kAudioFormatMPEG4AAC;outputFormat.mFormatFlags = 0;outputFormat.mBytesPerPacket = 0;outputFormat.mFramesPerPacket = 1024;

outputFormat.mBytesPerFrame = 0;outputFormat.mChannelsPerFrame = 1;outputFormat.mBitsPerChannel = 0;outputFormat.mReserved = 0;


关于四种录音方式的代码实现后续会更新,音频编辑功能,二期可能会上,到时也会研究、记录,希望到时自己不会太懒......


作者:阿喵同学
链接:https://juejin.cn/post/6936869349546426382

收起阅读 »

“杀死” App 上的疑难崩溃!

iOS
问题与背景在移动应用性能方面,崩溃带来的影响是最为严重的,程序崩溃可以打断用户正在进行的操作体验,造成关键业务中断、用户留存率下降、品牌口碑变差、生命周期价值下降等影响。很多公司将崩溃率作为优先级最高的技术指标,因此程序崩溃的监控与收集就成为了一项必不可少的工...
继续阅读 »

问题与背景

在移动应用性能方面,崩溃带来的影响是最为严重的,程序崩溃可以打断用户正在进行的操作体验,造成关键业务中断、用户留存率下降、品牌口碑变差、生命周期价值下降等影响。很多公司将崩溃率作为优先级最高的技术指标,因此程序崩溃的监控与收集就成为了一项必不可少的工作, 目前58同城App使用腾讯Bugly作为发布环境下App异常数据的收集工具。

我们的崩溃率一直在优化,每个版本都有专门负责监控线上崩溃以及解决问题的同学,经过我们不断的优化,目前 58同城iOS App的崩溃率维持在一个比较优秀的水准, Bugly上收集的崩溃大部分都是野指针崩溃和疑难崩溃。但是遗留的疑难崩溃优化手段比较有限,一个主要的原因是Bugly上的崩溃不能正常解析,定位不到真正原因。我们拿一个简单的例子来说明一下。

RN的HOOK函数问题

0 CoreFoundation  0x00000001804f504c 	0x000000018045c000 + 626764
1 Foundation 0x0000000181dae6cc 0x0000000181c7e000 + 1246924
2 UIKit 0x0000000198e5cf30 0x0000000198e57000 + 24368
3 AppName 0xe622388106d79fcc RCTFBQuickPerformanceLoggerConfigureHooks + 16244
4 CoreTelephony 0x0000000198e5e628 0x0000000198e57000 + 30248
5 CoreTelephony 0x0000000108f68fe4 0x0000000198e57000 + 78455260
6 CoreTelephony 0x00000001061ed870 0x0000000198e57000 + 30763624
7 CoreTelephony 0x0000000108f657ec 0x0000000198e57000 + 78440932
8 AppName 0x0000000108f67024 _ZN6tflite19AcquireFlexDelegateEv + 78447132
9 Foundation 0x0000000108f67024 _NSGetUsingKeyValueGetter + 88

在近几个版本中,我们发现Bugly上有大量的崩溃日志都会携带一个来自RN的函数调用栈: RCTFBQuickPerformanceLoggerConfigureHooks,这是一个RNHOOK函数。多条崩溃日志的堆栈都指向这个函数,且这个函数是一个空函数,没有任何实现,这让我们比较困扰。用过Bugly的同学都知道,Bugly每条崩溃日志都有个跟踪数据,记录着这个崩溃发生之前页面的跟踪日志,通过页面的跟踪日志我们发现这些崩溃中用户浏览的页面大多数都不涉及RN业务,与RN没有任何关系。而且每条崩溃的页面跟踪日志也不相同。既然程序崩溃之前浏览的业务不涉及RN但Bugly上的堆栈确指向RN,因此我们怀疑这种崩溃不是崩溃在RNHOOK函数上并且它们是不同错误导致的崩溃。带着这种疑问,我们开始验证这个猜想,来看一看我们的怀疑是否准确。

如何验证Bugly解析错误

因为Bugly无法拿到应用崩溃后所产生的ips文件,无法利用symbolicatecrash等工具符号化日志。因此我们采用atos命令来验证我们的怀疑是否正确。

1. atos验证

atos工具会输出崩溃的代码语句和它所在的文件以及行数,前置条件是需要拿到dSYM文件,确定手机架构是arm64还是armv7,还需要拿到atos需要的load-addressaddress,根据这些信息就能够找到问题所在。aots命令格式如下:

atos -o yourAppName.app.dSYM/Contents/Resources/DWARF/yourAppName -arch arm64/armv7 -l <load-address> <address>

怎么获取dSYM文件与架构这里就不做详细介绍了,我们来看一下怎么在Bugly的崩溃日志中拿到load-addressaddress

一般以app命名的地方就是崩溃的位置,例如:正常的一个崩溃日志格式为:

0x0000000103ef6970 0x0000000102728000 + 30252

其中0x0000000103ef6970为运行地址,就是atos需要的address0x0000000102728000为运行起始地址,就是atos需要的load address302522为偏移量,一般来说,偏移量 + 运行起始地址 = 运行地址。

介绍完atos需要的load address(运行起始地址)与address(运行地址)之后,再来看一下RCTFBQuickPerformanceLoggerConfigureHooks这个函数的崩溃,根据图中示例我们看到这个崩溃的运行地址为0xe622388106d79fcc,但是这个崩溃地址是错误的,一般地址小于0xFFFFFFFFFF,示例中明显大很多。因此我需要将高位地址清洗,清洗后此地址为0x106d79fcc。因此address0x106d79fcc

接下来我们打开Bugly其他信息一栏,看到App base addr(基地址):0x0000000102604000,这个就是atos需要需要的load address。 image20211116110000581.png 通过上述信息,我们以RCTFBQuickPerformanceLoggerConfigureHooks这个函数为例验证一下Bugly的解析结果是否正确

➜ atos -o AppName.app.dSYM/Contents/Resources/DWARF/AppName -arch arm64 -l 0x0000000102604000 0x0000000106d79fcc
-[NSMutableDictionary(YJKit) yjKit_setObject:forKey:] (in AppName) (YJKit.m:432)

结果发现atos符号化后的结果与Bugly给我们的结果确实不一致。再根据Bugly的页面跟踪数据我们确认atos符号化后的结果是正确的,这与我们的怀疑是一致的。

既然Bugly的堆栈错误的指向了这个RN的空函数,那么我们就来看一看源码中RCTFBQuickPerformanceLoggerConfigureHooks是怎样的存在。

**2. 源码中的RCTFBQuickPerformanceLoggerConfigureHooks**函数

RCTFBQuickPerformanceLoggerConfigureHooks函数在源码中的声明如下:

image-20211116110100992.png 源码中,这个函数没有任何实现,完全是一个空函数。将RCT__EXTERN 展开后为__attribute__((visibility("default"))),其作用为将RCTFBQuickPerformanceLoggerConfigureHooks向外界暴露,如果外界存在同名函数,那么RCTFBQuickPerformanceLoggerConfigureHooks会报符号冲突的错误。这里利用__attribute__((weak))RCTFBQuickPerformanceLoggerConfigureHooks声明为弱符号,当外界有同名函数时,SDK内部调用外届的函数,否则调用内部空函数,这个弱符号在RN里起到了HOOK的作用 ,接下来我们就详细的了解一下弱符号。

3. 弱符号__attribute__ ((weak))

在一个程序中,无论是变量名,还是函数名,在编译器的眼里,就是一个符号而已,符号可以分为强符号和弱符号。对于C/C++来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量是弱符号,强符号和弱符号在程序编译连接过程中一般遵循下面三个规则:

  1. 不允许强符号被多次定义。如果有多个强符号,会报符号重定义错误

  2. 如果有一个强符号,其他定义都是弱符号,则选择强符号

  3. 如果一个符号在所有文件中都是弱符号,则选择其中一个占用空间最大的

强弱符号规则定义摘选自:强符号和弱符号,强引用和弱引用

duplicate symbol '_OBJC_CLASS_$_XXX'这个错误大家应该都比较熟悉,通过错误的描述我们很容易就可以知道这是因为在链接的时候有重复的符号。在编译时,编译器向汇编器输出每个全局符号,若两个或两个以上全局符号(函数或变量名)名字一样,且都是强符号就会出现符号重定义错误,如果有一个是弱符号(weak symbol),则不会出现问题。

一个程序内同时存在强符号与弱符号时,链接器会忽略弱符号,去使用普通的全局符号来解析所有对这些符号的引用,但当普通的全局符号不可用时,链接器会使用弱符号。当有函数或变量名可能被用户覆盖时,该函数或变量名可以声明为一个弱符号。可以通过__attribute__((weak))来定义弱符号。

4. 弱符号的使用

在开发中,假如我们不确定外部模块是否提供一个函数func,但是我们不得不用这个函数,即自己模块的代码必须用到func函数:

extern int func(void);
...int a = func;
...

我们不知道func函数是否被定义了,这会导致2个结果:

  1. 外部存在这个函数func,那么在我自己的模块使用这个函数func,正确。
  2. 外部如果不存在这个函数,那么我们使用func,程序直接崩溃。

所以这个时候,__attribute__((weak)) 派上了用场,在自己的模块中定义:

int __attribute__((weak)) func(......)
{
return 0;
}

将本模块的func转成弱符号类型,如果遇到强符号类型(即外部模块定义了func),那么我们在本模块执行的func将会是外部模块定义的func。如果外部模块没有定义,那么,将会调用这个弱符号。

我们发现Bugly对某些没有解析正确的崩溃,堆栈都会定位到项目中的弱符号上,同时我们还发现在58同城App中,Bugly不单单定位到RCTFBQuickPerformanceLoggerConfigureHooks这一个弱符号上,还有大量的崩溃定位到了其他的弱符号上。

上面我们通过atos还原了正确的日志,并定位到了是弱符号的问题,下面我们结合符号表来看一下日志符号化的原理。

如何处理bugly解析异常的数据

Crash 日志在被符号化之前是不可读的,所谓符号化就是把堆栈信息解释成源码里可读的函数名或方法名,也就是所谓的符号。只有符号化成功后,Crash 日志才能更好的帮助开发者定位问题。日志的解析需要用到dSYM文件,dSYM指的是 Debug Symbols, 也就是调试符号。

DWARF是一种被众多编译器和调试器使用的用于支持源码级别调试的调试文件格式,该格式是一个固定的数据格式,dSYM就是按照DWARF格式保存调试信息的文件,我们常常称为符号表文件。

日志的符号化有很多种方式,例如xcode分析、symbolicatecrashatosdwarfdump等,本质其实就是查找崩溃指令在符号表哪个函数的指令区间。今天我们主要讲一下Bugly解析不准的日志怎么在符号表里查找出正确的堆栈。

1. Bugly还原正确堆栈的原理

以弱符号RCTFBQuickPerformanceLoggerConfigureHooks函数为例,还原一下日志的解析原理。

0 CoreFoundation  0x00000001804f504c 	0x000000018045c000 + 626764
1 Foundation 0x0000000181dae6cc 0x0000000181c7e000 + 1246924
2 UIKit 0x0000000198e5cf30 0x0000000198e57000 + 24368
3 AppName 0xe622388106d79fcc RCTFBQuickPerformanceLoggerConfigureHooks + 16244
4 CoreTelephony 0x0000000198e5e628 0x0000000198e57000 + 30248
5 CoreTelephony 0x0000000108f68fe4 0x0000000198e57000 + 78455260
6 CoreTelephony 0x00000001061ed870 0x0000000198e57000 + 30763624
7 CoreTelephony 0x0000000108f657ec 0x0000000198e57000 + 78440932
8 AppName 0x0000000108f67024 _ZN6tflite19AcquireFlexDelegateEv + 78447132
9 Foundation 0x0000000108f67024 _NSGetUsingKeyValueGetter + 88

  • 上图中我们看到 RCTFBQuickPerformanceLoggerConfigureHooks 这行调用栈的虚拟内存地址存在异常,一般地址地址小于0xFFFFFFFFFF ,示例中明显大很多。我们将高位地址清洗后来保证堆栈正常。调整后,地址为 0x106d79fcc,但当然不是每个Bugly解析错误的日志虚拟内存地址都异常,如果是正常的,则不用改变
  • 查看其他信息,找到基地址App base addr,此处为 0x102604000。如果崩溃发生在其他动态库,那么查找下方对应动态库的地址。
  • 经过第一步和第二步,我们获取到了 0x106d79fcc 和 0x102604000
  • 指令偏移地址为:0x4775FCC = (步骤1)0x106d79fcc - (步骤2)0x102604000
  • 找到此次打包对应的Bugly符号表,并以文本的方式打开
  • 查找0x4775FCC在哪一行符号区间内
  • 最终查找到其在 0x4775fb4 ≤ 0x4775FCC < 0x4775fd0,即3997407行的符号,符号区间遵循前闭后开原则

image20211112210415260.png 通过以上步骤我们找到了RCTFBQuickPerformanceLoggerConfigureHooks函数的实际崩溃位置,并且与我们用atos工具验证后的结果一致,说明这个结果是正确的。

上面我们在符号表里查找到Bugly解析错误的日志的正确堆栈,那如果没有符号表怎么呢,这就涉及到了提取符号表。

2. 如何提取符号表

如果符号表丢失了,但是代码没有改动,那么可以尝试在相同的环境下重新编译和提取符号表,这个步骤有两个前提 1. 代码要与之前保持一致 2. 编译和链接环境都相同,防止由于Debug/Relase对最终包有影响。如果是Debug包,可以用过dsymutil xxx.app/xxx -o xxx.dSYM 来提取符号表

有了以上两个前提就可以通过dSYM文件来提取符号表了,目前我们实现了Bugly轻量符号表的提取,并且文件体积相对于Bugly符号表体积减少到60%。推动ICI(58项目管理平台)按照一定规则输出符号表,目前可以做到根据崩溃日志的UUID直接下载对应的符号表,日志解析和问题排查效率极大提高。

3. 无符号表符号化日志

如果既找不到符号表(dSYM文件或symbol文件),也无法恢复到原先的代码重新生成符号表,那么可以考虑借助无符号表符号化工具 WBBlades 来还原日志:github.com/wuba/WBBlad…

WBBlades是基于Mach-O文件解析的工具集,包括未使用代码检测(支持ObjCSwift)、应用程序大小分析、不需要dSYM文件的日志恢复。

由于方案自身的限制,目前还不能解析除了OC方法以外的崩溃日志,如:block的崩溃、自定义C函数的崩溃。后续需要考虑如何将block的崩溃日志进行符号化。

优化成果与收益

现在我们知道了当Bugly解析不准的时候,我们可以利用Bugly给我们提供的其他信息在符号表里找到正确的答案。通过以上研究,我们通过自研解析工具重新对Bugly的日志进行符号化,通过工具我们在集团内部解决了除RNHOOK函数问题以外还解决了多个遗留已久的历史版本崩溃问题,这里简单的介绍几个比较有代表性的。

1. 拿不到基地址的问题

通过RCTFBQuickPerformanceLoggerConfigureHooks函数的崩溃的介绍,我们可以在Bugly的其他信息里获取到日志的基地址,通过这个地址我们不论是用atos验证还是手动在符号表里查找都可以还原正确的堆栈,但是如果Bugly的其他信息里没有基地址怎么办,我们来看一下下面的这种崩溃日志。

0 CoreFoundation 0x00000001835891b8 0x0000000183459000 + 1245624
5 UIKit 0x000000018963a660 0x000000018942f000 + 2143840
6 AppName 0x00000001075c9904 str_to_integral_8ExpectedIT_NS_14Conversion + 1950052
7 AppName 0x000000010627f94c RCTFBQuickPerformanceLoggerConfigureHooks + 3098344
8 AppName 0x00000001062015a0 RCTFBQuickPerformanceLoggerConfigureHooks + 2581308
9 AppName 0x00000001061fe498 RCTFBQuickPerformanceLoggerConfigureHooks + 2568756
10 AppName 0x00000001061fed38 RCTFBQuickPerformanceLoggerConfigureHooks + 2570964
11 AppName 0x00000001061ed900 RCTFBQuickPerformanceLoggerConfigureHooks + 2500252
12 AppName 0x0000000105231bd8 _ZZGetAppIdTableEvE12arAppIdTable + 57325128
13 libdispatch.dylib 0x00000001824121fc 0x0000000182411000 + 4604
21 UIKit 0x00000001894a4534 UIApplicationMain + 208
22 AppName 0x00000001085b73e8 _ZN15CTXAppidConvert13GetAppIdTableEv + 8521236
23 libdyld.dylib 0x00000001824455b8 0x0000000182441000 + 17848

通过上面的堆栈信息我们看到崩溃的调用栈也停留在了弱符号RCTFBQuickPerformanceLoggerConfigureHooks上,但与我们上面举的例子的不同点是这个崩溃在Bugly上的其他信息一栏里是空的,也就是拿不到基地址,因此我们使用atos命令是不可行的,所以只能在符号表里查找,但是我们要首先要拿到基地址。下面我们来看一下遇到这种情况该怎样拿到基地址。

  1. 首先我们看到 22 AppName 0x00000001085b73e8 str_to_integral_8ExpectedIT_NS_14Conversion + 8521236 这一行信息,熟悉Bugly与crash 日志的同学一定知道, 这一行大概率是main函数,那么我们就在这里找到突破口。
  2. 我们看到main函数的调用栈符号是_ZN15CTXAppidConvert13GetAppIdTableEv,这个函数运行地址是0x00000001085b73e8
  3. 那这个函数的运行起始地址为 0x00000001085b73e8 - 0x8521236 = 0x107D96DD4
  4. 打开符号表,找到_ZN15CTXAppidConvert13GetAppIdTableEv这个符号的偏移地址为 0x7d86dd4
  5. App base addr(基地址): 0x107D96DD4 - 0x7d86dd4 = 0x100010000

这样我们就拿到了这个日志的基地址,然后利用上面的方式在符号表里找到正确的堆栈,因此也就能将这个日志正确的解析了。

2. 百度地图SDK的崩溃问题

除了RNHOOK函数问题,我们还发现有大量的崩溃日志调用栈都指向了百度地图SDK。 image20211116110252571.png 首先我们通过Bugly显示的堆栈信息以为是百度地图SDK的崩溃,这个崩溃在某几个版本中占58同城App总崩溃率的40%左右,是58App内崩溃率最高的一个模块,在更换了新的SDK后崩溃率也并没有下降,而这么高的崩溃率,我们在开发与测试中却从未遇到过。通过Bugly上的跟踪数据我们看到最后的页面记录停留在了金融业务内,而金融业务与百度地图没有任何关系。因此这个崩溃应该与上面描述的一样,解析错误。拿到基地址与运行地址,通过我们自研的工具拿到了正确的堆栈。结果为金融业务使用的一个人脸识别SDK的崩溃,文件名称与Bugly上的跟踪日志也相同。

3. 安居客IM登录问题

我们编写了脚本文件,利用脚本文件定位了一个安居客存在很久的问题。在Bugly上排名比较靠前,崩溃占比很高,Bugly上的堆栈显示异常,因此这个崩溃之前并没有定位到具体原因。脚本协助安居客定位是IMSDK的原因。 image20211116110315205.png

以上是几个比较具有代表性的Bugly解析错误的日志,我们通过研究分析将这些错误的堆栈还原正确并解决了问题。

目前我们支持按版本自动排查出Bugly上前200名崩溃中解析异常的日志,并且可以将异常日志自动符号化成正确的日志。整个过程在符号表已经提前下载并解析好的前提下,只有10秒左右,大大提升了我们日常研发以及解决问题的效率。通过对Bugly上的疑难崩溃的治理,目前为止我们修复了Bugly上70%左右的疑难崩溃,大大降低了58 App的崩溃率。 image20211125150625544.png 除了上述我们研究的Bugly解析异常的日志可以正确解析外,58同城还支持其他异常日志的解析。 例如App内存在段迁移发生崩溃后的日志,段迁移崩溃日志中的库名变成了异常字符、丢失了进程的起始地址,获取到错误的偏移地址,这种情况下我们可以进行自动修正并解析出正确的堆栈信息。

总结与展望

文本首先介绍了我们使用Bugly遇到的RNHOOK函数问题,通过这个问题我们提出Bugly可能解析存在错误的疑问,后续用atos命令以及符号表排查找到了正确的答案,过程中又发现了弱符号的问题。按照这个研究方向我们在集团内做了一系列工具并解决了多个版本的历史遗留问题,大大的降低了58同城iOS App的崩溃率,也提高了日常工作研发效率。

App的性能优化对用户的体验十分重要,而崩溃作为其中最重要的一个环节需要我们持续的钻研与探索。后续我们将持续优化App的性能给用户带来最好的体验。

首发自CSDN:“杀死” App 上的疑难崩溃!


作者:ZYJ
链接:https://juejin.cn/post/7037308047382806565

收起阅读 »

Flutter定制一个ScrollView嵌套webview滚动的效果

场景描述 业务需要在一个滚动布局中嵌入一个webview,但是在Android平台上有一个关于webview高度的bug: 当webview高度过大时会导致Crash甚至手机重启。所以我想到了这样一种布局:最外层是一个ScrollView,内部含有一个定高的可...
继续阅读 »

场景描述


业务需要在一个滚动布局中嵌入一个webview,但是在Android平台上有一个关于webview高度的bug: 当webview高度过大时会导致Crash甚至手机重启。所以我想到了这样一种布局:最外层是一个ScrollView,内部含有一个定高的可以滚动的webview。这里有两个问题:



  1. webview怎么滚动

  2. webview的滚动怎么和外部的ScrollView联动


解决方案


第一个问题可以通过设置gestureRecognizers解决:
gestureRecognizers: [Factory(() => EagerGestureRecognizer())].toSet(),


但是这种方法会导致webview在手势竞争中获胜,外部的ScrollView根本无法获得滚动事件,从而导致webview滚动完全独立于外部ScrollView的滚动,这也是这种布局很少出现的原因。


于是我想到了使用NestedScrollView的方案,但是很明显我需要重新定义,因为我最终想要的效果是这样子的:



OutScrollView 滑动或者Fling时InnerScrollView完全静止。


在滚动InnerScrollView时OutScrollView完全不会滑动,只有在InnerScrollView滑动到边界时才能滑动OutScrollView。如果InnerScrollView Fling, OutScrollView不会Fling,同样的在InnerScrollView边界Fling则会触发OutScrollView的Fling。


下面就是具体方案:
NestedScrollView介入滚动是靠自定义ScrollActivityDelegate开始的,scrollable.dart源码中展示了滚动手势的传递过程:


Scrollable->GestureRecorgnizer->Drag(ScrollDragController)->ScrollActivityDelegate


当用户手指拖动ScrollView时会调用:


ScrollDragController:
@override
void update(DragUpdateDetails details) {
//other codes
delegate.applyUserOffset(offset);
}

当拖动结束时调用:


@override
void end(DragEndDetails details) {
///other codes, goBallistic代表Fling
delegate.goBallistic(velocity);
}

所以自定义ScrollActivityDelegate就是Hook滚动的开始,在NestedScrollView中这个类是_NestedScrollCoordinator, 所以我的思路就是自己定义一个Delegate。下面是魔改的过程:


需要判断InnerScrollView是否在滚动


我强制InnerScrollView必须被我的自定义Widget包裹:


class _NestedInnerScrollChildState extends State<NestedInnerScrollChild> {
@override
Widget build(BuildContext context) {
return Listener(
child: NotificationListener<ScrollEndNotification>(
child: widget.child,
onNotification: (end) {
widget.coordinator._innerTouchingKey = null;
//继续向上冒泡
return false;
},
),
onPointerDown: _startScrollInner,
);
}

void _startScrollInner(_) {
widget.coordinator._innerTouchingKey = widget.scrollKey;
}
}

我使用了Listener onPointerDown 方法来判断用户触摸了inner view, 但是并没有使用onPointerUp或者onPointerCancel来判断滚动结束,原因就是Fling的存在,Fling效果下手指已经离开屏幕但是view可能还在滑动,因此使用ScrollEndNotification这个标记更靠谱。


OutScrollView滑动时完全禁止InnerScrollView的滑动



  1. applyUserOffset的hook


  @override
void applyUserOffset(double delta) {
if (!innerScroll) {
_outerPosition.applyFullDragUpdate(delta);
}
}


  1. Fling


首先会调用Coordinator的goBallistic方法,然后触发beginActivity方法,我们直接在beginActivity中拦截即可:


///_innerPositions并不是所有innerView的集合,这个后面会讲到
if (innerScroll) {
for (final _NestedScrollPosition position in _innerPositions) {
final ScrollActivity newInnerActivity = innerActivityGetter(position);
position.beginActivity(newInnerActivity);
scrolling = newInnerActivity.isScrolling;
}
}

InnerScrollView和OutScrollView嵌套滑动



  1. applyUserOffset


借鉴NestedScrollView即可


@override
void applyUserOffset(double delta) {
double remainDelta = innerPositionList.first.applyClampedDragUpdate(delta);
if (remainDelta != 0.0) {
_outerPosition.applyFullDragUpdate(remainDelta);
}
}


  1. Fling


innerView触发Fling手势的调用链:ScrollDragController会调用ScrollActivityDelegate的goBallistic方法->触发ScrollPosition的beginActivity方法并创建BallisticScrollActivity实例->BallisticScrollActivity实例结合Simulation不断计算滚动距离。


BallisticScrollActivity有个方法:


 /// Move the position to the given location.
///
/// If the new position was fully applied, returns true. If there was any
/// overflow, returns false.
///
/// The default implementation calls [ScrollActivityDelegate.setPixels]
/// and returns true if the overflow was zero.
@protected
bool applyMoveTo(double value) {
return delegate.setPixels(value) == 0.0;
}

当这个方法返回false时就会立刻停止滚动,正好NestedScrollView有创建自定义OutBallisticScrollActivity方法,所以我在applyMove那里判断如果是innerView 正在滚动就返回false


  @override
bool applyMoveTo(double value) {
if (coordinator.innerScroll) {
return false;
}
// other codes
}

当然,这里也可以加个优化:比如innerView如果在边界触发了Fling就可以放开。


支持多个inner scroll view


outview只能有一个,但是innerView理论上可以有多个,我这里贴下参考的文章链接[:]("Flutter 扩展NestedScrollView (二)列表滚动同步解决 - 掘金 (juejin.cn)")。核心就是在ScrollController attach detach时实现position和ScrollView的绑定。


实现webview的滚动


这里我也是借鉴的大神的思路[:](大道至简:Flutter嵌套滑动冲突解决之路 - V大师在一号线 (vimerzhao.top))


Flutter中所有的滚动View最终都是用Scrollable+Viewport来实现的,Scrollable负责获取滚动手势,距离计算等,而绘制则交给Viewport来实现。翻看viewport.dart相关源码,我贴下paint的方法:


@override
void paint(PaintingContext context, Offset offset) {
if (firstChild == null)
return;
if (hasVisualOverflow && clipBehavior != Clip.none) {
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Offset.zero & size,
_paintContents,
clipBehavior: clipBehavior,
oldLayer: _clipRectLayer.layer,
);
} else {
_clipRectLayer.layer = null;
_paintContents(context, offset);
}
}

void _paintContents(PaintingContext context, Offset offset) {
for (final RenderSliver child in childrenInPaintOrder) {
if (child.geometry!.visible)
context.paintChild(child, offset + paintOffsetOf(child));
}
}

paintOffsetOf(child)就可以简化为滚动导致的绘制偏差。举个栗子:一个viewport高500,内容高度1000,默认绘制[0-500]的内容,当用户向上滑动了100,则绘制[100,600]的内容,这里的100就是paintOffset。


所以我最后创建了一个自定义Viewport,但是Flutter端绘制时paintOffset始终传0,我把真正的offset传递给webview,然后调用window.scrollTo(0,offset)即可实现webview内容的滑动了。简而言之,传统的ScrollView是内容不动,画布在动,而我的方案就是画布不动,但是内容在动。参考代码:[]("inner_scroll_webview.dart (github.com)")


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

iOS 实现类似探探、陌陌的卡片左滑右滑效果

iOS
本文章分析怎么实现这种卡片效果以及都有哪些功能,基于这些功能是否可以完善,让框架更加灵活,可拓展等。现已封装成通用框架。效果图如下: 代码地址 1、功能分析 不管是探探还是陌陌的点点匹配模块,都是对卡片的左滑右滑进行的操作,那么以陌陌的点点匹配模块分析,所涉...
继续阅读 »

本文章分析怎么实现这种卡片效果以及都有哪些功能,基于这些功能是否可以完善,让框架更加灵活,可拓展等。现已封装成通用框架。效果图如下:


效果图


代码地址


1、功能分析


不管是探探还是陌陌的点点匹配模块,都是对卡片的左滑右滑进行的操作,那么以陌陌的点点匹配模块分析,所涉及的功能有:



  • 卡片复用机制

  • 拖拽卡片时的动画

    • 卡片拖拽左上方、左下方、右上方、右下方都会有相应的旋转动画



  • 喜欢功能

    • 喜欢功能又分为拖拽卡片向右侧滑出、点击下方的喜欢按钮控制卡片滑出。



  • 不喜欢功能

    • 不喜欢功能也是和喜欢功能逻辑一样,只是方向不同。



  • 超级喜欢功能

    • 超级喜欢功能跟喜欢功能一样,只不过是针对的vip用户,普通用户需要充值vip才能使用,vip用户触发超级喜欢,会有一个炫丽的动画特效,并进行其它业务处理



  • 回退功能

    • 回退功能也是针对vip用户设计的,vip用户单次只能回退一张卡片。我们实现回退多张功能,让外界控制是否可以回退功能,更加灵活。



  • 预加载功能

    • 卡片可以无限的滑走,那么数据源获取就得支持加载更多数据。



  • 无数据的处理功能

    • 卡片数据操作完了,就得处理无数据的情况,显示占位图等等。



  • 触发卡片喜欢功能时,需要检测是否允许此次操作

    • 这个功能也是根据业务去做,探探里面,好像对喜欢的操作是有限制的,如果超出了这个限制,再次触发喜欢功能就会提示充值vip同时拖拽的卡片也会恢复原位。






2、功能实现


完成了上面的功能分析之后,接下来就可以一个一个的去实现了。




  • 卡片复用机制


    这里用了4张卡片,最上面的卡片划走之后,会被放在最下面一层,达到复用。




  • 拖拽卡片时的动画


    卡片拖拽左上方、左下方、右上方、右下方都会有相应的旋转动画。我们可以确定用户手势触发的点的位置,根据方位进行设置相关的旋转角度,左右是相反的。




  • 喜欢、不喜欢功能


    1、通过拖拽手势划走卡片
    给每个卡片添加一个拖拽手势,当拖拽卡片的时候,根据拖拽的距离和卡片原始的中心点X值进行判断卡片是向左还是向右,拖拽结束的时候,通过改变卡片的位置并加上动画,达到卡片划走的效果。


    2、通过按钮触发划走卡片
    按钮触发的时候,指定卡片的x位置。然后内部统一走手势结束的方法。




  • 超级喜欢功能
    超级喜欢功能,其实也是喜欢的一种,通过按钮触发喜欢操作,之后加上自己的炫丽动画以及业务逻辑.




  • 回退功能


    卡片回退的实现,将最底下的卡片放到最上面的卡片上面并加上入场动画,同时更新对应的索引数据。
    陌陌vip用户只支持回退一张,我们可以设计支持多张,有多少张不喜欢卡片,默认回退多少张,如果想要实现一张也可以,外界可以控制。相关的方法如下:

    这里回退操作的场景很多,比如:左滑10次,然后右滑4次,回退5次,在一次次的滑动,那么怎么保证卡片是按照正常的顺序显示的呢。这里用了2个数组处理的,第一个数组保存左滑的数据index,第二个数组保存回退的index。具体的思路看下截图:




  • 预加载功能
    每次划走一个卡片,都会代理回调对应的数据源Index供上层更新底部的卡片显示内容, 卡片划走的时候,也会做校验,看看当前的index相对于数据源总数是否小于一个值,这个值我们称为阀值。小于这个阀值会触发加载更多的代理回调。




  • 无数据的处理功能
    每次划走一个卡片,都会更新底部卡片显示内容,如果内部卡片的数据index超出了外界的数据源总数,则将卡片内容隐藏,也会做无数据的检测。




  • 触发卡片喜欢功能时,需要检测是否允许此次操作
    可以在拖拽手势结束的时候,通过代理去询问是否允许滑走,如果不允许则内部更改拖动的距离x值,走复位逻辑。






3、总结


卡片交互的细节很多,很多控制的地方也很多,封装的框架现已支持上面的所有功能, 使用的时候,可以自定义卡片cell实现自己的样式。提供的有示例demo. 欢迎预览。


作者:大大的太阳
链接:https://juejin.cn/post/7036769362652430372
收起阅读 »

现代配置指南——YAML 比 JSON 高级在哪?

一直以来,前端工程中的配置大多都是 .js 文件或者 .json 文件,最常见的比如:package.jsonbabel.config.jswebpack.config.js这些配置对前端非常友好,因为都是我们熟悉的 JS 对象结构。一般静态化的配置会选择 j...
继续阅读 »

一直以来,前端工程中的配置大多都是 .js 文件或者 .json 文件,最常见的比如:

  • package.json

  • babel.config.js

  • webpack.config.js

这些配置对前端非常友好,因为都是我们熟悉的 JS 对象结构。一般静态化的配置会选择 json 文件,而动态化的配置,涉及到引入其他模块,因此会选择 js 文件。

还有现在许多新工具同时支持多种配置,比如 Eslint,两种格式的配置任你选择:

  • .eslintrc.json

  • .eslintrc.js

后来不知道什么时候,突然出现了一种以 .yaml.yml 为后缀的配置文件。一开始以为是某个程序的专有配置,后来发现这个后缀的文件出现的频率越来越高,甚至 Eslint 也支持了第三种格式的配置 .eslintrc.yml

既然遇到了,那就探索它!

下面我们从 YAML 的出现背景使用场景具体用法高级操作四个方面,看一下这个流行的现代化配置的神秘之处。

出现背景

一个新工具的出现避免不了有两个原因:

  1. 旧工具在某些场景表现吃力,需要更优的替代方案

  2. 旧工具也没什么不好,只是新工具出现,比较而言显得它不太好

YAML 这种新工具就属于后者。其实在 yaml 出现之前 js+json 用的也不错,也没什么特别难以处理的问题;但是 yaml 出现以后,开始觉得它好乱呀什么东西,后来了解它后,越用越喜欢,一个字就是优雅。

很多文章说选择 yaml 是因为 json 的各种问题,json 不适合做配置文件,这我觉得有些言过其实了。我更愿意将 yaml 看做是 json 的升级,因为 yaml 在格式简化和体验上表现确实不错,这个得承认。

下面我们对比 YAML 和 JSON,从两方面分析:

精简了什么?

JSON 比较繁琐的地方是它严格的格式要求。比如这个对象:

{
 name: 'ruims'
}

在 JSON 中以下写法通通都是错的:

// key 没引号不行
{
 name: 'ruims'
}
// key 不是 "" 号不行
{
 'name': 'ruims'
}
// value 不是 "" 号不行
{
 "name": 'ruims'
}

字符串的值必须 k->v 都是 "" 才行:

// 只能这样
{
 "name": "ruims"
}

虽然是统一格式,但是使用上确实有不便利的地方。比如我在浏览器上测出了接口错误。然后把参数拷贝到 Postman 里调试,这时就我要手动给每个属性和值加 "" 号,非常繁琐。

YAML 则是另辟蹊径,直接把字符串符号干掉了。上面对象的同等 yaml 配置如下:

name: ruims

没错,就这么简单!

除了 "" 号,yaml 觉得 {}[] 这种符号也是多余的,不如一起干掉。

于是呢,以这个对象数组为例:

{
 "names": [{ "name": "ruims" }, { "name": "ruidoc" }]
}

转换成 yaml 是这样的:

names:
- name: ruims
- name: ruidoc

对比一下这个精简程度,有什么理由不爱它?

增加了什么?

说起增加的部分,最值得一提的,是 YAML 支持了 注释

用 JSON 写配置是不能有注释的,这就意味着我们的配置不会有备注,配置多了会非常凌乱,这是最不人性化的地方。

现在 yaml 支持了备注,以后配置可以是这样的:

# 应用名称
name: my_app
# 应用端口
port: 8080

把这种配置丢给新同事,还怕他看不懂配了啥吗?

除注释外,还支持配置复用的相关功能,这个后面说。

使用场景

我接触的第一个 yaml 配置是 Flutter 项目的包管理文件 pubspec.yaml,这个文件的作用和前端项目中的 package.json 一样,用于存放一些全局配置和应用依赖的包和版本。

看一下它的基本结构:

name: flutter_demo
description: A new Flutter project.

publish_to: 'none'
version: 1.0.0

dependencies:
cupertino_icons: ^1.0.2

dev_dependencies:
flutter_lints: ^1.0.0

你看这个结构和 package.json 是不是基本一致?dependencies 下列出应用依赖和版本,dev_dependencies 下的则是开发依赖。

后来在做 CI/CD 自动化部署的时候,我们用到了 GitHub Action。它需要多个 yaml 文件来定义不同的工作流,这个配置可比 flutter 复杂的多。

其实不光 GitHub Action,其他流行的类似的构建工具如 GitLab CI/CDcircleci,全部都是齐刷刷的 yaml 配置,因此如果你的项目要做 CI/CD 持续集成,不懂 yaml 语法肯定是不行的。

还有,接触过 Docker 的同学肯定知道 Docker Compose,它是 Docker 官方的单机编排工具,其配置文件 docker-compose.yml 也是妥妥的 yaml 格式。现在 Docker 正是如日中天的时候,使用 Docker 必然免不了编排,因此 yaml 语法早晚也要攻克。

上面说的这 3 个案例,几乎都是现代最新最流行的框架/工具。从它们身上可以看出来,yaml 必然是下一代配置文件的标准,并且是前端-后端-运维的通用标准。

说了这么多,你跃跃欲试了吗?下面我们详细介绍 yaml 语法。

YAML 语法

介绍 yaml 语法会对比 json 解释,以便我们快速理解。

先看一下 yaml 的几个特点:

  • 大小写敏感

  • 使用缩进表示层级关系

  • 缩进空格数不强制,但相同层级要对齐

  • # 表示注释

相比于 JSON 来说,最大的区别是用 缩进 来表示层级,这个和 Python 非常接近。还有强化的一点是支持了注释,JSON 默认是不支持的(虽然 TS 支持),这也对配置文件非常重要。

YAML 支持以下几种数据结构:

  • 对象:json 中的对象

  • 数组:json 中的数组

  • 纯量:json 中的简单类型(字符串,数值,布尔等)

对象

先看对象,上一个 json 例子:

{
 "id": 1,
 "name": "杨成功",
 "isman": true
}

转换成 yaml:

id: 1
name: 杨成功
isman: true

对象是最核心的结构,key 值的表示方法是 [key]:,注意这里冒号后面有个空格,一定不能少。value 的值就是一个纯量,且默认不需要引号。

数组

数组和对象的结构差不多,区别是在 key 前用一个 - 符号标识这个是数组项。注意这里也有一个空格,同样也不能少。

- hello
- world

转换成 JSON 格式如下:

["hello", "world"]

了解了基本的对象和数组,我们再来看一个复杂的结构。

众所周知,在实际项目配置中很少有简单的对象或数组,大多都是对象和数组相互嵌套而成。在 js 中我们称之为对象数组,而在 yaml 中我们叫 复合结构

比如这样一个稍复杂的 JSON:

{
 "name": "杨成功",
 "isman": true,
 "age": 25,
 "tag": ["阳光", "帅气"],
 "address": [
  { "c": "北京", "a": "海淀区" },
  { "c": "天津", "a": "滨海新区" }
]
}

转换成复合结构的 YAML:

name: 杨成功
isman: true
age: 25
tag:
- 阳光
- 帅气
address:
- c: 北京
  a: 海淀区
- c: 天津
  a: 滨海新区

若你想尝试更复杂结构的转换,可以在 这个 网页中在线实践。

纯量

纯量比较简单,对应的就是 js 的基本数据类型,支持如下:

  • 字符串

  • 布尔

  • 数值

  • null

  • 时间

比较特殊的两个,null 用 ~ 符号表示,时间大多用 2021-12-21 这种格式表示,如:

who: ~
date: 2019-09-10

转换成 JS 后:

{
 who: null,
 date: new Date('2019-09-10')
}

高级操作

在 yaml 实战过程中,遇到过一些特殊场景,可能需要一些特殊的处理。

字符串过长

在 shell 中我们常见到一些参数很多,然后特别长的命令,如果命令都写在一行的话可读性会非常差。

假设下面的是一条长命令:

$ docker run --name my-nginx -d nginx

在 linux 中可以这样处理:

$ docker run \
--name my-nginx \
-d nginx

就是在每行后加 \ 符号标识换行。然而在 YAML 中更简单,不需要加任何符号,直接换行即可:

cmd: docker run
--name my-nginx
-d nginx

YAML 默认会把换行符转换成空格,因此转换后 JSON 如下,正是我们需要的:

{ "cmd": "docker run --name my-nginx -d nginx" }

然而有时候,我们的需求是保留换行符,并不是把它转换成空格,又该怎么办呢?

这个也简单,只需要在首行加一个 | 符号:

cmd: |
docker run
--name my-nginx
-d nginx

转换成 JSON 变成了这样:

{ "cmd": "docker run\n--name my-nginx\n-d nginx" }

获取配置

获取配置是指,在 YAML 文件中定义的某个配置,如何在代码(JS)里获取?

比如前端在 package.json 里有一个 version 的配置项表示应用版本,我们要在代码中获取版本,可以这么写:

import pack from './package.json'
console.log(pack.version)

JSON 是可以直接导入的,YAML 可就不行了,那怎么办呢?我们分环境解析:

在浏览器中

浏览器中代码用 webapck 打包,因此加一个 loader 即可:

$ yarn add -D yaml-loader

然后配置 loader:

// webpack.config.js
module.exports = {
 module: {
   rules: [
    {
       test: /\.ya?ml$/,
       type: 'json', // Required by Webpack v4
       use: 'yaml-loader'
    }
  ]
}
}

在组件中使用:

import pack from './package.yaml'
console.log(pack.version)

在 Node.js 中

Node.js 环境下没有 Webpack,因此读取 yaml 配置的方法也不一样。

首先安装一个 js-yaml 模块:

$ yarn add js-yaml

然后通过模块提供的方法获取:

const yaml = require('js-yaml')
const fs = require('fs')

const doc = yaml.load(fs.readFileSync('./package.yaml', 'utf8'))
console.log(doc.version)

配置项复用

配置项复用的意思是,对于定义过的配置,在后面的配置直接引用,而不是再写一遍,从而达到复用的目的。

YAML 中将定义的复用项称为锚点,用& 标识;引用锚点则用 * 标识。

name: &name my_config
env: &env
version: 1.0

compose:
key1: *name
key2: *env

对应的 JSON 如下:

{
 "name": "my_config",
 "env": { "version": 1 },
 "compose": { "key1": "my_config", "key2": { "version": 1 } }
}

但是锚点有个弊端,就是不能作为 变量 在字符串中使用。比如:

name: &name my_config
compose:
key1: *name
key2: my name is *name

此时 key2 的值就是普通字符串 my name is *name,引用变得无效了。

其实在实际开发中,字符串中使用变量还是很常见的。比如在复杂的命令中多次使用某个路径,这个时候这个路径就应该是一个变量,在多个命令中复用。

GitHub Action 中有这样的支持,定义一个环境变量,然后在其他的地方复用:

env:
NAME: test
describe: This app is called ${NAME}

这种实现方式与 webpack 中使用环境变量类似,在构建的时候将变量替换成对应的字符串。

作者:杨成功
来源:https://segmentfault.com/a/1190000041108051

收起阅读 »

LOOK 直播活动地图生成器方案

在最近的活动开发中,笔者就刚好碰到了这个问题。这次活动开发需要完成一款大富翁游戏,而作为一款大富翁游戏,地图自然是必不可少的。在整个地图中,有很多的不同种类的方格,如果一个个手动去调整位置,工作量是很大的。那么有没有一种方案能够帮助我们快速确定方格的位置和种类...
继续阅读 »

对于前端而言,与视觉稿打交道是必不可少的,因为我们需要对照着视觉稿来确定元素的位置、大小等信息。如果是比较简单的页面,手动调整每个元素所带来的工作量尚且可以接受;然而当视觉稿中素材数量较大时,手动调整每个元素便不再是个可以接受的策略了。

在最近的活动开发中,笔者就刚好碰到了这个问题。这次活动开发需要完成一款大富翁游戏,而作为一款大富翁游戏,地图自然是必不可少的。在整个地图中,有很多的不同种类的方格,如果一个个手动去调整位置,工作量是很大的。那么有没有一种方案能够帮助我们快速确定方格的位置和种类呢?下面便是笔者所采用的方法。

方案简述

位点图

首先,我们需要视觉同学提供一张特殊的图片,称之为位点图。

这张图片要满足以下几个要求:

  1. 在每个方格左上角的位置,放置一个 1px 的像素点,不同类型的方格用不同颜色表示。

  2. 底色为纯色:便于区分背景和方格。

  3. 大小和地图背景图大小一致:便于从图中读出的坐标可以直接使用。

bitmap

上图为一个示例,在每个路径方格左上角的位置都有一个 1px 的像素点。为了看起来明显一点,这里用红色的圆点来表示。在实际情况中,不同的点由于方格种类不同,颜色也是不同的。

bitmap2

上图中用黑色边框标出了素材图的轮廓。可以看到,红色圆点和每个路径方格是一一对应的关系。

读取位点图

在上面的位点图中,所有方格的位置和种类信息都被标注了出来。我们接下来要做的,便是将这些信息读取出来,并生成一份 json 文件来供我们后续使用。

const JImp = require('jimp');
const nodepath = require('path');

function parseImg(filename) {
   JImp.read(filename, (err, image) => {
       const { width, height } = image.bitmap;

       const result = [];

       // 图片左上角像素点的颜色, 也就是背景图的颜色
       const mask = image.getPixelColor(0, 0);

       // 筛选出非 mask 位置点
       for (let y = 0; y < height; ++y) {
           for (let x = 0; x < width; ++x) {
               const color = image.getPixelColor(x, y);
               if (mask !== color) {
                   result.push({
                       // x y 坐标
                       x,
                       y,
                       // 方格种类
                       type: color.toString(16).slice(0, -2),
                  });
              }
          }
      }

       // 输出
       console.log(JSON.stringify({
           // 路径
           path: result,
      }));
  });
}

parseImg('bitmap.png');

在这里我们使用了 jimp 用于图像处理,通过它我们能够去扫描这张图片中每个像素点的颜色和位置。

至此我们得到了包含所有方格位置和种类信息的 json 文件:

{
   "path": [
      {
           "type": "",
           "x": 0,
           "y": 0,
      },
       // ...
  ],
}

其中,x y 为方格左上角的坐标;type 为方格种类,值为颜色值,代表不同种类的地图方格。

通路连通算法

对于我们的项目而言,只确定路径点是不够的,还需要将这些点连接成一个完整的通路。为此,我们需要找到一条由这些点构成的最短连接路径。

代码如下:

function takePath(point, points) {
   const candidate = (() => {
       // 按照距离从小到大排序
       const pp = [...points].filter((i) => i !== point);
       const [one, two] = pp.sort((a, b) => measureLen(point, a) - measureLen(point, b));

       if (!one) {
           return [];
      }

       // 如果两个距离 比较小,则穷举两个路线,选择最短连通图路径。
       if (two && measureLen(one, two) < 20000) {
           return [one, two];
      }
       return [one];
  })();

   let min = Infinity;
   let minPath = [];
   for (let i = 0; i < candidate.length; ++i) {
       // 递归找出最小路径
       const subpath = takePath(candidate[i], removeItem(points, candidate[i]));

       const path = [].concat(point, subpath);
       // 测量路径总长度
       const distance = measurePathDistance(path);

       if (distance < min) {
           min = distance;
           minPath = subpath;
      }
  }

   return [].concat(point, minPath);
}

到这里,我们已经完成了所有的准备工作,可以开始绘制地图了。在绘制地图时,我们只需要先读取 json 文件,再根据 json 文件内的坐标信息和种类信息来放置对应素材即可。

方案优化

上述方案能够解决我们的问题,但仍有一些不太方便的地方:

  1. 只有 1px 的像素点太小了,肉眼无法辨别。不管是视觉同学还是开发同学,如果点错了位置就很难排查。

  2. 位点图中包含的信息还是太少了,颜色仅仅对应种类,我们希望能够包含更多的信息,比如点之间的排列顺序、方格的大小等。

像素点合并

对于第一个问题,我们可以让视觉同学在画图的时候,将 1px 的像素点扩大成一个肉眼足够辨识的区域。需要注意两个区域之间不要有重叠。

bitmap3

这时候就要求我们对代码做一些调整。在之前的代码中,当我们扫描到某个颜色与背景色不同的点时,会直接记录其坐标和颜色信息;现在当我们扫描到某个颜色与背景色不同的点时,还需要进行一次区域合并,将所有相邻且相同颜色的点都纳入进来。

区域合并的思路借鉴了下图像处理的区域生长算法。区域生长算法的思路是以一个像素点为起点,将该点周围符合条件的点纳入进来,之后再以新纳入的点为起点,向新起点相邻的点扩张,直到所有符合条件条件的点都被纳入进来。这样就完成了一次区域合并。不断重复该过程,直到整个图像中所有的点都被扫描完毕。

我们的思路和区域生长算法非常类似:

  1. 依次扫描图像中的像素点,当扫描到颜色与背景色不同的点时,记录下该点的坐标和颜色。

    步骤1.png

  2. 之后扫描与该点相邻的 8 个点,将这些点打上”已扫描“的标记。筛选出其中颜色与背景色不同且尚未被扫描过的点,放入待扫描的队列中。

    步骤2.png

  3. 从待扫描队列中取出下一个需要扫描的点,重复步骤 1 和步骤 2。

  4. 直到待扫描的队列为空时,我们就扫描完了一整个有颜色的区域。区域合并完毕。

    步骤3.png

const JImp = require('jimp');

let image = null;
let maskColor = null;

// 判断两个颜色是否为相同颜色 -> 为了处理图像颜色有误差的情况, 不采用相等来判断
const isDifferentColor = (color1, color2) => Math.abs(color1 - color2) > 0xf000ff;

// 判断是(x,y)是否超出边界
const isWithinImage = ({ x, y }) => x >= 0 && x < image.width && y >= 0 && y < image.height;

// 选择数量最多的颜色
const selectMostColor = (dotColors) => { /* ... */ };

// 选取左上角的坐标
const selectTopLeftDot = (reginDots) => { /* ... */ };

// 区域合并
const reginMerge = ({ x, y }) => {
   const color = image.getPixelColor(x, y);
   // 扫描过的点
   const reginDots = [{ x, y, color }];
   // 所有扫描过的点的颜色 -> 扫描完成后, 选择最多的色值作为这一区域的颜色
   const dotColors = {};
   dotColors[color] = 1;

   for (let i = 0; i < reginDots.length; i++) {
       const { x, y, color } = reginDots[i];

       // 朝临近的八个个方向生长
       const seeds = (() => {
           const candinates = [/* 左、右、上、下、左上、左下、右上、右下 */];

           return candinates
               // 去除超出边界的点
              .filter(isWithinImage)
               // 获取每个点的颜色
              .map(({ x, y }) => ({ x, y, color: image.getPixelColor(x, y) }))
               // 去除和背景色颜色相近的点
              .filter((item) => isDifferentColor(item.color, maskColor));
      })();

       for (const seed of seeds) {
           const { x: seedX, y: seedY, color: seedColor } = seed;

           // 将这些点添加到 reginDots, 作为下次扫描的边界
           reginDots.push(seed);

           // 将该点设置为背景色, 避免重复扫描
           image.setPixelColor(maskColor, seedX, seedY);

           // 该点颜色为没有扫描到的新颜色, 将颜色增加到 dotColors 中
           if (dotColors[seedColor]) {
               dotColors[seedColor] += 1;
          } else {
               // 颜色为旧颜色, 增加颜色的 count 值
               dotColors[seedColor] = 1;
          }
      }
  }

   // 扫描完成后, 选择数量最多的色值作为区域的颜色
   const targetColor = selectMostColor(dotColors);

   // 选择最左上角的坐标作为当前区域的坐标
   const topLeftDot = selectTopLeftDot(reginDots);

   return {
       ...topLeftDot,
       color: targetColor,
  };
};

const parseBitmap = (filename) => {
   JImp.read(filename, (err, img) => {
       const result = [];
       const { width, height } = image.bitmap;
       // 背景颜色
       maskColor = image.getPixelColor(0, 0);
       image = img;

       for (let y = 0; y < height; ++y) {
           for (let x = 0; x < width; ++x) {
               const color = image.getPixelColor(x, y);

               // 颜色不相近
               if (isDifferentColor(color, maskColor)) {
                   // 开启种子生长程序, 依次扫描所有临近的色块
                   result.push(reginMerge({ x, y }));
              }
          }
      }
  });
};

颜色包含额外信息

在之前的方案中,我们都是使用颜色值来表示种类,但实际上颜色值所能包含的信息还有很多。

一个颜色值可以用 rgba 来表示,因此我们可以让 r、g、b、a 分别代表不同的信息,如 r 代表种类、g 代表宽度、b 代表高度、a 代表顺序。虽然 rgba 每个的数量都有限(r、g、b 的范围为 0-255,a 的范围为 0-99),但基本足够我们使用了。

rgba.png

当然,你甚至可以再进一步,让每个数字都表示一种信息,不过这样每种信息的范围就比较小,只有 0-9。

总结

对于素材量较少的场景,前端可以直接从视觉稿中确认素材信息;当素材量很多时,直接从视觉稿中确认素材信息的工作量就变得非常大,因此我们使用了位点图来辅助我们获取素材信息。

无标题-2021-09-28-1450.png

地图就是这样一种典型的场景,在上面的例子中,我们已经通过从位点图中读出的信息成功绘制了地图。我们的步骤如下:

  1. 视觉同学提供位点图,作为承载信息的载体,它需要满足以下三个要求:

    1. 大小和地图背景图大小一致:便于我们从图中读出的坐标可以直接使用。

    2. 底色为纯色:便于区分背景和方格。

    3. 在每个方格左上角的位置,放置一个方格,不同颜色的方格表示不同类型。

  2. 通过 jimp 扫描图片上每个像素点的颜色,从而生成一份包含各个方格位置和种类的 json。

  3. 绘制地图时,先读取 json 文件,再根据 json 文件内的坐标信息和种类信息来放置素材。

上述方案并非完美无缺的,在这里我们主要对于位点图进行了改进,改进方案分为两方面:

  1. 由于 1px 的像素点对肉眼来说过小,视觉同学画图以及我们调试的时候,都十分不方便。因此我们将像素点扩大为一个区域,在扫描时,对相邻的相同颜色的像素点进行合并。

  2. 让颜色的 rgba 分别对应一种信息,扩充位点图中的颜色值能够给我们提供的信息。

我们在这里只着重讲解了获取地图信息的部分,至于如何绘制地图则不在本篇的叙述范围之内。在我的项目中使用了 pixi.js 作为引擎来渲染,完整项目可以参考这里,在此不做赘述。

FAQ

  • 在位点图上,直接使用颜色块的大小作为路径方格的宽高可以不?

    当然可以。但这种情况是有局限性的,当我们的素材很多且彼此重叠的时候,如果依然用方块大小作为宽高,那么在位点图上的方块就会彼此重叠,影响我们读取位置信息。

  • 如何处理有损图的情况?

    有损图中,图形边缘处的颜色和中心的颜色会略微有所差异。因此需要增加一个判断函数,只有扫描到的点的颜色与背景色的差值大于某个数字后,才认为是不同颜色的点,并开始区域合并。同时要注意在位点图中方块的颜色尽量选取与背景色色值相差较大的颜色。

    这个判断函数,就是我们上面代码中的 isDifferentColor 函数。

    const isDifferentColor = (color1, color2) => Math.abs(color1 - color2) > 0xf000ff;
  • 判断两个颜色不相等的 0xf000ff 是怎么来的?

    随便定的。这个和图片里包含颜色有关系,如果你的背景色和图片上点的颜色非常相近的话,这个值就需要小一点;如果背景色和图上点的颜色相差比较大,这个值就可以大一点。

参考资料

作者:李一笑
来源:https://segmentfault.com/a/1190000041115022

收起阅读 »

一个Vue3可使用的JSON转excel组件

JSON to Excel for VUE3在浏览器中将JSON格式数据以excel文件的形式下载。该组件是基于this thread 提出的解决方案。支持Vue3.2.25及以上版本使用重要提示! Microsoft Excel中的额外提示此组件中实现的方法...
继续阅读 »



JSON to Excel for VUE3

在浏览器中将JSON格式数据以excel文件的形式下载。该组件是基于this thread 提出的解决方案。支持Vue3.2.25及以上版本使用

重要提示! Microsoft Excel中的额外提示

此组件中实现的方法使用HTML表绘制。在xls文件中,Microsoft Excel不再将HTML识别为本机内容,因此在打开文件之前会显示警告消息。excel的内容已经完美呈现,但是提示信息无法避免,请不要在意!

Getting started

安装依赖:

npm install vue3-json-excel

在vue3的应用入口处有两种注册组件的方式:

import Vue from "vue"
import {vue3JsonExcel} from "vue3-json-excel"

Vue.component("vue3JsonExcel", vue3JsonExcel)

或者

import Vue from "vue"
import vue3JsonExcel from "vue3-json-excel"

Vue.use(vue3JsonExcel)

在template文件中直接使用即可

<vue3-json-excel :json-data="json_data">
Download Data
</vue3-json-excel>

Props List

NameTypeDescriptionDefaultremark
json-dataArray即将导出的数据

fieldsObject要导出的JSON对象内的字段。如果未提供任何属性,将导出JSON中的所有属性。

export-fields (exportFields)Object用于修复使用变量字段的其他组件的问题,如vee-validate。exportFields的工作原理与fields完全相同

typestringMime 类型 [xls, csv]xls1.0.x版本暂时只支持xls,csv会在下个版本迭代
namestringFile 导出的文件名jsonData.xls
headerstring/Array数据的标题。可以是字符串(一个标题)或字符串数组(多个标题)。

title(deprecated)string/Array与header相同,title是出于追溯兼容性目的而维护的,但由于与HTML5 title属性冲突,不建议使用它。

License

MIT

Status

该项目处于早期开发阶段。欢迎参与共建。
有好的产品建议可以联系我!!!!

npm地址

vue3-json-excel

作者:小章鱼
来源:https://segmentfault.com/a/1190000041117522

收起阅读 »

kotlin函数

1.概念函数是执行操作并可以返回值的离散代码块。在 Kotlin 中,函数是使用 fun 关键字声明的,并且可以使用接收具名值或默认值的参数。与特定类关联的函数称为方法。一个用于执行特定任务的代码块它可以将大型的程序分解为小型的模块使用关键字 fun 来声明可...
继续阅读 »

1.概念

函数是执行操作并可以返回值的离散代码块。在 Kotlin 中,函数是使用 fun 关键字声明的,并且可以使用接收具名值或默认值的参数。与特定类关联的函数称为方法。

  • 一个用于执行特定任务的代码块
  • 它可以将大型的程序分解为小型的模块
  • 使用关键字 fun 来声明
  • 可以通过参数接收具名值或默认值


2.函数的组成部分

我们可以使用 fun 关键字,并紧跟一个函数名来定义一个函数。例如:
fun sayHello(text:String) :Unit{
println("hello world!")
}

其中 sayHello就是函数名小括号()内text是函数参数大括号{}中是函数具体代码Unit是返回值(当函数没有任何返回的时候那它的返回值便是UnitUnit 类型只包含一个值,即:Unit 。返回Unit类型的声明是可选的。)。

以上函数等价于:

fun sayHello(text:String){
println("hello world!")
}

2.1函数参数

函数可能会有:

  • 默认参数

  • 必选参数

  • 具名参数

我们重新编写一个 sayHello 函数,这个函数接收一个 String 类型的参数: name,并且会打印出输入的姓名。name 的默认值为 "张三"。

fun sayHello(name: String = "张三") {
println("你好:${name}")
}

sayHello() =>你好:张三

sayHello("李四") =>你好:李四

sayHello(name="王五") =>你好:王五

在 main() 函数中,有三种方法可以调用 sayHello() 函数:

  • 在调用函数时,使用默认参数;
  • 调用函数时,不使用参数名传入 speed;
  • 调用函数时,传入名为 speed 的参数。

如果没有为某个参数指定默认值,则该参数为必选参数。

fun sayHello(name: String) {
println("你好:${name}")
}

sayHello() =>编译错误:Kotlin: No value passed for parameter 'name'

sayHello("李四") =>你好:李四

sayHello(name="王五") =>你好:王五

为了提升可读性,可以向必选参数传入具名值。就是我们上述代码中的 sayHello(name="王五")调用方式 。

函数可以同时拥有默认参数和必选参数。我们改造一下sayHello函数 。

fun sayHello(name: String, age: Int = 6, sex: String = "男") {
println("你好:${name} ,你的年龄:$age ,你的性别:$sex")
}
sayHello(name = "王五") =>你好:王五,你的年龄:6,你的性别:男

sayHello("王五",sex = "女") =>你好:王五 ,你的年龄:6 ,你的性别:女

参数可以通过它们的名称进行传递 (具名参数)。在函数包含大量参数或者默认参数时,具名参数非常方便。默认参数和具名参数可以减少重载并优化代码可读性。


3.紧凑函数

如果一个函数只返回了一个表达式,那么它的大括号可以省略,而函数体可以在 “=” 符号后指定。

fun getSum(x: Int, y: Int): Int {          =>完整版
return x + y
}

fun getSum(x: Int, y: Int): Int = x + y =>紧凑版


4.Lambda 表达式与高阶函数

Kotlin 中函数是 first-class(头等函数)

  • Kotlin 的函数可以储存在变量和数据结构中
  • 函数可以作为其他高阶函数的参数和返回值
  • 可以使用高阶函数创建新的 “内建” 函数

4.1 Lambda 函数

除了传统的命名函数外,Kotlin 还支持 lambda 表达式。lambda 是用来创建函数的表达式,但不同于声明已命名的函数,以这种方式声明的函数没有名称。lambda 很好用的一点便是可以作为数据传递。在其他编程语言中,lambda 也被称为匿名函数、函数字面量或其他类似的名称。
像具名函数一样,lambda 也可以有参数。对于 lambda 来说,参数 (及其类型,如果需要声明的话) 位于函数箭头 -> 的左侧。要执行的代码在函数箭头的右侧。将 lambda 赋值给变量后,就可以像函数一样调用它。

kotlin可以声明一个存储函数的变量

val getSum = { x: Int, y: Int -> x + y }
println(getSum(1, 2)) =>3

其中x,y就是参数。->为函数箭头 ,需要执行的代码再其右侧。其中getSum就是变量名。

4.2高阶函数

高阶函数接收函数作为参数,或以函数作为返回值。

fun getSum(x: Int, y: Int, sum: (Int, Int) -> Int): Int {
return sum(x, y)
}
val sum: (Int, Int) -> Int = { x, y -> x + y }
println(getSum(1, 2, sum)) =>3

此段代码中函数体调用了作为第三个参数传入的函数,并将第一个第二个参数传递给该函数。

lambda 的真正作用在于可以使用它们创建高阶函数,高阶函数接收另一个函数作为参数。

使用函数类型可将其实现与使用处分离。

4.3传递函数引用

 使用:: 操作符将具名函数作为参数传入另一个函数。

fun sum(x: Int, y: Int): Int {
return x + y
}
println(getSum(1, 2, ::sum))

:: 操作符让 Kotlin 知道我们正在将函数引用作为参数传递,这样一来 Kotlin 便不会尝试调用该函数。

在使用高阶函数时,Kotlin 倾向于将接收函数的参数作为函数的最后一个参数。Kotlin 有一个特别的语法,叫做尾随参数调用语法,可以让我们的代码更加简洁。在下面代码中中,我们可以为函数参数传递一个 lambda,而不必将 lambda 放在括号中。

println(getSum(1, 2,{ x, y -> x + y }) )

等价于
println(getSum(1, 2) { x, y -> x + y })

Kotlin 内建的许多函数,其声明方式都遵循了尾随参数调用语法。

inline fun repeat(times: Int, action: (Int) -> Unit)

repeat(3) {
println("hello world!")
}
收起阅读 »

小程序框架对比(Taro VS uni-app)

前前段时间,要开发一个小程序,需要选一个跨平台的框架,为此做了一些调研,在这里记录一下。 目前的跨平台方案大致是以下三种类型,各有优劣。 结合项目自身情况,我选择了第三种类型的框架,再结合支持多平台的要求,重点锁定在了Taro和uni-app之间。 ...
继续阅读 »

前前段时间,要开发一个小程序,需要选一个跨平台的框架,为此做了一些调研,在这里记录一下。


目前的跨平台方案大致是以下三种类型,各有优劣。


image.png


结合项目自身情况,我选择了第三种类型的框架,再结合支持多平台的要求,重点锁定在了Tarouni-app之间。















































框架技术栈微信小程序H5App支付宝/百度小程序
TaroReact/Vue
uni-appVue
WePYVue
mpvueVue

Taro


开发者:


京东


优缺点:


Taro在App端使用的是React Native的渲染引擎,原生的UI体验较好,但据说在实时交互和高响应要求的操作方面不是很理想。


微信小程序方面,结合度感觉没有那么顺滑,有一些常见功能还是需要自己去封装。


另外就是开发环境难度稍高,需要自己去搭建iOS和Android的环境,对于想要一处开发到处应用的傻瓜式操作来讲,稍显繁琐。


但Taro 3的出现,支持了React 和 Vue两种DSL,适合的人群会更多一点,并且对快应用的支持也更好。


案例:


image.png


学习成本:


React、RN、小程序、XCode、Android Studio


uni-app


开发者:


DCloud


优缺点:


uni-app在App渲染方面,提供了原生渲染引擎和小程序引擎的双选方案,加上自身的一些技术优化(renderjs),对于高性能和响应要求的场景展现得更为流畅。


另外它整体的开发配套流程也做得很容易上手。比如有丰富的插件市场,使用简单,支持大量常用场景。


还比如它的定制IDE——HBuilder,提供了强大的整合能力。在用HBuilder之前,我心想:“还要多装一个编辑器麻烦,再好用能有VS Code好用?”用过之后:“真香!”


虽然用惯了VS Code对比起来还是有一些痛点没有解决,但是对于跨平台开发太友好了,其他缺点都可以忍受。HBuilder里支持直接跳转到微信开发者工具调试,支持真机实时预览,支持直接打包小程序和App,零门槛上手。


image.png


不过,uni-app也还是不够成熟,开发中也存在一些坑,需要不时到论坛社区去寻找答案。


代表产品:


image.png


学习成本:


Vue、小程序


总结


跨平台方案目前来看都不完善,适合以小程序、H5为主,原生APP(RN)为辅,不涉及太过复杂的交互的项目。


uni-app 开发简单,小项目效率高,入门容易debug难,不适合中大型项目。
Taro 3 开发流程稍微复杂一点,但复杂项目的支持度会稍好,未来可以打通React和Vue,已经开始支持RN了。



  1. 不考虑原生RN的话二者差不多,考虑RN目前Taro3不支持,只能选uni-app;

  2. 开发效率uni-app高,有自家的IDE(HBuilderX),编译调试打包一体化,对原生App开发体验友好;

  3. 个人技术栈方面倾向于Taro/React,但项目角度uni-app/Vue比较短平快,社区活跃度也比较高。

作者:sherryhe
链接:https://juejin.cn/post/6974584590841167879

收起阅读 »

前端重新部署后,领导跟我说页面崩溃了..

背景: 每次前端更新,重新部署后,用户还停留在更新之前的页面,当请求页面数据时,会导致页面白屏,报错信息如下: Uncaught ChunkLoadError: Loading chunk {n} failed. 原因 每次更新后,用户端的html文件中的 j...
继续阅读 »

背景:


每次前端更新,重新部署后,用户还停留在更新之前的页面,当请求页面数据时,会导致页面白屏,报错信息如下:


Uncaught ChunkLoadError: Loading chunk {n} failed.


原因


每次更新后,用户端的html文件中的 js 和 css 名称就和服务器上的不一致导致,导致加载失败。


解决方案


1.对error事件进行监听,检测到错误之后重新刷新


      window.addEventListener(
'error',
function (event) {
if (
event.message &&
String(event.message).includes('Loading chunk') &&
String(event.message).includes('failed')
) {
window.location.replace(window.location.href);
}
},
true,
);

2.对window.console.error事件监听,效果同上


      window.console.error = function () {
console.log(JSON.stringify(arguments), 'window.console.error'); // 自定义处理
};

3.其他方案


如:HTTP2.0推送机制 / fis3 构建 /webSocket通知等,未尝试


注:有好的方案可以在下面评论讨论哈


本篇收录在个人工作记录专栏中,专门记录一些比较有意思的场景和问题。


后记


在之后的某一天,该问题再次暴露出来。源于一位同事在使用过程中,会不定页面的出现报错情况,报错如下:


image.png


很明显,还是资源加载问题,按道理讲应该可以走入我们逻辑进行刷新。但是当时用户反馈:刷新也不能解决问题,强制刷新才可以解决。


分析:刷新为什么不能解决问题?其实还是因为当用户第一次因为网络原因未成功加载到资源,后续刷新均走的缓存,因此让用户手动去刷新解决不了问题。


解决方案:


不知道大家发现没有,上面对error事件进行监听代码中,并没有包括css失败的情况,因此匹配上css加载失败的情况即可。


更新代码如下:


      window.addEventListener(
'error',
function (event) {
if (
event.message &&
String(event.message).includes('chunk') &&
String(event.message).includes('failed')
) {
window.location.replace(window.location.href);
}
},
true,
);

可能有人会问,既然刷新解决不了问题,那window.location.replace(window.location.href)可以解决吗?


其实刷新的方法有很多,根据MDN的说法,Location.replace() 方法会以给定的URL替换当前的资源,因此可解决此问题。



作者:纵有疾风起
链接:https://juejin.cn/post/6981718762483679239

收起阅读 »

上一个程序员提桶跑路了!我接手后用这些方法优化了项目

平常我们在开发和维护项目的过程中,如果我们跑的项目有点大啊,或者数据太多,导致项目跑起来弊蜗牛还要慢,然后用户体验还不友好,对于新手程序员来说!老板天天都要你加班改!你还不敢辞职!这种时候,就很让人头痛了,怎么办! 但是!也不是没有办法的!骚年!你当时学vue...
继续阅读 »

平常我们在开发和维护项目的过程中,如果我们跑的项目有点大啊,或者数据太多,导致项目跑起来弊蜗牛还要慢,然后用户体验还不友好,对于新手程序员来说!老板天天都要你加班改!你还不敢辞职!这种时候,就很让人头痛了,怎么办!


但是!也不是没有办法的!骚年!你当时学vue的时候可不是这样说的!


接下来我来给你浓重介绍几个优化性能的小技巧,让你的项目蹭蹭蹭的飞起来!老板看了都直呼内行!


1.v-if和v-show的使用场景要区分


v-if 是条件渲染,当条件满足了,那肯定是渲染哇!如果你需要设置一个元素随时隐藏或者消失,然后用v-if是非常的浪费性能的,因为它不停的创建然后销毁。


但是它也是惰性的,如果你开始一给它条件为 false,它就害羞不出来了,跟你家女朋友一样天天晚上都不跟你回家,甚至你家里都没有你女朋友的衣物!然后结构页面里也不会渲染出来查不到这个元素,不知情的朋友以为你谈了个虚拟女友,直到你让它条件为 true ,它才开始渲染,也就是拿了结婚证才跟你回家。


v-show 就很简单,他的原理就是利用 css display 的属性,让他隐藏或者出现,所以一开始渲染页面哪怕我们看不到这个元素,但是它在文档的话,是确确实实存在的,只是因为 display:none; 隐藏了。




就像是你的打气女朋友,平常有人你肯定不敢打气哇!肯定是等夜深人静的时候,才偷偷打气,然后早上又继续放气藏起来,这样是不是很方便咧!所以这个元素你也就是你打气女朋友每天打气放气,是不是也没有那么费力咧!白天就可以藏起来快乐的上班啦!


好啦划重点啦!不要瞎鸡巴想什么女朋友了,女朋友只会影响我码项目的速度!


所以这样看来,如果是很少改变条件来渲染或者销毁的,建议是用 v-if ,如果是需要不断地切换,不断地隐藏又出现又隐藏这些场景的话, v-show 更适合使用!所以要在这些场景里合适的运用 v-if v-show 会节省很多性能以达到性能优化。


2.v-if和v-for不能同时使用


v-if v-for 一起使用时, v-for **** v-if 更高的优先级。这样就意味着 v-if 将分别重复运行于每一个 v-for 循中,那就是先运行 v-for 的循环,然后在每一个 v-for 的循环中,再进行 v-if 的条件对比,会造成性能浪费,影响项目的速度




如果按照下面的写法(我是用vue3写的),好家伙!直接不工作了,没有报错也没有页面,诡异的很


<template>
<div id="app">
<div v-for="item in list" v-if="list.flag" :key="item">{{item.color}}</div>
</div>
</template>
<script>
export default {
data(){
return{
list:[
{
color:"red",
flag:true
},
{
color:"green",
flag:false
},
{
color:"blue",
flag:true
},
],
}
}}
</script>

当你真的需要根据条件渲染页面的时候,建议是采用计算属性,这样及高效且美观,又不会出问题,如下面的代码展示


<template>
<div id="app">
<div v-for="item in newList" :key="item">{{item.color}}</div>
</div>
</template>
<script>
export default {
data(){
return{
list:[
{
color:"red",
flag:true
},
{
color:"green",
flag:false
},
{
color:"blue",
flag:true
},
],
}
},
computed:{
newList(){
return this.list.filter(list => {
return list.flag
})
}
}
}
</script>

3.computed和watch使用要区分场景


先看一下计算属性computed,它支持缓存,当依赖的数据发生变化的时候,才会重新计算,但是它并不支持异步操作,它无法监听数据变化。而且计算属性是基于响应式依赖进行缓存的。


再看一下侦听属性watch,它不支持缓存,它支持异步操作,当需要在数据变化时执行异步或开销较大的操作时,这个方式是最有用的。这是和computed最大的区别。


所以说,如果你的需求是写像购物车那种的,一个属性受其他属性影响的,用计算属性 computed 就像是你家的二哈,你不带它出去玩,你一回家就发现你家能拆的都被二哈拆掉了,因为你不带它出去跟你女朋友逛街!


如果是像写那种像模糊查询的,可以用侦听属性 watch ,因为可以一条数据影响多条数据。 比如你双12给你女朋友买了很多东西,那双十二之后,是不是很多机会回不去宿舍咧!


用好这两个,可以让你的代码更加高效,看起来也更加简洁优雅,让项目蹭蹭跑起来!这样都是一种优化性能的方式!


4.路由懒加载


当Vue项目是单页面应用的时候,可能会有很多路由引入 ,这样的话,使用 webpcak 打包后的文件会非常的大,当第一次进入首页的时候,加载的资源很多多,页面有时候会出现白屏的情况,对用户很不友好,体验也差。


但是,当我们把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应的组件,这样就很高效了。会大大提升首屏加载显示的速度,但是可能其他的页面的速度就会降下来。有利有弊吧,根据自己业务需求来使用,实现效果也非常的简单,在 router index.js 文件下,如下所示


import Home from '../views/Home'

const routes = [
{
path:'/home',
name:"Home", //这里没有用懒加载
component:Home
},
{
path:'/about',
name:'About',
component:()=>import(/*webpackChunkName:"about"*/ '../views/About.vue') //这里用了懒加载
}
]

打开浏览器运行,当我没有点击进入 about 组件的时候包的大小就如蓝色框住的那些,当我点击了 about 组件进入后,就增加了后面红色圈住的包,总的大小是增加了



所以,使用路由懒加载可以降低首次加载的时候的性能消耗,但是后面打开这些组件可能会有所减慢,建议是如果体积不大的又不用马上展示的页面可以使用路由懒加载降低性能消耗,从而做到性能优化!


5.第三方插件按需引入


比如我们做一个项目,如果是全局引入第三方插件,打包构建的时候,会将别人整个插件包也一起打包进去,这样的话文件是非常庞大的,然后我们就需要将第三方插件按需引入,这个就需要自己去根据每个插件的官方文档在项目配置,始终就是一句话,用什么引什么!


6.优化列表的数据


当我们遇到哪些,一开始取的数据非常的庞大,然后还要渲染在页面上的时候,比如一下子给你传回来10w条数据还要渲染在页面上,项目一下子渲染出来是非常的有难度的。


这个时候,我们就 需要采用窗口化的技术来优化性能,只需要渲染少部分的内容(比如一下子拿多少条数据),这样就可以减少重新渲染组件和创建dom节点的时间。可以看看下面代码


<template>
<div>
<h3>列表的懒加载</h3>
<div>
<div v-for="item in list">
<div>{{ item }}</div>
</div>
</div>
<div>
<div v-if="moreShowBoolen">滚动加载更多</div>
<div v-else>已无更多</div>
</div>
</div>
</template>
<script>
export default {
name: 'List',
data() {
return {
list: [],
moreShowBoolen: false,
nowPage: 1,
scrollHeight: 0,
};
},
mounted() {
this.init();
// document.documentElement.scrollTop获取当前页面滚动条的位置,documentElement对应的是html标签,body对应的是body标签
// document.compatMode用来判断当前浏览器采用的渲染方式,CSS1Compat表示标准兼容模式开启
window.addEventListener("scroll", () => {
let scrollY=document.documentElement.scrollTop || document.body.scrollTop; // 滚动条在Y轴上的滚动距离
let vh=document.compatMode === "CSS1Compat"?document.documentElement.clientHeight:document.body.clientHeight; // 页面的可视高度
let allHeight = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight
); // 整个页面的高度
if (scrollY + vh >= allHeight) {
// 当滚动条滑到页面底部的时候触发这个函数继续添加数据
this.init2();
}
});
},
methods: {
init() {
//一开始就往list添加数据
for (let i = 0; i < 100; i++) {
this.list.push(i);
}
},
init2() {
for (let i = 0; i < 200; i++) {
// 当滑动到底部的时候,继续触发这个函数
this.list.push(i);
}
},
},
};
</script>

这样的话,就可以做到数据懒加载啦,根据需要,逐步添加数据进去,减少一次性拉取所有数据,因为数据是非常庞大的,这样就可以优化很多性能了!


作者:零零后程序员小三
链接:https://juejin.cn/post/7041471019327946759

收起阅读 »

axios 封装,API接口统一管理,支持动态API!

vue
分享一个自己封装的 axios 网络请求 主要的功能及其优点: 将所有的接口放在一个文件夹中管理(api.js)。并且可以支持动态接口,就是 api.js 文件中定义的接口可以使用 :xx 占位,根据需要动态的改变。动态接口用法模仿的是vue的动态路由,如果你...
继续阅读 »

分享一个自己封装的 axios 网络请求


主要的功能及其优点:


将所有的接口放在一个文件夹中管理(api.js)。并且可以支持动态接口,就是 api.js 文件中定义的接口可以使用 :xx 占位,根据需要动态的改变。动态接口用法模仿的是vue的动态路由,如果你不熟悉动态路由可以看看我的这篇文章:Vue路由传参详解(params 与 query)


1.封装请求:



  1. 首先在 src 目录下创建 http 目录。继续在 http 目录中创建 api.js 文件与 index.js 文件。

  2. 然后再 main.js 文件中导入 http 目录下的 index.js 文件。将请求注册为全局组件。

  3. 将下面封装所需代码代码粘到对应的文件夹


2.基本使用:


//示例:获取用户列表
getUsers() {
 const { data } = await this.$http({
   url: 'users' //这里的 users 就是 api.js 中定义的“属性名”
})
},

3.动态接口的使用:


//示例:删除用户
deleteUser() {
 const { data } = await this.$http({
   method: 'delete',
   //动态接口写法模仿的是vue的动态路由
   //这里 params 携带的是动态参数,其中 “属性名” 需要与 api 接口中的 :id 对应
   //也就是需要保证携带参数的 key 与 api 接口中的 :xx 一致
   url: {
     // 这里的 name 值就是 api.js 接口中的 “属性名”
     name: 'usersEdit',
     params: {
       id: userinfo.id,
    },
  },
})
},

4.不足:


封装的请求只能这样使用 this.$http() 。不能 this.$http.get()this.$http.delete()


由于我感觉使用 this.$http() 这种就够了,所以没做其他的封装处理


如果你有更好的想法可以随时联系我


如下是封装所需代码:



  • api.js 管理所有的接口


// 如下接口地址根据自身项目定义
const API = {
 // base接口
 baseURL: 'http://127.0.0.1:8888/api/private/v1/',
 // 用户
 users: '/users',
 // “修改”与“删除”用户接口(动态接口)
 usersEdit: '/users/:id',
}

export default API


  • index.js 逻辑代码


// 这里请求封装的主要逻辑,你可以分析并将他优化,如果有更好的封装方法欢迎联系我Q:2356924146
import axios from 'axios'
import API from './api.js'

const instance = axios.create({
 baseURL: API.baseURL,
 timeout: '8000',
 method: 'GET'
})

// 请求拦截器
instance.interceptors.request.use(
 config => {
   // 此处编写请求拦截代码,一般用于加载弹窗,或者每个请求都需要携带的token
   console.log('正在请求...')
   // 请求携带的token
   config.headers.Authorization = sessionStorage.getItem('token')
   return config
},
 err => {
   console.log('请求失败', err)
}
)

// 响应拦截器
instance.interceptors.response.use(
 res => {
   console.log('响应成功')
   //该返回对象会绑定到响应对象中
   return res
},
 err => {
   console.log('响应失败', err)
}
)

//options 接收 {method, url, params/data}
export default function(options = {}) {
 return instance({
   method: options.method,
   url: (function() {
     const URL = options.url

     if (typeof URL === 'object') {
       //拿到动态 url
       let DynamicURL = API[URL.name]

       //将 DynamicURL 中对应的 key 进行替换
       for (const key of Object.keys(URL.params)) {
         DynamicURL = DynamicURL.replace(':' + key, URL.params[key])
      }

       return DynamicURL
    } else {
       return API[URL]
    }
  })(),
   //获取查询字符串参数
   params: options.params,
   //获取请求体字符串参数
   data: options.data
})
}



  • main.js 将请求注册为全局组件


import Vue from 'vue'

// 会自动导入 http 目录中的 index.js 文件
import http from './http'

Vue.prototype.$http = http

作者:寸头男生
链接:https://juejin.cn/post/7006508579595223070

收起阅读 »

AES 前后端加解密方案

AES
AES 前后端加解密方案 背景 最近有一个需求:后端对敏感数据进行加密传输给前端,由前端解密后进行回显。在讨论之后,定下了AES加解密方案 概念 AES: 密码学中的高级加密标准(Advanced Encryption Standard,AES),又称Rijn...
继续阅读 »

AES 前后端加解密方案


背景


最近有一个需求:后端对敏感数据进行加密传输给前端,由前端解密后进行回显。在讨论之后,定下了AES加解密方案


概念


AES: 密码学中的高级加密标准(Advanced Encryption Standard,AES),又称Rijndael加密法,是美国联邦政府采用的一种区块加密标准,是最为常见的对称加密算法


密码说明


AES算法主要有四种操作处理,分别是:



  1. 密钥轮加(Add Round Key)

  2. 字节代换层(SubBytes)

  3. 行位移层(Shift Rows)

  4. 列混淆层(Mix Column)


主要是讲使用方案,所以这里不说太多废话了,对算法感兴趣的同学移步这里, 讲的非常详细,不过文章里的代码是使用C语言写的,为此找到了github上aes.js 的源码,感兴趣的同学移步这里


前端实现


现在简单说一下前端的实现:


我先找到了github上的源码,看了一下大概800行的样子。本来打算直接改吧改吧,封装成一个加解密的工具方法,直接扔在utils目录里的。本来也很成功的改好了,本地加解密试了一下,效果也很不错。根据github链接上的readme文档说明,封装了如下函数:


// 省略了改完的aes.js的代码。。。

// 加密 text 需要加密的文本 key 密钥
const toAESBytes = (text, key) => {
const textBytes = aesjs.utils.utf8.toBytes(text);
const aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(5));
const encryptedBytes = aesCtr.encrypt(textBytes);
const encryptedHex = aesjs.utils.hex.fromBytes(encryptedBytes);
console.log('加密后的文本:', encryptedHex);
return encryptedHex;
};

// 解密
const fromAESBytes = (text, key) => {
const encryptedBytes = aesjs.utils.hex.toBytes(text);
const aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(5));
const decryptedBytes = aesCtr.decrypt(encryptedBytes);
const decryptedText = aesjs.utils.utf8.fromBytes(decryptedBytes);
console.log('解密后的文本:', decryptedText);
return decryptedText;
}

但是这个方法在和后端对接的时候出现了一点偏差,死活也不能将后端加密后的数据成功解密。于是又向后端同学请教了一下,发现原因如下:


在AES加解密算法中,除了加解密的密文,也就是key需要一样之外,还有几样东西也非常重要:




  • AES 的算法模式需要保持一致


    关于算法模式,主要有以下几种:


      1. 电码本模式 Electronic Codebook Book (ECB);
    2. 密码分组链接模式 Cipher Block Chaining (CBC)
    3. 计算器模式Counter (CTR)
    4. 密码反馈模式(Cipher FeedBack (CFB)
    5. 输出反馈模式Output FeedBack (OFB)

    这么看的话,我上面的demo应该使用的就是计算器模式了!关于算法模式的介绍,感兴趣的同学请移步这里




  • 补码方式保持一致


    关于补码方式,我查到的以下几种:


      1. PKCS5Padding PKCS7Padding的子集,块大小固定为8字节
    2. PKCS7Padding 假设数据长度需要填充n(n>0)个字节才对齐,那么填充n个字节,每个字节都是n;如果数据本身就已经对齐了,则填充一块长度为块大小的数据,每个字节都是块大小。
    3. ZeroPadding 数据长度不对齐时使用0填充,否则不填充



  • 密钥长度保持一致


    AES算法一共有三种密钥长度:128、192、256。这个前后端的密钥长度确实是保持一致的。




  • 加密结果编码方式保持一致


    一般情况下,AES加密结果有两种编码方式:base64 和 16进制




所以到底是哪里出了问题呢?后端同学好心发给了我他后端的代码:


/**
* aes 加密 Created by xingxiping on 2017/9/20.
*/
public class AesUtils {
private static final String CIPHER_ALGORITHM = "AES"; // optional value AES/DES/DESede

private AesUtils(){

}
/**
* 加密
*
* @param content
* 源内容
* @param key
* 加密密钥
* @return
*/
public static String encrypt(String content, String key) throws Exception {
Cipher cipher = getCipher(key, Cipher.ENCRYPT_MODE);
byte[] byteContent = content.getBytes(StandardCharsets.UTF_8);
byte[] result = cipher.doFinal(byteContent);
return Base64Utils.encode(result);
}

/**
* 解密
*
* @param content
* 内容
* @param key
* 解密密钥
* @return
*/
public static byte[] decrypt(String content, String key) throws Exception {
Cipher cipher = getCipher(key, Cipher.DECRYPT_MODE);
byte[] bytes = Base64Utils.decode(content);
bytes = cipher.doFinal(bytes);
return bytes;
}

private static Cipher getCipher(String key, int cipherMode) throws NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException {
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(), "AES");
cipher.init(cipherMode, secretKey);
return cipher;
}
}

破案了,后端老哥对加密后的结果进行了base64编码,然后我又仔细去看了一下aes.js源码,根本没有找到base64的影子啊!


于是在查找一翻资料以后,决定使用crypto-j,使用 Crypto-JS 可以非常方便地在 JavaScript 进行 MD5、SHA1、SHA2、SHA3、RIPEMD-160 哈希散列,进行 AES、DES、Rabbit、RC4、Triple DES 加解密。真是方便呀,老规矩,感兴趣的同学可以移步这里


以下是我又一轮的解决步骤:




  1. npm install crypto-js




  2. 在utils目录下新建一个文件aes.js




  3. 封装如下代码:


    // aes 解密
    import CryptoJS from 'crypto-js';

    // 解密 encryptedStr待解密字符串 pass 密文
    export const aesDecode = (encryptedStr, pass) => {
    const key = CryptoJS.enc.Utf8.parse(pass); // 通过密钥获取128位的key
    const encryptedHexStr = CryptoJS.enc.Base64.parse(encryptedStr); // 解码base64编码结果
    const encryptedBase64Str = CryptoJS.enc.Base64.stringify(encryptedHexStr);
    const decryptedData = CryptoJS.AES.decrypt(encryptedBase64Str, key, {
    mode: CryptoJS.mode.ECB,
    padding: CryptoJS.pad.Pkcs7
    });
    return decryptedData.toString(CryptoJS.enc.Utf8);
    }



  4. 然后就可以正常调用了!




最后,终于成功解密!


一点点小感悟


在日常工作中真的很少使用算法,对称加密在学校里听起来好像非常简单的样子,但是真的应用到生活中,特别是安全领域,还是非常复杂的。哎,学无止境吧~


感谢大家的阅读!


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

聊一聊ThreadLocal,终于搞明白了

ThreadLocal是什么? 试想以下情况: 在多线程的情况下,对一同一个变量操作可能会出现冲突,解决的办法就是对这个变量加锁。但是我们有时候其实就是想要一个全局变量,不想让多个线程去干扰。那么能不能有一个变量,名字相同,但是多个线程操作的时候又不会相互影响...
继续阅读 »

ThreadLocal是什么?


试想以下情况:


在多线程的情况下,对一同一个变量操作可能会出现冲突,解决的办法就是对这个变量加锁。但是我们有时候其实就是想要一个全局变量,不想让多个线程去干扰。那么能不能有一个变量,名字相同,但是多个线程操作的时候又不会相互影响呢?


从另外一个角度来说,对于一个变量,在一个线程的任何一个地方都可能需要用到,但是通过传参的方式又比较麻烦,有没有一个变量是贯穿整个线程,我们想取就能取到的呢。


ThreadLocal就是这么一个变量,那么这个变量是怎么实现的呢?


ThreadLocal源码分析


ThreadLocal github地址


首先看用法


public class Client {
private static final ThreadLocal<String> myThreadLocal = ThreadLocal.withInitial(() -> "This is the initial value");

public static void main(String[] args) {

for (int i = 0; i < 6; i++){
new Thread(new MyRunnable(), "线程"+i).start();
}

}

public static class MyRunnable implements Runnable {

@Override
public void run() {
String name = Thread.currentThread().getName();
System.out.println(name + "的threadLocal"+ ",设置为" + name);
myThreadLocal.set(name);
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {}
System.out.println(name + ":" + myThreadLocal.get());
}

}
}

------

线程0的threadLocal,设置为线程0
线程3的threadLocal,设置为线程3
线程4的threadLocal,设置为线程4
线程2的threadLocal,设置为线程2
线程1的threadLocal,设置为线程1
线程5的threadLocal,设置为线程5
线程0:线程0
线程4:线程4
线程5:线程5
线程2:线程2
线程1:线程1
线程3:线程3

例子中有六个线程,myThreadLocal里存的都是自己线程独有的变量。这样就实现了变量的线程隔离,而且如果不向传参数,在另外一个函数里直接就能get到这个变量,这对于很多场景下都非常有用。


我们下面来分析一下源码:



  1. 首先每一个Thread,都有一个ThreadLocalMap,变量名字叫做threadLocas,里面保存的是多个ThreadLocal,所以每一个线程才能保存属于自己线程的值。

  2. ThreadLocal封装了getMap()、Set()、Get()、Remove()4个核心方法。主要是对ThreadLocalMap来进行操作。

  3. ThreadLocalMap是一个ThreadLocal的内部类,它实现了一个自定义的Map,ThreadLocalMap中的Entry[]数组存储数据。

  4. Entry的键是threadLocal变量本身,值就是设置的变量的值。Entry的key是对ThreadLocal的弱引用,当ThreadLocal不再有强引用的时候,就会清理掉这个key,防止内存泄漏(然而并不能,后面会说)


5abe86d1459c394b7552c1ef9d7e370c.png


get方法


public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}



  1. 获取当前的Thread对象,通过getMap获取Thread内的ThreadLocalMap,ThreadLocalMap的定义如下:ThreadLocal.ThreadLocalMap threadLocals = null;

  2. 如果map已经存在,以当前的ThreadLocal为键,获取Entry对象,并从从Entry中取出值

  3. 否则,调用setInitialValue进行初始化。


setInitialValue


private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}



  1. 首先是调用initialValue生成一个初始的value值,深入initialValue函数,我们可知它就是返回一个null;

  2. 然后还是在get以下Map,如果map存在,则直接map.set,这个函数会放在后文说;

  3. 如果不存在则会调用createMap创建ThreadLocalMap,这里正好可以先说明下ThreadLocalMap了。


ThreadLocalMap


createMap


void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private static final int INITIAL_CAPACITY = 16;

private Entry[] table;

private int size = 0;

private int threshold; // Default to 0

private void setThreshold(int len) {
threshold = len * 2 / 3;
}

private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
...
}


  1. 首先是Entry的定义,前面已经说过;

  2. 初始的容量为INITIAL_CAPACITY = 16

  3. 主要数据结构就是一个Entry的数组table;

  4. size用于记录Map中实际存在的entry个数;

  5. threshold是扩容上限,当size到达threashold时,需要resize整个Map,threshold的初始值为len * 2 / 3

  6. nextIndex和prevIndex则是为了安全的移动索引,后面的函数里经常用到。


map.getEntry


private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}



  1. 计算索引位置

  2. 获取Entry,如果Entry存在,且key和threadLocal相等,那么返回

  3. 否则,调用getEntryAfterMiss。


getEntryAfterMiss


private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;

while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}


  1. 如果k==key,那么代表找到了这个所需要的Entry,直接返回;

  2. 如果k==null,那么证明这个Entry中key已经为null,那么这个Entry就是一个过期对象,这里调用expungeStaleEntry清理该Entry。



为什么会需要清理呢?


如果说ThreadLocal变量被人为的置为null了,ThreadLocal对象只有一个弱引用指着,就会被GC,Entry的key没有了,value可能会内存泄漏。ThreadLocal在每一个get,set的时候都会清理这种过期的key。


为什么需要循环查找key?


这是一种解决hash冲突的手段,这里用的是开放地址法,既有冲突之后,把要插入的元素放在要插入的位置后面为null的地方。HashMap则采用的是链地址法。



expungeStaleEntry


private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}


  1. expunge entry at staleSlot:这段主要是将i位置上的Entry的value设为null,Entry的引用也设为null,那么系统GC的时候自然会清理掉这块内存;

  2. Rehash until we encounter null: 这段就是扫描位置staleSlot之后,null之前的Entry数组,清除每一个key为null的Entry,同时若是key不为空,做rehash,调整其位置。



这里rehash的作用是什么?


我们清理的过程中会把某个值设置为null,如果之前这个值后面的区域是和前两连起来的,那么下次循环查找的时候,就会只查到null为止。比如三个hash值碰撞的key,中间的那个被删除了,那么第三个key在查找的时候会从第一个开始查找,查找到第二个就停止了,第三个就查不到了。



set


public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

map.set


private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}


  1. 首先还是根据key计算出位置i,然后查找i位置上的Entry,

  2. 若是Entry已经存在并且key等于传入的key,那么这时候直接给这个Entry赋新的value值。

  3. 若是Entry存在,但是key为null,则调用replaceStaleEntry来更换这个key为空的Entry

  4. 不断循环检测,直到遇到为null的地方,这时候要是还没在循环过程中return,那么就在这个null的位置新建一个Entry,并且插入,同时size增加1。

  5. 最后调用cleanSomeSlots,这个函数就不细说了,你只要知道内部还是调用了上面提到的expungeStaleEntry函数清理key为null的Entry就行了,最后返回是否清理了Entry,接下来再判断sz>thresgold,这里就是判断是否达到了rehash的条件,达到的话就会调用rehash函数。


rehash


private void rehash() {
expungeStaleEntries();

// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}


private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;

for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}


  1. 首先,size大于threshold的时候才会rehash。

  2. 清理空key,如果size大于3/4的threshold,调用resize()函数。

  3. 每次扩容大小扩展为原来的2倍,然后再一个for循环里,清除空key的Entry,同时重新计算key不为空的Entry的hash值,把它们放到正确的位置上,再更新ThreadLocalMap的所有属性。


remove


private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

先计算出hash值,若是第一次没有命中,就循环直到null,在此过程中也会调用expungeStaleEntry清除空key节点。


什么是内存泄漏?


当程序分配了空间但是却忘了回收导致以后的程序都无法或暂时无法使用这块空间,就发生了内存泄漏。和内存溢出不一样,内存溢出是内存不足的时候出现的。这块要理解清楚,才能明白ThreadLocal为什么会导致内存泄漏。


Java 引用类型


要说到ThreadLocal引起内存泄漏,还得从java的四种引用类型说起。


java中有四种引用类型,分别是强软弱虚。


强引用


一个对象被强引用,那么他就不会被回收。


软引用


如果一个对象只具有软引用,那么它的性质属于可有可无的那种。如果此时内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。


软引用可以和一个引用队列联合使用,如果软件用所引用的对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。


    Object obj = new Object();
ReferenceQueue queue = new ReferenceQueue();
SoftReference reference = new SoftReference(obj, queue);
//强引用对象滞空,保留软引用
obj = null;
当内存不足时,软引用对象被回收时,reference.get()为null,此时软引用对象的作用已经发挥完毕,这时将其添加进ReferenceQueue 队列中

如果要判断哪些软引用对象已经被清理:


    SoftReference ref = null;
while ((ref = (SoftReference) queue.poll()) != null) {
//清除软引用对象
}

弱引用


弱引用和软引用的区别就是,如果一个对象只有弱引用,那么只要GC,不管内存够不够,都会回收他的内存。注意这里的”只有弱引用“。如果这个对象还被其他变量强引用,那么他是不会被回收的。


虚引用


如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收的活动。





































引用类型被垃圾回收时间用途生存时间
强引用从来不会对象的一般状态JVM停止运行时终止
软引用在内存不足时对象缓存内存不足时终止
弱引用在垃圾回收时对象缓存垃圾回收时终止
虚引用UnkonwnUnkonwnUnkonwn


为什么要有四种引用类型?



  1. 可以让程序员通过代码的方式来决定某个对象的生命周期。

  2. 有利于垃圾回收

  3. 能够实现一些复杂的数据结构。



ThreadLocal什么情况下会出现内存泄漏?


threadlocal里面使用了一个存在弱引用的map,当释放掉threadlocal的强引用以后,map里面的value却没有被回收.而这块value永远不会被访问到了. 所以存在着内存泄露。


如果这个时候还会去调用get set方法,那么这块内存可能会被清理掉。


如果没有去调用get set方法,如果这个线程很快销毁了,那么也不会内存泄漏。


最坏的情况就是,threadLocal对象设置成null了,然后使用线程池,这个线程被重复使用了,但是有一直没有调用get set方法,这个期间就会发生真正的内存泄漏。


其实ThreadLocal发生内存泄露的条件还是比较苛刻的,只要是使用规范,那么就没有什么问题。


ThreadLocal最佳实践



  1. 每次使用完手动调用remove函数,删除不再使用的ThreadLocal.

  2. 可以将ThreadLocal设置成private static的,这样ThreadLocal会尽量和线程本身一起消亡。


ThreadLocal应用案例



管理数据库连接。


  假如A类的方法a中,会调用B类的方法b和C类的方法c,a方法开启了事务,b方法和c方法会去操作数据库。我们知道,要想实现事务,那么b方法和c方法中所使用的的数据库连接一定是同一个连接,那怎么才能实现所用的是同一个数据库连接呢?答案就是通过ThreadLocal来管理。


MDC日志链路追踪。


MDC(Mapped Diagnostic Contexts)主要用于保存每次请求的上下文参数,同时可以在日志输出的格式中直接使用 %X{key} 的方式,将上下文中的参数输出至每一行日志中。而保存上下文信息主要就是通过ThreadLocal来实现的。
假如在交易流程每个环节的日志中,你都想打印全局流水号transId,流程可能涉及多个系统、多个线程、多个方法。有一些环节中,全局流水号并不能当做参数传递,那你怎么才能获取这个transId参数呢,这里就是利用了Threadlocal特性。每个系统或者线程在接收到请求时,都会将transId存放到ThreadLocal中,在输出日志时,将transId获取出来,进行打印。这样,我们就可以通过transId,在日志文件中查询全链路的日志信息了。



InheritableThreadLocal


使用ThreadLocal时,子线程获取不到父线程通过set方法保存的数据,要想使子线程也可以获取到,可以使用InheritableThreadLocal类。


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

【墙裂推荐】球球了,RPC之间调用别再使用 if-else做参数校验了

RPC
RPC调用时使用 @Validated进行参数校验不起作用 球球了,RPC之间调用别再使用 if-else做参数校验了。众所周知,@Validated 是一款非常好用的参数校验工具。但在RPC调用时不可用,在当前的微服务大环境下,微服务之间的调用怎么做到优雅...
继续阅读 »

RPC调用时使用 @Validated进行参数校验不起作用


球球了,RPC之间调用别再使用 if-else做参数校验了。众所周知,@Validated 是一款非常好用的参数校验工具。但在RPC调用时不可用,在当前的微服务大环境下,微服务之间的调用怎么做到优雅的参数校验呢?


话不多说,直接上干货


引包


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

1. 参数校验 这里我们先要定义一个注解来代替来继承 @Validated


import org.springframework.validation.annotation.Validated;

@Validated
public @interface RPCValidated {
}

2. 然后使用AOP来解析参数,进行参数校验。


import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator;
import org.hibernate.validator.resourceloading.PlatformResourceBundleLocator;
import org.springframework.stereotype.Component;
import wangjubao.base.common.extend.Response;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

@Aspect
@Component
@Slf4j
public class ValidatedAop {
private static Validator validator;

static {
validator = Validation.byDefaultProvider().configure()
.messageInterpolator(new ResourceBundleMessageInterpolator(
new PlatformResourceBundleLocator("validationMessages")))
.buildValidatorFactory().getValidator();
}

@Around("@annotation(com.qiaoba.annotation.RPCValidated))")
public Object around(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 执行方法参数的校验
Set<ConstraintViolation<Object>> constraintViolations = validator.forExecutables().validateParameters(joinPoint.getThis(), signature.getMethod(), args);
List<String> messages = new ArrayList<>();
for (ConstraintViolation<Object> error : constraintViolations) {
messages.add(error.getMessage());
}
if (!messages.isEmpty()) {
return Response.paramError("参数错误:", messages.get(0));
}
try {
return joinPoint.proceed(args);
} catch (Throwable e) {
e.printStackTrace();
return Response.error("操作失败:", e.getMessage());
}
}
}

3. 使用方法,在接口Impl实现类加上定义的@RPCValidated


@Override
@RPCValidated
public Response create(MessageSmsRechargeDto dto) {
//todo:业务逻辑......
}

4. 在Interfaces接口层加上@Valid注解


Response create(@Valid MessageSmsRechargeDto params);

5. 实体类


@Data
public class MessageSmsRechargeDto implements Serializable {


/**
* 充值公司
*/
@NotNull(message = "充值公司不能为空 ")
private Long companyId;

/**
* 充值备注
*/
@NotEmpty(message = "充值备注不能为空 ")
private String rechargeRemark;
}

--- 至此,整个流程完成,加上自定义的参数校验注解@RPCValidated后,就可以优雅的进行参数校验,不用再写各种if-else 做参数校验了


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

Flutter APP 前期准备工作

组件库可参考:flutter.dev、bruno(贝壳开源组件库) 以下从部分GetX文档转载 用于记录。 框架: Flutter GetX GetX 是 Flutter 上的一个轻量且强大的解决方案:高性能的状态管理、智能的依赖注入和便捷的路由管理。 Get...
继续阅读 »

组件库可参考:flutter.dev、bruno(贝壳开源组件库)


以下从部分GetX文档转载 用于记录。
框架: Flutter GetX


GetX 是 Flutter 上的一个轻量且强大的解决方案:高性能的状态管理、智能的依赖注入和便捷的路由管理。


GetX 有3个基本原则:


性能: GetX 专注于性能和最小资源消耗。GetX 打包后的apk占用大小和运行时的内存占用与其他状态管理插件不相上下。效率: GetX 的语法非常简捷,并保持了极高的性能,能极大缩短你的开发时长。 结构: GetX 可以将界面、逻辑、依赖和路由完全解耦,用起来更清爽,逻辑更清晰,代码更容易维护。 GetX 并不臃肿,却很轻量。


三大功能


状态管理


目前,Flutter有几种状态管理器。但是,它们中的大多数都涉及到使用ChangeNotifier来更新widget,这对于中大型应用的性能来说是一个很糟糕的方法。你可以在Flutter的官方文档中查看到,ChangeNotifier应该使用1个或最多2个监听器,这使得它们实际上无法用于任何中等或大型应用。


Get 并不是比任何其他状态管理器更好或更差,而是说你应该分析这些要点以及下面的要点来选择只用Get,还是与其他状态管理器结合使用。


Get不是其他状态管理器的敌人,因为Get是一个微框架,而不仅仅是一个状态管理器,既可以单独使用,也可以与其他状态管理器结合使用。


Get有两个不同的状态管理器:简单的状态管理器(GetBuilder)和响应式状态管理器(GetX)。


响应式状态管理器


响应式编程可能会让很多人感到陌生,因为觉得它很复杂,但是GetX将响应式编程变得非常简单。



  • 你不需要创建StreamControllers.

  • 你不需要为每个变量创建一个StreamBuilder。

  • 你不需要为每个状态创建一个类。

  • 你不需要为一个初始值创建一个get。


使用 Get 的响应式编程就像使用 setState 一样简单。


让我们想象一下,你有一个名称变量,并且希望每次你改变它时,所有使用它的小组件都会自动刷新。


这就是你的计数变量。


var name = 'Jonatas Borges';

要想让它变得可观察,你只需要在它的末尾加上".obs"。


var name = 'Jonatas Borges'.obs;

而在UI中,当你想显示该值并在值变化时更新页面,只需这样做。


Obx(() => Text("${controller.name}"));

这就是全部,就这么简单。


关于状态管理的更多细节


关于状态管理更深入的解释请查看这里。在那里你将看到更多的例子,以及简单的状态管理器和响应式状态管理器之间的区别


你会对GetX的能力有一个很好的了解。


路由管理


如果你想免上下文(context)使用路由/snackbars/dialogs/bottomsheets,GetX对你来说也是极好的,来吧展示:


在你的MaterialApp前加上 "Get",把它变成GetMaterialApp。


GetMaterialApp( // Before: MaterialApp(
home: MyHome(),
)

导航到新页面


Get.to(NextScreen());

用别名导航到新页面。查看更多关于命名路由的详细信息这里


Get.toNamed('/details');

要关闭snackbars, dialogs, bottomsheets或任何你通常会用Navigator.pop(context)关闭的东西。


Get.back();

进入下一个页面,但没有返回上一个页面的选项(用于闪屏页,登录页面等)。


Get.off(NextScreen());

进入下一个页面并取消之前的所有路由(在购物车、投票和测试中很有用)。


Get.offAll(NextScreen());

注意到你不需要使用context来做这些事情吗?这就是使用Get路由管理的最大优势之一。有了它,你可以在你的控制器类中执行所有这些方法,而不用担心context在哪里。


关于路由管理的更多细节


关于别名路由,和对路由的低级控制,请看这里


依赖管理


Get有一个简单而强大的依赖管理器,它允许你只用1行代码就能检索到与你的Bloc或Controller相同的类,无需Provider context,无需inheritedWidget。


Controller controller = Get.put(Controller()); // 而不是 Controller controller = Controller();


  • 注意:如果你使用的是Get的状态管理器,请多注意绑定api,这将使你的界面更容易连接到你的控制器。


你是在Get实例中实例化它,而不是在你使用的类中实例化你的类,这将使它在整个App中可用。 所以你可以正常使用你的控制器(或类Bloc)。


提示:  Get依赖管理与包的其他部分是解耦的,所以如果你的应用已经使用了一个状态管理器(任何一个,都没关系),你不需要全部重写,你可以使用这个依赖注入。


controller.fetchApi();

想象一下,你已经浏览了无数条路由,现在你需要拿到一个被遗留在控制器中的数据,那你需要一个状态管理器与Provider或Get_it一起使用来拿到它,对吗?用Get则不然,Get会自动为你的控制器找到你想要的数据,而你甚至不需要任何额外的依赖关系。


Controller controller = Get.find();
//是的,它看起来像魔术,Get会找到你的控制器,并将其提供给你。你可以实例化100万个控制器,Get总会给你正确的控制器。

然后你就可以恢复你在后面获得的控制器数据。


Text(controller.textFromApi);

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

cocoapods-binary工作原理及改进

iOS
「这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战」 在iOS开发中,如果能够对一些稳定的组件能够二进制化,那么将大大的缩减我们在开发过程中的编译时间。在基于Cocaopods工程,快速实现Swift组件二进制一文中,我们讲述了,借助P...
继续阅读 »

「这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战


iOS开发中,如果能够对一些稳定的组件能够二进制化,那么将大大的缩减我们在开发过程中的编译时间。在基于Cocaopods工程,快速实现Swift组件二进制一文中,我们讲述了,借助Pods工程和Shell脚本,一步实现二进制打包,但需要我们手动更改podspec文件,采用这种方式,如果作为依赖项加入到其他工程中,还会出现二进制源码共存的情况,今天介绍一个cocoapods插件 cocoapods-binary,可以实现组件预编译,该工程已经两年多没维护了,随着pods更新,有了一些小bug。基于源码,我对该插件做了几点更改,又可以开心的玩耍了.在了解该插件之前,我们先大概了解下,输入pod install之后发生了什么?


Pod install


如果你想调试cocoapods工程,可以查看之前的文章Ruby和Cocoapods文章合集


一图胜千言,当输入Pod install


Pod install.png



  • 1,首先校验是否有Podfile文件,并解析为Podfile对象

  • 2,准备阶段,安装插件,调用pre_install阶段的插件

  • 3,解析依赖,通过 当前的Podfile文件 和 上一次的Pofile.Lock,Manifest.Lock文件进行比对,确认哪些文件需要更新。

  • 4,下载依赖,更新需要更改的文件,并执行Podfile里面定义的pre_installhook函数。

  • 5,集成阶段,生成新的Pods.xcodeproj工程文件,并执行Podfile里面的post_installhook函数。

  • 6,写入新的Lockfile信息。

  • 7,执行 post_install阶段的插件,并输出安装信息。


cocoapods-binary是以插件的形式,在Pod工程的pre_install阶段进行预编译的,


cocoapods-binary工作流


pre_install 插件


cocoapods-binary中的,通过HookManager来注册插件的执行时机


Pod::HooksManager.register('cocoapods-binary', :pre_install) do |installer_context|

end


主流程


主要流程如下图所示


截屏2021-11-28 下午7.20.16.png



  • 1,必须使用framework的形式,也就是use_frameworks

  • 2,在Pods文件夹下,创建一个名为_Prebuild的文件夹,作为预编译沙箱

  • 3,在当前环境下,读取Podfile文件,并创建Podfile对象。

  • 4,读取Podflie.lock文件,创建 Lockfile对象。

  • 5,创建预编译安装器,沙箱地址为 预编译沙箱

  • 6,对比PodfilePodfile.lock,得到已更改的pod_name

  • 7,使用预编译安装器pod_name的源代码下载到预编译沙箱中,并生成新的Pods.xcodeproj文件。开始编译需要更新的framework

  • 8,回到主工程,继续执行 pod install的后续流程,在这一步修该需要二进制文件的podspec文件。


解析自定义参数


在插件中,有两个自定义的参数 :binaryall_binary!,是通过自定义DSL来实现的,有对这一块不熟悉的,可以参考我的这篇文章Cocoapods之 Podfile文件


Podfile.png
创建Podfile对象时,通过 method swizzling来hook:parse_inhibit_warnings方法,拿到我们在Podfile文件中写入的配置选项。将需要预编译pod,保存到数组中。


old_method = instance_method(:parse_inhibit_warnings)
define_method(:parse_inhibit_warnings) do |name, requirements|
variables = requirements
parse_prebuild_framework(name, requirements)
old_method.bind(self).(name, variables)
end
复制代码

Ruby中,Method Swizzling主要分为三步:



  • 1,获取parse_inhibit_warnings实例方法。

  • 2,定义一个相同名字的方法。

  • 3,调用原来的方法。


对比Lockfile


lockfile.png
在这里 Podfle.lock预编译沙箱Manifest.lock是一样的,通过对比可以一个Hash对象


<Pod::Installer::Analyzer::SpecsState:0x00007f83370c61a8 @added=#<Set: {}>, @deleted=#<Set: {}>, @changed=#<Set: {}>, @unchanged=#<Set: {"Alamofire", "SnapKit"}>>


可以很清楚的知道哪些pod库发生了更改。如果有改动,则就在预编译沙箱进行install


binary_installer.install!


pre_install.png
在这一阶段,主要是在预编译沙箱中拉取framework源码和修改Pods.xcodeproj文件,在 Manifest.lock成功写入预编译沙箱,通过hook run_plugins_post_install_hooks函数,在预编译沙箱中,使用 xcodebuild命令,编译每一个需要更新的pod_target,并将编译好的framework放至GeneratedFrameworks目录下。


回到主工程执行pod install


截屏2021-11-28 下午8.22.44.png


编译完成后,就回到了主工程里面的 Pod install流程中。对:resolve_dependencies方法进行Method Swizzling,对需要更改的pod_target进行修改。通过修改内存中的Pod::Specification对象的vendored_frameworkssource_filesresource_bundlesresources属性,来引用已经编译好的framework


工作流总结


通过对每一个阶段的了解,我们了解了作者的思路是这样的:
1,先将源码和Pods.project安装到预编译沙箱中 。
2,借助于Pods.project工程,使用xcodebuild编译需要预编译的scheme
3,巧妙的利用Method Swizzling,在分析依赖阶段,修改Pod::Specification对象,完成二进制的引用等工作。


现有问题


1,:binary => true 无效


ruby 2.6.8p205 (2021-07-07 revision 67951) [universal.x86_64-darwin21]版本中,使用:binary => true无效,无法编译为framework。在D ebug模式下生效,在发布后就失效了。


def set_prebuild_for_pod(pod_name, should_prebuild)
Pod::UI.puts("pod_name: #{pod_name} prebuild:#{should_prebuild}")
if should_prebuild == true
@prebuild_framework_pod_names ||= []
@prebuild_framework_pod_names.push pod_name
else
@should_not_prebuild_framework_pod_names ||= []
@should_not_prebuild_framework_pod_names.push pod_name
end
end


ruby 2.6.8中,release模式下,执行了两次,参数还不一致,导致 @prebuild_framework_pod_names@should_not_prebuild_framework_pod_names相等,最终需要预编译的数组为[]


pod_name: SnapKit, should_prebuild:true
pod_name: SnapKit, should_prebuild:


2,pod update没有及时更新最新的framework


frameworkA依赖frameworkB,在 Podfile中,只引入了 frameworkA


target xxx do 
pod "frameworkA", :binary => true
end


frameworkB有新版本时,没有更新最新的frameworkB,对于我们自己的组件,我们期望有新版本发布时,能及时更新。


解决办法:
在检测更新的方法中,读取最新的Manifest.lock文件,读取已编译的frameworkplist文件,比较两个版本号是否一致,不一致则重新编译。


cocoapods-binary-bel


为了能充分利用该插件,我根据实际产生的问题,对源码进行了一些修改,增加了 plist版本校验。使用方式和cocoapods-binary一致。
源码github链接cocoapods-binary-bel


安装


sudo gem install cocoapods-binary-bel


流程图


cocoapods-binary的基础上增加了版本校验工程,总的流程图如下所示:


cocoapods-binary-bel.png


一键转源码


1,:binary => false,指定某一个framework为源码


2, 新增 --hsource选项,输入 pod install --hsource,可以将所有的framework转为源码。


去除了依赖分析


在实际运用的工程中,如果 A 依赖 BC, B又依赖D,如果 A需要预编译,那么 BCD都需要重新编译,实际上此时BCD已经有了已经编译好的版本,无需重新编译。在Podfile指明 BCD即可。


pod "A"
pod "B"
pod "C"
pod "D"


增加静态库的resource处理


在 cocoapods中,如果使用 resource_bundle 处理资源文件,会生成一个相对应的target来处理资源文件,如果是动态库,会在动态库Target,添加资源工程依赖,使用 xcodebuild命令制作二进制会将bundle文件编译至xxx.framework目录下,使用 resources同样也会将资源文件编译至xxx.framework目录下。


对于静态库而言,如果是使用resource_bundle,也同样生成一个会生成一个相对应的target来处理资源文件,但对资源文件的拷贝,是由主工程做的,无需静态库工程处理, 如果使用 resources,则需要将资源文件从源码中,拷贝到 xxx.framework下,在主工程编译时,由主工程处理即可。


作者:Bel李玉
链接:https://juejin.cn/post/7035628418972516360

收起阅读 »

iOS 简单封装一个新用户功能模块引导工具类小玩儿意

iOS
废话开篇:新手引导功能就是简单的告诉用户某一模块下能够进行什么样的操作,起到指引用户的作用,那么就简单的实现一下这样的功能模块。一、实现效果展示可以从效果图中看到,对新用户的必要模块都会进行简单的功能解释。二、调用代码添加待引导功能视图到管理类管理类进行展示三...
继续阅读 »

废话开篇:新手引导功能就是简单的告诉用户某一模块下能够进行什么样的操作,起到指引用户的作用,那么就简单的实现一下这样的功能模块。

一、实现效果展示

屏幕录制2021-12-08 上午11.04.57.gif

可以从效果图中看到,对新用户的必要模块都会进行简单的功能解释。

二、调用代码

添加待引导功能视图到管理类

image.png

管理类进行展示

image.png

三、工具类解析

image.png

1、KDSGuideMannager 类

(1)统一管理全局下需要进行 “引导” 的功能区域(UIView)的保存。

(2)控制引导界面的显示与消失。

(3)控制下一个功能区域(UIView)圈定及描述展示。

2、KDSGuideView 类

(1)整体的蒙板视图层。

(2)对当前所选引导功能区域(UIView)进行镂空标注

(3)调整气泡(KDSGuideBubbleView)位置。

3、KDSGuideBubbleView 类

(1)气泡标注视图。

4、KDSGuideModel 类

(1)保存功能区域(UIView)视图及功能描述文字

四、实现代码

1、KDSGuideMannager 类

KDSGuideMannager.h

image.png

KDSGuideMannager.m

image.png

image.png

image.png

2、KDSGuideView 类

KDSGuideView.h

image.png

KDSGuideView.m

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

3、KDSGuideBubbleView 类

KDSGuideBubbleView.h

image.png

KDSGuideBubbleView.m

image.png

image.png

4、KDSGuideModel 类

KDSGuideModel.h

image.png

KDSGuideModel.m

image.png

五、其他效果展示

屏幕录制2021-12-08 下午1.54.31.gif

个人总结,代码拙劣,大神勿笑。

收起阅读 »

SDWebImage从小白到大师蜕变

iOS
简介SDWebImage提供的简洁的获取远程URL图片的API;平时开发中使用最多场景就是列表中的cell中要显示远程图片的需求,在具体的实现中要避免加载图片造成的界面卡顿,列表卡顿等现象的出现;所以需要编码实现如下功能:使用占位图片显示UI界面,异步线程加载...
继续阅读 »

简介

SDWebImage提供的简洁的获取远程URL图片的API;平时开发中使用最多场景就是列表中的cell中要显示远程图片的需求,在具体的实现中要避免加载图片造成的界面卡顿,列表卡顿等现象的出现;所以需要编码实现如下功能:

  • 使用占位图片显示UI界面,异步线程加载图片成功后刷新控件
  • 缓存机制,下载过的图片做内存缓存和磁盘缓存
  • app内存吃紧的状态下移除缓存的内容

SDWebImage的框架结构

SDWebImage的框架结构

SDWebImage的图片下载分类,只要一行代码就可以实现图片异步下载和缓存功能。

功能简介

  1. 一个添加了web图片加载和缓存管理的UIImageView分类
  2. 一个异步图片下载器
  3. 一个异步的内存加磁盘综合存储图片并且自动处理过期图片
  4. 支持动态gif图
    • 4.0 之前的动图效果并不是太好
    • 4.0 以后基于 FLAnimatedImage加载动图
  5. 支持webP格式的图片
  6. 后台图片解压处理
  7. 确保同样的图片url不会下载多次
  8. 确保伪造的图片url不会重复尝试下载
  9. 确保主线程不会阻塞

实现原理

  1. 架构图(UML 类图)

架构图(UML 类图)

  1. 流程图(方法调用顺序图)

1559217862563-364c0d60-3f2a-4db9-b5c5-e81f01cd125e.png

目录结构

  • Downloader
    ○ SDWebImageDownloader
    ○ SDWebImageDownloaderOperation

  • Cache
    ○ SDImageCache

  • Utils
    ○ SDWebImageManager
    ○ SDWebImageDecoder
    ○ SDWebImagePrefetcher

  • Categories
    ○ UIView+WebCacheOperation
    ○ UIImageView+WebCache
    ○ UIImageView+HighlightedWebCache
    ○ UIButton+WebCache
    ○ MKAnnotationView+WebCache
    ○ NSData+ImageContentType
    ○ UIImage+GIF
    ○ UIImage+MultiFormat
    ○ UIImage+WebP

  • Other
    ○ SDWebImageOperation(协议)
    ○ SDWebImageCompat(宏定义、常量、通用函数)

相关类名与功能描述

SDWebImageDownloader:是专门用来下载图片和优化图片加载的,跟缓存没有关系

SDWebImageDownloaderOperation:继承于 NSOperation,用来处理下载任务的

SDImageCache:用来处理内存缓存和磁盘缓存(可选)的,其中磁盘缓存是异步进行的,因此不会阻塞主线程

SDWebImageManager:作为 UIImageView+WebCache 背后的默默付出者,主要功能是将图片下载(SDWebImageDownloader)和图片缓存(SDImageCache)两个独立的功能组合起来

SDWebImageDecoder:图片解码器,用于图片下载完成后进行解码

SDWebImagePrefetcher:预下载图片,方便后续使用,图片下载的优先级低,其内部由

SDWebImageManager :来处理图片下载和缓存

UIView+WebCacheOperation:用来记录图片加载的 operation,方便需要时取消和移除图片加载的 operation

UIImageView+WebCache:集成 SDWebImageManager 的图片下载和缓存功能到 UIImageView 的方法中,方便调用方的简单使用

UIImageView+HighlightedWebCache:跟 UIImageView+WebCache 类似,也是包装了 SDWebImageManager,只不过是用于加载 highlighted 状态的图片

UIButton+WebCache:跟 UIImageView+WebCache 类似,集成 SDWebImageManager 的图片下载和缓存功能到 UIButton 的方法中,方便调用方的简单使用

MKAnnotationView+WebCache:跟 UIImageView+WebCache 类似

NSData+ImageContentType:用于获取图片数据的格式(JPEG、PNG等)

UIImage+GIF:用于加载 GIF 动图

UIImage+MultiFormat:根据不同格式的二进制数据转成 UIImage 对象

UIImage+WebP用于解码并加载 WebP 图片

工作流程

工作流程

  • 入口 setImageWithURL:placeholderImage:options: 会先把 placeholderImage 显示,然后 SDWebImageManager 根据 URL 开始处理图片。

  • 进入 SDWebImageManager-downloadWithURL:delegate:options:userInfo:交给 SDImageCache 从缓存查找图片是否已经下载 queryDiskCacheForKey:delegate:userInfo:。

  • 先从内存图片缓存查找是否有图片,如果内存中已经有图片缓存,SDImageCacheDelegate 回调 imageCache:didFindImage:forKey:userInfo: 到 SDWebImageManager。

  • SDWebImageManagerDelegate 回调 webImageManager:didFinishWithImage: 到 UIImageView+WebCache 等前端展示图片。

  • 如果内存缓存中没有,生成 NSInvocationOperation 添加到队列开始从硬盘查找图片是否已经缓存。

  • 根据 URLKey 在硬盘缓存目录下尝试读取图片文件。这一步是在 NSOperation 进行的操作,所以回主线程进行结果回调 notifyDelegate:。

  • 如果从硬盘读取到了图片,将图片添加到内存缓存中(如果空闲内存过小,会先清空内存缓存)。SDImageCacheDelegate 回调 imageCache:didFindImage:forKey:userInfo:进而回调展示图片。

  • 如果从硬盘缓存目录读取不到图片,说明所有缓存都不存在该图片,需要下载图片,回调 imageCache:didNotFindImageForKey:userInfo:。

  • 共享或重新生成一个下载器 SDWebImageDownloader 开始下载图片。

  • 图片下载由 NSURLConnection(3.8.0之后使用了NSURLSession),实现相关 delegate 来判断图片下载中、下载完成和下载失败。

  • connection:didReceiveData: 中利用 ImageIO 做了按图片下载进度加载效果。connectionDidFinishLoading: 数据下载完成后交给 SDWebImageDecoder 做图片解码处理。

  • 图片解码处理在一个 NSOperationQueue 完成,不会拖慢主线程 UI。如果有需要对下载的图片进行二次处理,最好也在这里完成,效率会好很多。

  • 在主线程 notifyDelegateOnMainThreadWithInfo: 宣告解码完成,imageDecoder:didFinishDecodingImage:userInfo: 回调给 SDWebImageDownloader。

  • imageDownloader:didFinishWithImage: 回调给 SDWebImageManager 告知图片下载完成。

  • 通知所有的 downloadDelegates 下载完成,回调给需要的地方展示图片。

  • 将图片保存到 SDImageCache 中,内存缓存和硬盘缓存同时保存。写文件到硬盘也在以单独 NSInvocationOperation 完成,避免拖慢主线程。

  • SDImageCache 在初始化的时候会注册一些消息通知,在内存警告或退到后台的时候清理内存图片缓存,应用结束的时候清理过期图片。

  • SDWebImagePrefetcher 可以预先下载图片,方便后续使用。

常见面试题

  1. 图片文件缓存的时间有多长:1周

_maxCacheAge = kDefaultCacheMaxCacheAge

  1. SDWebImage 的内存缓存是用什么实现的?

NSCache

  1. SDWebImage 的最大并发数是多少?

maxConcurrentDownloads = 6

  • 是程序固定死了,可以通过属性进行调整!
  1. SDWebImage 支持动图吗?GIF
1. #import <ImageIO/ImageIO.h>
2. [UIImage animatedImageWithImages:images duration:duration];
复制代码
  1. SDWebImage是如何区分不同格式的图像的

    • 根据图像数据第一个字节来判断的!
    • PNG:压缩比没有JPG高,但是无损压缩,解压缩性能高,苹果推荐的图像格式!
    • JPG:压缩比最高的一种图片格式,有损压缩!最多使用的场景,照相机!解压缩的性能不好!
    • GIF:序列桢动图,特点:只支持256种颜色!最流行的时候在1998~1999,有专利的!

6.SDWebImage 缓存图片的名称是怎么确定的!

  • md5

  • 如果单纯使用 文件名保存,重名的几率很高!

  • 使用 MD5 的散列函数!对完整的 URL 进行 md5,结果是一个 32 个字符长度的字符串!

  1. SDWebImage 的内存警告是如何处理的!
    • 利用通知中心观察
    • - UIApplicationDidReceiveMemoryWarningNotification 接收到内存警告的通知
    • 执行 clearMemory 方法,清理内存缓存!
    • - UIApplicationWillTerminateNotification 接收到应用程序将要终止通知
    • 执行 cleanDisk 方法,清理磁盘缓存!
    • - UIApplicationDidEnterBackgroundNotification 接收到应用程序进入后台通知
    • 执行 backgroundCleanDisk 方法,后台清理磁盘!
    • 通过以上通知监听,能够保证缓存文件的大小始终在控制范围之内!
    • clearDisk 清空磁盘缓存,将所有缓存目录中的文件,全部删除!

实际工作,将缓存目录直接删除,再次创建一个同名空目录!

青山不改,绿水长流,后会有期,感谢每一位佳人的支持!

收起阅读 »

闲鱼正在悄悄放弃 Flutter 吗?

iOS
闲鱼技术阿里巴巴集团采访嘉宾 | 于佳(宗心)编辑 | Tina闲鱼在 2017 年引入 Flutter,当时的 Flutter 还远未成熟,行业内也没有把 Flutter 放入已有工程体系进行开发的先例。之后这支不到 15 人的闲鱼团队从工程架构、混合栈调用...
继续阅读 »

闲鱼技术lv-4阿里巴巴集团

采访嘉宾 | 于佳(宗心)

编辑 | Tina

闲鱼在 2017 年引入 Flutter,当时的 Flutter 还远未成熟,行业内也没有把 Flutter 放入已有工程体系进行开发的先例。

之后这支不到 15 人的闲鱼团队从工程架构、混合栈调用、打包构建、协同模式上都做了一些创新,保证了 Flutter 能融入到闲鱼已有的客户端工程体系内。在 2017 年到 2019 年期间,闲鱼也不断的修正 Bug 提高 Flutter 的稳定性并同步给 Google,并在实践中沉淀出一套自己的混合技术方案,开源了 Flutter Boost 引擎。

2019 年,闲鱼开始大规模落地,推进 Flutter 在闲鱼的应用。2020 年,闲鱼线上的主链路几乎已经完全拥抱 Flutter。这两年,Flutter 也逐渐在其他企业里落地,但同时也不断有质疑的声音发出。甚至有传言表示“闲鱼的新业务已经放弃 Flutter”、“相信闲鱼遇到了很大的难题”......

那么,作为 Flutter 先驱和探路者,闲鱼在过去几年的摸索过程中是否有走弯路?闲鱼现在到底面临着什么样的挑战?是否会放弃 Flutter?新业务选择了什么技术?对应的技术选型原则是什么?针对这些疑问,闲鱼技术团队客户端负责人于佳(宗心)逐一给了我们解答。

国内第一个引进 Flutter 的团队

InfoQ:闲鱼当时引进 Flutter 时主要是为了解决什么问题?

于佳(宗心):闲鱼在 17 年调研的时候,客户端团队只有不到 15 人,而闲鱼的业务场景可以称得上是一个 “小淘宝”,相对比较复杂。这种场景下我们首先需要解决的是多端人力共享的问题。多端人力带来的好处不只是可以一人开发双端,也代表着更好的研发资源调配灵活性(这意味着团队的 iOS:Android 的比例不再需要 1:1,而市面上 Android 的工程师基数远大于 iOS)。

另外我们希望这个技术是贴合移动端研发技术栈的,而非前端技术栈,本身对于 RN 和 Weex 来说,工具链和研发习惯还是有比较大的差异的。最后我们希望这个技术的体验可以做到接近原生,AOT 下的 Flutter 基本满足我们当时的要求,在实际测试过程中,同样未深度优化的详情页面,Flutter 在低端机的表现比 Native 更好。因此当时基于这三个条件选择了 Flutter。

2018 年的尝试投入过程中,整个基建和探索带来了一定的成本。2019 年,团队开始正式大量使用 Flutter 进行研发,目前整个团队 70% 的 commit 来自 Dart,可以说基本完成了我们当初的期望。在实际的研发过程中,基本可以完成一个需求一个客户端投入的目标。

InfoQ:很多人质疑 Dart 语言,认为这个语言独特小众,还存在比如说多层嵌套的问题,您们怎么看待新语言的应用?

于佳(宗心):语言是我们选择技术方案的其中一个因素,但是相对比较弱的因素。

我们会从几个角度去看:

  • 语言的背景,从我们的角度来看 Dart 是大厂研发的,也有比较久的历史。

  • 语言的学习成本,从语法糖和学习曲线上来看,Dart 成本都比较低,首先 Android 同学的上手率很快。另外熟悉 swift 的 iOS 同学,上手也很快。现代语言的特性有很多是相通的。这部分是它的优势。

  • 语言带来的其他优势,如编译产物支持 AOT 和 JIT,比较灵活。AOT 有明显的性能优势。

  • 语言的未来的趋势。Dart 在 2020 年第四季度 Github Pull Request 的排名已经到了全网第 13 位,超过了 Kotlin(15 位),Swift(16 位),Objective-C(18 位)。作为移动技术领域的新语言成长性还是非常不错的。

对于像多层嵌套的问题,可以通过进一步抽象一些控件类或方法解决,并不是特别大的问题。

InfoQ:闲鱼引入 Flutter 之后做了哪些关键创新?在使用 Flutter 上有哪些收益?

于佳(宗心):闲鱼在这部分创新非常多,并在内部申请了非常多专利。

  • 我们的开源项目 Flutter Boost 彻底改变了 Flutter 官方的一些 RoadMap。目前 Add2ExistApp 是行业最主流的研发方式。混合开发一方面帮助了业务更平滑的迁移到了新的技术栈,另一方面可以更好的利用已有的 Native 能力,大幅减少了重复开发的工作。

  • 针对音视频的外接纹理方案,也是目前行业大厂常见的解决方案,在外接纹理方案下,Native 和 Flutter 侧的缓存管理得到了统一,在性能上也有一定的提升。

  • Flutter APM,基于 Flutter 技术栈的性能稳定性数据采集和加工方案,目前在集团内部也是跟多个 BU 一起共建,为大的 AliFlutter 组织提供服务。

  • Flutter 相关的动态模版方案,Flutter DX,兼容集团的已有的 Native 模版,保证了业务的平滑迁移,并为 Flutter 提供了部分业务动态性。

  • 其他还有很多,包括内部的高性能长列表容器 PowerScrollView,动画框架 Fish-Lottie,游戏引擎 Candy,我们现在还有一些新的方向在沉淀,在基于 Flutter 的研发流程和研发工具上也有投入,未来大家如果感兴趣可以去 InfoQ 组织的行业大会与我们交流。

闲鱼有想过放弃 Flutter 吗?

InfoQ:最近一两年,您们在 Flutter 开发上,遇到的最大挑战是什么?跟最初使用 Flutter 时的挑战一样吗?

于佳(宗心):早先几年闲鱼作为整个行业的先驱,主要的挑战是整个技术生态太差,都需要自己做。另外就是前期引擎的稳定性有比较大的问题。

最近几年随着整个技术的深度使用,以及闲鱼这两年业务快速发展背后,越来越多的体验问题被大家提及,因此我们从去年开始进行了整个产品的大改版,同时客户端的目标就是全面优化,打造更好的用户端产品体验。

因此在生态逐渐完善后,我们的挑战是,怎么通过 Flutter 来实现更加精细化的用户体验。去年,这部分确实花了我们比较多的精力。基于这个命题,我们在内存和卡顿上内部也开发了较多的基于 Flutter 的检测工具,在内存优化和卡顿优化上也有一些比较具体的方法,但不得不说,所有的细节优化都是比较耗人力的,不管是 Native 还是 Flutter 都要投入相当的精力,所以我们目前也面向全行业进行客户端的招聘,希望有志在 Flutter 领域进行探索的同学联系我。

InfoQ:在混合研发体系下,闲鱼还进行了引擎定制,那么官方提供的方案主要问题是什么?对于一般小企业来说,混合开发复杂度会不会太高?

于佳(宗心):闲鱼在前期有不少修改引擎的动作,我针对当时有一些 自己的反思,一方面是确实因为 Flutter 不太完善,另一方面在 18 年左右,我们自己引擎的理解也不够深刻,很多时候可以通过更上层的方案解决,这也间接导致了我们的很多引擎定制修改难以合入主干。

所以这部分我想说的是,目前官方的方案可以解决 90% 的问题,如果一定要说定制,目前在性能侧还是有一些问题的。比如闲鱼目前首页还是 native 没有使用 Flutter,就是因为替换以后启动加载体验不佳,另外在长列表侧大家一直诟病的卡顿问题,我们有尝试通过上层框架解决了一部分,接下来可能还需要底层引擎帮忙优化。另外一些包括双端字体不一致的问题,还有输入框体验不一致的问题,都需要官方进行长期的优化。

目前我们主要还是希望跟随主干分支,尽量不修改 Flutter 的代码,闲鱼团队会储备一些引擎侧的专家,同时也会依靠集团 AliFlutter 的生态做事情。在整个 AliFlutter 的组织里不同的 BU 擅长的也不同,如 UC 同学更擅长引擎定制,闲鱼团队有大量的上层应用框架,淘宝团队提供基于构建相关的基础设施。这样在大型公司中通过内部开源社区的方式就可以解决大部分的问题,放心开发了。

对于中小企业来说,要明确下大家面临的场景,如果前期快速迭代跑起来,对细节问题可以有一部分妥协,选择 Flutter 是一个比较明确的路径。今天大家所处的环境比闲鱼当年所处的环境要完善的多。推荐使用 Flutter Boost 进行混合开发,在部分场景下遇到问题无法快速响应时,也可以通过混合工程使用 native 进行兜底。复杂度方面,单纯引入混合栈能力,整体复杂度一般。

InfoQ:有传言,闲鱼有新业务没采用 Flutter,这给很多人造成了闲鱼放弃 Flutter 的观念,那么您们在新业务的技术选型上,考虑了哪些因素?

于佳(宗心):作为技术决策者,是应该避免自己被某一个技术绑架而在落地过程中产生谬误的。Flutter 和其他技术一样,最终是为了帮助团队实现业务价值,同时它也只是移动端的一种技术,捧杀和谩骂都是不合适的。这也是我特别不想在公众面前回应这个事情的原因,因为 技术本身要看适用场景。

从目前闲鱼的人员规模和业务规模来看。对于架构设计,我的理念是尽量追求一致性和架构的简洁。

整个客户端组织未来从语言的方向来看是 Dart First,尽量减少双端的研发投入。而对其他容器的选择,主要以 H5 为主,在未来的路径上尽量减少其他容器的接入,让前端开发也回归到标准路线来。

这里有两个好处:

  1. 组织成本最低,组织成本包括了同学们的学习成本、协同成本等等,多技术栈和容器多会带来额外的成本,这是我不愿意看到的。

  2. 架构的一致性对研发效能和质量都有帮助。举个例子,随着业务复杂性加大,多容器带来的内存飙升和包大小的问题是非常致命的,而且几乎是无解的,这就需要架构师作出决策,干掉什么留下什么。回到研发效能上,配套的工具,流程一定是围绕一类容器和语言来扩展的,如果方案特别多,每个方向都需要做额外的配套设施,成本收益很低,研发的幸福感也很低。

从这个设计的角度出发,我们会有几个明确的选择

  • 在默认场景下使用 Flutter 作为首选的方案;

  • 在投放活动、前台导购、非常不确定的新业务、以及管理后台等使用 H5 作为首选实现方案;

  • 在极少场景下,比如已有完整的 SDK 附带 UI 的支持如直播,以及未来中台的拍摄功能 SDK 也是自带 UI 的部分,如要切换,Native 成本最低,选择 Native。另外目前 Flutter 在首页加载还有一定的性能问题,因此还在使用 Native。从长远发展来看,未来到一定程度可能随改版直接改为 Flutter。

关于未来发展

InfoQ:使用 Flutter 多年后,现在回过头去看,您认为哪些公司哪些场景适合 Flutter?

于佳(宗心):目前看起来有几个典型场景比较适合:

  • 中台战略下的小前台产品,从大公司的组织里看阿里、头条、美团都有相对完善的 Flutter 组织或内部技术社区可以提供一些基础服务,保证了基于 Flutter 基础设施在前期投入过程中的成本均摊,在未来落地过程中,业务团队可以更加专注于业务研发,而更少的担心过程中填坑的成本。

  • 中小型企业的初创 App,在人力成本资源都不够的情况下,希望先跑通流程上线验证的团队,可以尝试使用 Flutter 进行研发,在我自己实际的面试和行业交流过程中,这一类情况也比较典型。这种方式可以避免前期成本过度投入,在人员调配上也更加灵活。

  • 另外这个观点还没有验证,但是逻辑上应该可行。未来面向企业内部流程工具,政府部门的部分工具属性较强的 App,可以尝试使用 Flutter。因为目前我了解的情况来看,在企业这边的应用来看,整体 ToB(美团商家端)和 ToD(比如饿了么骑手端)的场景的 App 特别多。横向比较来看,场景比较类似,也就是说更多中长尾应用有可能是 Flutter 技术的主要场景。

InfoQ:您认为未来 Flutter 急需改善的地方是什么?

于佳(宗心):从 Flutter 2.0 发布后我跟一些一线开发者交流的感受来看,Flutter 还是需要推进跨端性能和细节体验的优化。去年一年在大的战略方向上(跨终端),Flutter 做的不错,在 PC 和 Web 侧都有建树,跟车企以及操作系统厂商合作都有一定进展。但回归到产品体验和开发者体验上,还有不少路要走,很多时候对于一个严苛的业务方来说,小到字体和控件的体验都会成为最后不选择这门技术的原因。这部分希望整个开源社区在新的一年能有一些进步。我们 AliFlutter 组织内部,以 UC 内核团队为首的同学们,在这方面就有非常多的沉淀以及 PR,在内部引擎制定上有很多体验的提升。未来在 AliFlutter 组织内,我们也会除了完善整个公司的基建外,进一步关注细节体验,沉淀一些最佳实践给到其他的开发同学。大家会在2个月内看到我们最新出版的书籍,欢迎交流。

InfoQ:Flutter2.0 来了,那么 Flutter 会成为主流选择吗?

于佳(宗心):可以讲一下我对 Flutter 未来的判断。一方面在未来操作系统有可能走向分裂,多终端的场景下,Flutter 会有比较不错的发展,跨平台本身的对企业来说在成本侧是有很大的诉求的,尤其是互联网公司。但是从历史的经验来看,Flutter 只是渲染引擎,即使今天的游戏开发,在游戏引擎和配套工具完善的情况下,有部分的功能模块(比如社区 / 直播的功能)依然还是混合的框架,所以混合开发最后一定是一直存在的。能不能成为未来整个移动研发的主流这件事情上看,我无法给出答案,但可以肯定的是,在生态更加完善后,会在一定的历史阶段成为客户端研发的另一种常见的技术选择。

嘉宾介绍:

于佳,花名 宗心,闲鱼技术团队客户端负责人。2012 年应届毕业加入阿里巴巴,经历集团无线化转型的重要时期,参与过集团多款重量级 App 以及移动中间件的设计与开发,多年客户端老兵。2014 年参与了手机淘宝的 iOS 客户端的架构升级,该架构首次完成了对百人团队并行开发的支持,同年主导了手机天猫客户端基础架构以及交易链路向手淘架构的归一,为手机淘宝作为未来集团无线中台奠定了坚实的基础。2015 年加入闲鱼客户端团队负责端架构和团队建设,工作期间完成了基于 Flutter 混合架构的闲鱼客户端的整体架构设计,在工程体系上完善了针对 Flutter 的持续集成以及高可用体系的支撑,同时推进了闲鱼主链路业务的 Flutter 化。未来将持续关注终端技术的演变及发展趋势。

收起阅读 »

如何用 GPU硬件层加速优化Android系统的游戏流畅度

作为一款VR实时操作游戏App,我们需要根据重力感应系统,实时监控手机的角度,并渲染出相应位置的VR图像,因此在不同 Android 设备之间,由于使用的芯片组和不同架构的GPU,游戏性能会因此受到影响。举例来说:游戏在 Galaxy S20+ 上可能以 6...
继续阅读 »

作为一款VR实时操作游戏App,我们需要根据重力感应系统,实时监控手机的角度,并渲染出相应位置的VR图像,因此在不同 Android 设备之间,由于使用的芯片组和不同架构的GPU,游戏性能会因此受到影响。举例来说:游戏在 Galaxy S20+ 上可能以 60fps 的速度渲染,但它在HUAWEI P50 Pro上的表现可能与前者大相径庭。 由于新版本的手机具有良好的配置,而游戏需要考虑基于底层硬件的运行情况。

如果玩家遇到帧速率下降或加载时间变慢,他们很快就会对游戏失去兴趣。
如果游戏耗尽电池电量或设备过热,我们也会流失处于长途旅行中的游戏玩家。
如果提前预渲染不必要的游戏素材,会大大增加游戏的启动时间,导致玩家失去耐心。
如果帧率和手机不能适配,在运行时会由于手机自我保护机制造成闪退,带来极差的游戏体验。

基于此,我们需要对代码进行优化以适配市场上不同手机的不同帧率运行。

所遇到的挑战

首先我们使用Streamline获取在 Android 设备上运行的游戏的配置文件,在运行测试场景时将 CPU 和 GPU性能计数器活动可视化,以准确了解设备处理 CPU 和 GPU 工作负载,从而去定位帧速率下降的主要问题。

以下的帧率分析图表显示了应用程序如何随时间运行。

在下面的图中,我们可以看到执行引擎周期与 FPS 下降之间的相关性。显然GPU 正忙于算术运算,并且着色器可能过于复杂。

为了测试在不同设备中的帧率情况,使用友盟+U-APM测试不同机型上的卡顿状况,发现在onSurfaceCreated函数中进行渲染时出现卡顿, 应证了前文的分析,可以确定GPU是在算数运算过程中发生了卡顿:

因为不同设备有不同的性能预期,所以需要为每个设备设置自己的性能预算。例如,已知设备中 GPU 的最高频率,并且提供目标帧速率,则可以计算每帧 GPU 成本的绝对限制。

数学公式: $ 每帧 GPU 成本 = GPU 最高频率 / 目标帧率 $

CPU 到 GPU 的调度存在一定的约束,由于调度上存在限制所以我们无法达到目标帧率。

另外,由于 CPU-GPU 接口上的工作负载序列化,渲染过程是异步进行的。
CPU 将新的渲染工作放入队列,稍后由 GPU 处理。

数据资源问题

CPU 控制渲染过程并且实时提供最新的数据,例如每一帧的变换和灯光位置。然而,GPU 处理是异步的。这意味着数据资源会被排队的命令引用,并在命令流中停留一段时间。而程序中的OpenGL ES 需要渲染以反映进行绘制调用时资源的状态,因此在引用它们的 GPU 工作负载完成之前无法修改资源。

调试过程

我们曾做出尝试,对引用资源进行代码上的编辑优化,然而当我们尝试修改这部分内容时,会触发该部分的新副本的创建。这将能够一定程度上实现我们的目标,但是会产生大量的 CPU 开销。

于是我们使用Streamline查明高 CPU 负载的实例。在图形驱动程序内部libGLES_Mali.so路径函数, 视图中看到极高的占用时间。

由于我们希望在不同手机上适配不同帧率运行,所以需要查明libGLES_Mali.so是否在不同机型的设备上都产生了极高的占用时间,此处采用了友盟+U-APM来检测用户在不同机型上的函数占用比例。

友盟+ U-APM自定义异常测试,下列机型会产生高libGLES_Mali.so占用的问题,因此我们需要基于底层硬件的运行情况来解决流畅性问题,同时由于存在问题的机型不止一种,我们需要从内存层面着手,考虑如何调用较少的内存缓存区并及时释放内存。

解决方案及优化

基于前文的分析,我们首先尝试从缓冲区入手进行优化。

单缓冲区方案
• 使用glMapBufferRange和GL_MAP_UNSYNCHRONIZED.然后使用单个缓冲区内的子区域构建旋转。这避免了对多个缓冲区的需求,但是这一方案仍然存在一些问题,我们仍需要处理管理子区域依赖项,这一部分的代码给我们带来了额外的工作量。

多缓冲区方案
• 我们尝试在系统中创建多个缓冲区,并以循环方式使用缓冲区。通过计算我们得到了适合的缓冲区的数目,在之后的帧中,代码可以去重新使用这些循环缓冲区。由于我们使用了大量的循环缓冲区,那么大量的日志记录和数据库写入是非常有必要的。但是有几个因素会导致此处的性能不佳:

1. 产生了额外的内存使用和GC压力
2. Android 操作系统实际上是将日志消息写入日志而并非文件,这需要额外的时间。
3. 如果只有一次调用,那么这里的性能消耗微乎其微。但是由于使用了循环缓冲区,所以这里需要用到多次调用。
我们会在基于c#中的 Mono 分析器中启用内存分配跟踪函数用于定位问题:

$ adb shell setprop debug.mono.profile log:calls,alloc

我们可以看到该方法在每次调用时都花费时间:

Method call summary Total(ms) Self(ms) Calls Method name 782 5 100 MyApp.MainActivity:Log (string,object[]) 775 3 100 Android.Util.Log:Debug (string,string,object[]) 634 10 100 Android.Util.Log:Debug (string,string)

在这里定位到我们的日志记录花费了大量时间,我们的下一步方向可能需要改进单个调用,或者寻求全新的解决方案。

log:alloc还让我们看到内存分配;日志调用直接导致了大量的不合理内存分配:

Allocation summary Bytes Count Average Type name 41784 839 49 System.String 4280 144 29 System.Object[]

硬件加速

最后尝试引入硬件加速,获得了一个新的绘图模型来将应用程序渲染到屏幕上。它引入了DisplayList 结构并且记录视图的绘图命令以加快渲染速度。

同时,可以将 View渲染到屏幕外缓冲区并随心所欲地修改它而不用担心被引用的问题。此功能主要适用于动画,非常适合解决我们的帧率问题,可以更快地为复杂的视图设置动画。

如果没有图层,在更改动画属性后,动画视图将使其无效。对于复杂的视图,这种失效会传播到所有的子视图,它们反过来会重绘自己。

在使用由硬件支持的视图层后,GPU 会为视图创建纹理。因此我们可以在我们的屏幕上为复杂的视图设置动画,并且使动画更加流畅。

代码示例:

// Using the Object animator view.setLayerType(View.LAYER_TYPE_HARDWARE, null); ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, View.TRANSLATION_X, 20f); objectAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { view.setLayerType(View.LAYER_TYPE_NONE, null); } }); objectAnimator.start(); // Using the Property animator view.animate().translationX(20f).withLayer().start();

另外还有几点在使用硬件层中仍需注意:

(1)在使用之后进行清理:

硬件层会占用GPU上的空间。在上面的 ObjectAnimator代码中,侦听器会在动画结束时移除图层。在 Property animator 示例中,withLayers()方法会在开始时自动创建图层并在动画结束时将其删除。

(2)需要将硬件层更新可视化:

使用开发人员选项,可以启用“显示硬件层更新”。
如果在应用硬件层后更改视图,它将使硬件层无效并将视图重新渲染到该屏幕外缓冲区。

硬件加速优化

但是由此带来了一个问题是,在不需要快速渲染的界面,比如滚动栏, 硬件层也会更快地渲染它们。当将 ViewPager 滚动到两侧时,它的页面在整个滚动阶段会以绿色突出显示。

因此当我滚动ViewPager时,我使用DDMS运行 TraceView
,按名称对方法调用进行排序,搜索“android/view/View.setLayerType”,然后跟踪它的引用:

ViewPager#enableLayers(): private void enableLayers(boolean enable) { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final int layerType = enable ? ViewCompat.LAYER_TYPE_HARDWARE : ViewCompat.LAYER_TYPE_NONE; ViewCompat.setLayerType(getChildAt(i), layerType, null); } }

该方法负责为 ViewPager的孩子启用/禁用硬件层。它从 ViewPaper#setScrollState() 调用一次:

private void setScrollState(int newState) { if (mScrollState == newState) { return; } mScrollState = newState; if (mPageTransformer != null) { enableLayers(newState != SCROLL_STATE_IDLE); } if (mOnPageChangeListener != null) { mOnPageChangeListener.onPageScrollStateChanged(newState); } }

正如代码中所示,当滚动状态为IDLE时硬件被禁用,否则在DRAGGINGSETTLING时启用。PageTransformer 旨在“使用动画属性将自定义转换应用于页面视图”(Source)。

基于我们的需求,只在渲染动画的时候启用硬件层,所以我想覆盖ViewPager 方法,但由于它们是私有的,我们无法修改这个方法。

所以我采取了另外的解决方案:在 ViewPage#setScrollState() 上,在调用 enableLayers()之后,我们还会调用

OnPageChangeListener#onPageScrollStateChanged()

。所以我设置了一个监听器,当 ViewPager的滚动状态不同于 IDLE时,它将所有ViewPager的孩子的图层类型重置为 NONE

@Override public void onPageScrollStateChanged(int scrollState) { // A small hack to remove the HW layer that the viewpager add to each page when scrolling. if (scrollState != ViewPager.SCROLL_STATE_IDLE) { final int childCount = <your_viewpager>.getChildCount(); for (int i = 0; i < childCount; i++) <your_viewpager>.getChildAt(i).setLayerType(View.LAYER_TYPE_NONE, null); } }

这样,在ViewPager#setScrollState()为页面设置了一个硬件层之后——我将它们重新设置为NONE,这将禁用硬件层,因此而导致的帧率区别主要显示在Nexus上。

作者:六一
来源:https://segmentfault.com/a/1190000040864118

收起阅读 »

黄仁勋要造“第二颗地球”?对“元宇宙”意味着什么

随着元宇宙概念的爆火,英伟达正凭借其Omniverse平台及在AI、高性能计算等方面的建树,迅速成长为市值高达7700亿美元的AI顶级玩家。作为科技圈极具个性的大佬“黄教主”——NVIDIA创始人兼CEO黄仁勋,每年都会在行业科技展会上拿出一些有趣的东西。在刚...
继续阅读 »


随着元宇宙概念的爆火,英伟达正凭借其Omniverse平台及在AI、高性能计算等方面的建树,迅速成长为市值高达7700亿美元的AI顶级玩家。

作为科技圈极具个性的大佬“黄教主”——NVIDIA创始人兼CEO黄仁勋,每年都会在行业科技展会上拿出一些有趣的东西。

在刚刚结束的 NVIDIA GTC 2021 大会上,黄仁勋惊喜亮相,表达了对今年爆火的元宇宙的痴迷,更宣布要造“第二颗地球”!

一时间,引发“全网”热议。

“元宇宙”格局打开:Omniverse Avatar

此次大会上,英伟达推出了全球最小、功能强大、能效最高的新一代AI超级计算机NVIDIA Jetson AGX Orin、NVIDIA Triton推理服务器及NVIDIA A2 Tensor Core GPU加速器、NeMo Megatron、NVIDIA Modulus及新自动驾驶平台DRIVE Hyperion 8 GA等一系列重磅新技术。

同时,英伟达还推出了3个新加速库:一、针对运筹优化问题的加速求解器——NVIDIA ReOpt,可实现实时路线规划优化。二、cuQuantum DGX设备,配备有针对量子计算工作流的加速库,可用态矢量和张量网络的方法来加速量子电路模拟。三、在PyData和NumPy生态系统的大规模加速计算cuNumeric,属于NVIDIA RAPIDS开源Python数据科学套件。

除此之外,会上黄仁勋还带来了承载着其“元宇宙”愿景的全新虚拟化身平台——Omniverse Avatar,彻底将元宇宙格局打开。

作为NVIDIA一系列先进AI技术的集大成者,Omniverse Avatar可以将Metropolis的感知能力、Riva的语音识别能力、Merlin的推荐能力、Omniverse的动画渲染能力等交汇于一体。

Omniverse Avatar能帮助开发者能构建出一个完全交互式的虚拟化身,它足够生动,能对语音和面部提示做出反应,能理解多种语言,能给出智能的建议。

通过Omniverse Avatar平台,用户可以为视频会议和协作平台、客户支持平台、内容创建、应用收益和数字孪生、机器人应用等等构建定制的AI助理。

英伟达的“元宇宙”探索之路

对于英伟达来说,早在2020年10月份,该公司就10月推出了面向企业的实时仿真和协作平台Omniverse的测试版,当时就吸引了包括宝马、爱立信、沃尔沃、Adobe、Epic Games在内的众多公司与之合作。

而今年4月份, Omniverse 的正式版一经推出后,便被称为“工程师的元宇宙”的虚拟工作平台。

黄仁勋评价称,“Omniverse可以让个人模拟制造出遵从物理规律的共享3D虚拟世界。”

自此,英伟达彻底掀起了国内“元宇宙”的浪潮。

而据英伟达相关负责人透露,英伟达已经为此花费了数年时间和数亿美元。

“人类需要‘在为时已晚之前采取行动缓解和适应’”。本次GTC大会上,黄仁勋谈到了许多关于未来的畅想:英伟达未来的规划,将主要着力于生物合成、气候预测等与人类未来息息相关的方面。

”使用物理原理以及源自原理型模型和观测结果的数据 Physics-ML 模型,经过优化在多 GPU 和多节点上进行训练,以超实时方式预测气候变化、创造地球的数字孪生模型或者将其数据传输到元宇宙中。”

最后,黄仁勋正式宣布了“Earth-two第二颗地球”即将到来。

据悉,英伟达上一台超级计算机名为 Cambridge-1,即 C-1,接下来英伟达将着手研发新的超级计算机“E-2”,即“Earth-two”第二颗地球。

黄仁勋强调:“我们目前所发明的所有技术均是实现元宇宙所必不可少的,我想象不出比这更宏伟更重要的用途。”

元宇宙火爆背后也需要警醒

其实除了英伟达,对于元宇宙的突然爆火,今年以来已经有不少业内不少科技公司都纷纷跟进布局,乘此风口发力突围。

比如Facebook(现更名为Meta)早在2019年就同意收购了专门从事云游戏开发的PlayGiga;今年早些时候,微软(Microsoft Corp.)以75亿美元收购电子游戏公司ZeniMax Media Inc.等等,再比如从过去到现在一直在对元宇宙进行探索的英伟达,这些都让VR/AR及相关衍生产业热度大涨。

当然,也并不是所有科技公司都认同元宇宙这个概念。也有内人士评论称,“目前构成“元宇宙”概念的技术力量薄弱,商品化程度低,处于发展的初级阶段”。

Facebook (现更名为Meta)的老对手Snap Inc.,就对此有不同看法,尽管该公司目前也在投资增强现实技术。

据《华尔街日报》报道,此前Snap Inc.公司首席执行官埃文·斯皮格尔(Evan Spiegel)在Tech Live大会上曾表示,“我们之所以对增强现实如此感兴趣,因为它的立足点是我们共享的这个世界。”

所以,看到这里,我们也会思考,如果能够让这个世界变的更好,那么这些技术这个发展趋势某些方面会值得人们追捧。但同时,我们也要时刻保持警醒,避免被“元宇宙”概念中所描绘的巨无霸虚拟世界所“迷惑而上瘾”。

作者:MissD
来源:https://segmentfault.com/a/1190000040937338


收起阅读 »

关于组件文档从编写到生成的那些事

前言说到前端领域的组件,Vue 技术体系下有 Element UI,React 技术体系下有 Ant Design,这些都是当前的前端攻城狮们都免不了要实际使用到的基础组件库。而在实际工作中,我们也总免不了要根据自己的工作内容,整理一些适合自己业务风格的一套组...
继续阅读 »



前言

说到前端领域的组件,Vue 技术体系下有 Element UI,React 技术体系下有 Ant Design,这些都是当前的前端攻城狮们都免不了要实际使用到的基础组件库。而在实际工作中,我们也总免不了要根据自己的工作内容,整理一些适合自己业务风格的一套组件库,基础组件部分可以基于上面开源的组件库以及 less 框架等多主题样式方案做自己的定制,但更多的是一些基于这些基础组件整理出适合自己业务产品的一套业务组件库。

而说到开发组件库,我们或选择 Monorepo 单仓库多包的形式(参考网文 https://segmentfault.com/a/11... 等)或其他 Git 多仓库单包的形式来维护组件代码,最终都免不了要将组件真正落到一个文档中,提供给其他同事去参考使用。

本篇文章就产出组件文档这件事,聊聊我在产出文档过程中的一系列思考过程,解决组件开发这「最后一公里」中的体验问题。

组件文档的编写

规范与搭建的调研

组件文档是要有一定的规范的,规范是任何软件工程阶段的第一步。对于组件来说,文档透出的内容都应包含哪些内容,决定着组件开发者和使用者双方的所有的体验。确定这些规范并不难,参考开源的组件库的文档,我们会有一些初步的印象。

因为我们团队使用的是 React 技术栈,这里我们参考 Ant Design 组件库。

比如这个最基本的 Button 组件,官方文档从上至下的内容结构是这样:

  1. 显示组件标题,下跟组件的简单描述。

  2. 列出组件的使用场景。

  3. 不同使用场景下的效果演示、代码案例。可外跳 CodeSandbox、CodePen 等在线源码编辑器网站编辑实时查看效果。

  4. 列出组件可配置的属性、接口方法列表。列表中包含属性/方法名、字段类型、默认值、使用描述等。

  5. 常见问题的 FAQ。

  6. 面向设计师的一些 Case 说明链接。

这些文档内容很丰富,作为一个开放的组件库,几乎考虑到的从设计到开发视角的方方面面,使用体验特别好。而在好奇心驱使下,我去查看了官网源码方库,比如 Button 组件:https://github.com/ant-design...。在源码库下,放置了和组件入口文件同名的分别以 .zh-CN.md.en-US.md 后缀命名的 Markdown 文件,而在这些 Markdown 文件中,便是我们看到的官网文档内容...咦?不对,好像缺少了什么,案例演示和示例代码呢?

难道 AntD 官网文档是另外自己手动开发维护的?这么大的一个项目肯定不至于,根据其官网类似 docs/react/introduce-cn 这种访问路径在源码库中有对应的 Markdown 文件来看,官网的文档肯定是官方仓库根据一种规范来生成的。那么是怎么生成的呢?第一次做组件文档规范的我被挑起了兴趣。

而作为一个前端工程老手,我很熟练地打开了其 package.json 文件,通过查看其中的 scripts 命令,轻易便发现了其下的 site 命令(源码仓库 package.json):

npm run site:theme && cross-env NODE_ICU_DATA=node_modules/full-icu ESBUILD=1 bisheng build --ssr -c ./site/bisheng.config.js

原来如此,网站的构建使用了 bisheng。通过查阅了解 bisheng 这个工具库,发现它确实是一个文档系统的自动生成工具,其下有一个插件 bisheng-plugin-react,可以将 Markdown 文档中的 JSX 源码块转换成可以运行演示的示例。而每个组件自身的示例代码文档,则在每个组件路径下的
demo 目录下维护。

Emmm,bisheng 确实是很好很强大,还能支持多语言,结合一定的文档规范约束下,能够快速搭建一个文档的主站。但在深入了解 bisheng 的过程中,发现其文档相对来说比较缺乏,包装的东西又那么多,使用过程中黑盒感严重,而我们团队的组件库其实要求很简单,一是能做到方便流通,而是只在内部流通使用,不会开源。那么,有没有更简单的搭建文档的方式呢?

更多的文档工具库的调研

在谷歌搜索中敲入如 React Components Documentation 等关键字,我们很快便能搜索出很多与 React 组件文档相关的工具库,这里我看到了如下这些:DoczStoryBookReact Styleguidist 、UMI 构建体系下的 dumi 等等。

这些工具库都支持解析 Markdown 文档,其中 DoczStoryBook 还支持使用 mdx 格式(Markdown 和 JSX 的混合写法),且在文档内容格式都能支持到组件属性列表、示例代码演示等功能。

接下来,我们分别简单看下这些工具库对于组件文档的支持情况。

Docz

在了解过程中,发现 Docz 其实是一个比较老牌的文档系统搭建工具了。它本身便主推 MDX 格式的文档,基本不需要什么配置便能跑起来。支持本地调试和构建生成可发布产物,支持多包仓库、TypeScript 环境、CSS 处理器、插件机制等,完全满足功能需要。

只是 Docz 貌似只支持 React 组件(当然对于我们来说够用),且看其 NPM 包最近更新已经是两年之前。另外 MDX 格式的文档虽然理解成本很少但对于使用不多的同事来说还是有一定的接受和熟练上手的成本。暂时备选。

StoryBook

在初次了解到 StoryBook 时便被其 66.7K 的 Star 量惊到了(Docz 是 22K),相对 Docz 来说,StoryBook 相关的社区内容非常丰富,它不依赖组件的技术栈体系,现在已经支持 React、Vue、Angular、Web Components 等数十个技术栈。

StoryBook 搭建文档系统的方式不是去自动解析 Markdown 文件,而是暴露一系列搭建文档的接口,让开发者自己为组件手动编写一个个的 stories 文件,StoryBook 会自动解析这些 stories 文件来生成文档内容。这种方式会带来一定的学习和理解接口的成本,但同时也基于这种方式实现了支持跨组件技术栈的效果,并让社区显得更为丰富。

官方示例:https://github.com/storybookj...

StoryBook 的强大毋庸置疑,但对于我们团队的情况来说还是有些杀鸡用牛刀了。另外,其需要额外理解接口功能并编写组件的 stories 文件在团队内很难推动起来:大家都很忙,组件开发分布在团队几十号人,情况比较复杂,将文档整理约束到一个人身上又不现实。继续调研。

React Styleguidist

React Styleguidist 的 Star 量没有 StoryBook 那么耀眼(10K+),但包体的下载量也比较大,且近期的提交也是相当活跃。由名字可知,它支持的是 React 组件的环境。它是通过自动解析 Mardown 文件的形式来生成文档的,实现方式是自动解析文档中 JSX 声明代码块,按照名称一一对应的规则查找到组件源码,然后将声明的代码块通过 Webpack 打包产生出对应的演示示例。

而在继续试用了 React Styleguidist 的一些基础案例后,它的一个功能让我眼前一亮:它会自动解析组件的属性,并解析出其类型、默认值、注释描述等内容,然后将解析到的内容自动生成属性表格放置在演示示例的上方。这就有点 JSDOC 的意思了,对于一个组件开发者来说,TA 确实需要关心组件属性的透出、注释以及文档案例的编写,但编写完也就够了,不用去考虑怎么适应搭建出一个文档系统。

另外, React Styleguidist 解析组件属性是基于解析 AST 以及配合工具 react-docgen 来实现的,并且还支持配合 react-docgen-typescript 来实现解析 TypeScript 环境下的组件,另外还能很多配置项支持更改文档站点相关的各个部分的展示样式、内容格式等,配置自定义支持相当灵活。

当然,它也有一些缺点,比如内嵌 Webpack,对于已经将编译组件库的构建工具换为 Rollup.js 的情况是一个额外的配置负担。

总的来说,React Styleguidist 在我看来是一个小而美的工具库,很适合我们团队协作参与人多、且大都日常开发工作繁重的情况。暂时备选。

dumi

了解到 dumi 是因为我们团队内已经有部分组件文档站点是基于它来搭建的了。dumi 一样是通过自动解析 Markdown 文档的方式来实现搭建文档系统,同样基本零配置,也有很多灵活的配置支持更改文档站点一些部分的显示内容、(主题)样式等,整体秉承了 UMI 体系的风格:开箱即用,封装极好。它能单独使用,也能结合 UMI 框架一起配置使用。

只是相比于上面已经了解到的 React Styleguidist 来说,并未看到有其他明显的优势,且貌似没有看到有自动解析组件属性部分的功能,对于我来说没有 React Styleguidist 下得一些亮点。可以参考,不再考虑。

组件文档的生成

在多方对比了多个文档搭建的工具库后,我最终还是选用了 React Styleguidist。在我看来,自然是其基于 react-docgen 来实现解析组件属性、类型、注释描述等的功能吸引到了我,这个功能一方面能在较少的额外时间付出下规范团队同事开发组件过程中一系列规范,另一方面其 API 接口的接入形式能够通过统一构建配置而统一产出文档内容格式和样式,方便各业务接入使用。

决定了技术方案后,便是如何具体实现基于其封装一个工具,便于各业务仓库接入了。

我们团队有自己统一的 CLI 构建工具,再多一个 React Styleguidist 的 CLI 配置会在理解上有一定的熟悉成本,但我可以基于 React Styleguidist 的 Node API 接入形式,将 React Styleguidist 的功能分别融入我们自身 CLI 的 devbuild 命令。

首先,基于 React Styleguidist API 的形式,统一一套配置,将生成 React Styleguidist 示例的代码抽象出来:

// 定义一套统一的配置,生成 react-styleguidist 实例
import styleguidist from 'react-styleguidist/lib/scripts/index.esm';
import * as docgen from 'react-docgen';
import * as docgenTS from 'react-docgen-typescript';

import type * as RDocgen from 'react-docgen';

export type DocStyleguideOptions = {
 cwd?: string;
 rootDir: string;
 workDir: string;
 customConfig?: object;
};

const DOC_STYLEGUIDE_DEFAULTS = {
 cwd: process.cwd(),
 rootDir: process.cwd(),
 workDir: process.cwd(),
 customConfig: {},
};

export const createDocStyleguide = (
 env: 'development' | 'production',
 options: DocStyleguideOptions = DOC_STYLEGUIDE_DEFAULTS,
) => {
 // 0. 处理配置项
 const opts = { ...DOC_STYLEGUIDE_DEFAULTS, ...options };
 const {
   cwd: cwdPath = DOC_STYLEGUIDE_DEFAULTS.cwd,
   rootDir,
   workDir,
   customConfig,
} = opts;

 // 标记:是否正在调试所有包
 let isDevAllPackages = true;

 // 解析工程根目录包信息
 const pkgRootJson = Utils.parsePackageSync(rootDir);

 // 1. 解析指定要调试的包下的组件
 let componentsPattern: (() => string[]) | string | string[] = [];
 if (path.relative(rootDir, workDir).length <= 0) {
   // 选择调试所有包时,则读取根路径下 packages 字段定义的所有包下的组件
   const { packages = [] } = pkgRootJson;
   componentsPattern = packages.map(packagePattern => (
     path.relative(cwdPath, path.join(rootDir, packagePattern, 'src/**/[A-Z]*.{js,jsx,ts,tsx}'))
  ));
} else {
   // 选择调试某个包时,则定位至选择的具体包下的组件
   componentsPattern = path.join(workDir, 'src/**/[A-Z]*.{js,jsx,ts,tsx}');
   isDevAllPackages = false;
}

 // 2. 获取默认的 webpack 配置
 const webpackConfig = getWebpackConfig(env);

 // 3. 生成 styleguidist 配置实例
 const styleguide = styleguidist({
   title: `${pkgRootJson.name}`,
   // 要解析的所有组件
   components: componentsPattern,
   // 属性解析设置
   propsParser: (filePath, code, resolver, handlers) => {
     if (/\.tsx?/.test(filePath)) {
       // ts 文件,使用 typescript docgen 解析器
       const pkgRootDir = findPackageRootDir(path.dirname(filePath));
       const tsConfigParser = docgenTS.withCustomConfig(
         path.resolve(pkgRootDir, 'tsconfig.json'),
        {},
      );
       const parseResults = tsConfigParser.parse(filePath);
       const parseResult = parseResults[0];
       return (parseResult as any) as RDocgen.DocumentationObject;
    }
     // 其他使用默认的 react-docgen 解析器
     const parseResults = docgen.parse(code, resolver, handlers);
     if (Array.isArray(parseResults)) {
       return parseResults[0];
    }
     return parseResults;
  },
   // webpack 配置
   webpackConfig: { ...webpackConfig },
   // 初始是否展开代码样例
   // expand: 展开 | collapse: 折叠 | hide: 不显示;
   exampleMode: 'expand',
   // 组件 path 展示内容
   getComponentPathLine: (componentPath) => {
     const pkgRootDir = findPackageRootDir(path.dirname(componentPath));
     try {
       const pkgJson = Utils.parsePackageSync(pkgRootDir);
       const name = path.basename(componentPath, path.extname(componentPath));
       return `import ${name} from '${pkgJson.name}';`;
    } catch (error) {
       return componentPath;
    }
  },
   // 非调试所有包时,不显示 sidebar
   showSidebar: isDevAllPackages,
   // 日志配置
   logger: {
     // One of: info, debug, warn
     info: message => Utils.log('info', message),
     warn: message => Utils.log('warning', message),
     debug: message => console.debug(message),
  },
   // 覆盖自定义配置
   ...customConfig,
});

 return styleguide;
};

这样,在 devbuild 命令下可以分别调用实例的 server 接口方法和 build 接口方法来实现调试和构建产出文档静态资源。

// dev 命令下启动调试
// 0. 初始化配置
const HOST = process.env.HOST || customConfig.serverHost || '0.0.0.0';
const PORT = process.env.PORT || customConfig.serverPort || '6060';

// 1. 生成 styleguide 实例
const styleguide = createDocStyleguide(
 'development',
{
   cwd: cwdPath,
   rootDir: pkgRootPath,
   workDir: workPath,
   customConfig: {
     ...customConfig,
     // dev server host
     serverHost: HOST,
     // dev server port
     serverPort: PORT,
  },
},
);

// 2. 调用 server 接口方法启动调试
const { compiler } = styleguide.server((err, config) => {
 if (err) {
   console.error(err);
} else {
   const url = `http://${config.serverHost}:${config.serverPort}`;
   Utils.log('info', `Listening at ${url}`);
}
});
compiler.hooks.done.tap('done', (stats: any) => {
 const timeStr = stats.toString({
   all: false,
   timings: true,
});

 const statStr = stats.toString({
   all: false,
   warnings: true,
   errors: true,
});

 console.log(timeStr);

 if (stats.hasErrors()) {
   console.log(statStr);
   return;
}
});
// build 命令下执行构建

// 生成 styleguide 实例
const styleguide = MonorepoDev.createDocStyleguide('production', {
 cwd,
 rootDir,
 workDir,
 customConfig: {
   styleguideDir: path.join(pkgDocsDir, 'dist'),
},
});
// 构建文档内容
await new Promise<void>((resolve, reject) => {
 styleguide.build(
  (err, config, stats) => {
     if (err) {
       reject(err);
    } else {
       if (stats != null) {
         const statStr = stats.toString({
           all: false,
           warnings: true,
           errors: true,
        });
         console.log(statStr);
         if (stats.hasErrors()) {
           reject(new Error('Docs build failed!'));
           return;
        }
         console.log('\n');
         Utils.log('success', `Docs published to ${path.relative(workDir, config.styleguideDir)}`);
      }
       resolve();
    }
  },
);

最后,在组件多包仓库的每个包下的 package.json 中,分别配置 devbuild 命令即可。实现了支持无感启动调试和构建产出文档资源。

小结

本文主要介绍了我在调研实现组件文档规范和搭建过程中的一个思考过程,诚如文中介绍其他文档系统搭建工具时所说,有很多优秀的开源工具能够支持实现我们想要的效果,这是前端攻城狮们的幸运,也是不幸:我们可以站在前人的肩膀上,但要在这么多优秀库中选择一个适合自己的,更需要多做一些了解和收益点的权衡。一句老话经久不衰:适合自己的才是最好的。

希望这篇文章对看到这里的你能有所帮助。

作者:ES2049 / 靳志凯
链接:https://segmentfault.com/a/1190000041097170

收起阅读 »

驳“低代码开发取代程序员”论 为什么专业开发者也需要低代码?

低代码又火了。近几年,腾讯、阿里、百度等互联网大厂纷纷入局,国内外低代码平台融资动辄数千万甚至数亿,以及伴随着热度而来的巨大争议……无不说明“低代码”的火爆。事实上,低代码并非新概念,它可以追溯到上世纪80年代的“第四代编程语言”。2014年,Forreste...
继续阅读 »

低代码又火了。

近几年,腾讯、阿里、百度等互联网大厂纷纷入局,国内外低代码平台融资动辄数千万甚至数亿,以及伴随着热度而来的巨大争议……无不说明“低代码”的火爆。

事实上,低代码并非新概念,它可以追溯到上世纪80年代的“第四代编程语言”。2014年,Forrester正式提出低代码的概念。低代码是一种软件开发技术,衍生于软件开发的高级语言,让使用者通过可视化的方式,以更少的编码,更快速地构建和交付应用软件,全方位降低软件的开发成本。与传统软件开发方式相比,低代码开发平台整合了软件开发和部署所需的 IDE(集成开发环境)、服务器和数据库管理工具,覆盖软件开发的全生命周期,我们可以将其理解为 Visual Studio + IIS + SQL Management Studio(.NET 技 术)或 Eclipse + Tomcat + MySQL Workbench(Java 技术)的组合。

编码更少、交付更快、成本更低,还覆盖软件开发全生命周期,怎么看低代码都可以说是不错的软件开发工具。那么,它又为什么引发争议,甚至被其主要用户群体之一——程序员所诟病呢?“低代码开发会取代程序员” 这一观点大行其是,它说得对吗?

为什么低代码引起专业开发者的反感?

技术浪潮引发巨大变革,也带来了无数“取代论”,比如机器翻译是否取代人类翻译、机器人记者是否取代人类记者,以及低代码开发是否取代程序员。

低代码虽然火爆,但程序员对此抱有不同的心态:

  • 轻视:低代码技术的诸多优势只是炒作,该技术更适合初学者,解决不了复杂的技术问题;

  • 恐惧:担心被低代码取代;

  • 抵触:低代码开发平台能够覆盖所有需求吗;大量封装组件使得低代码开发平台更像一个黑盒子,可能导致难以debug、难以修改和迭代升级等技术问题;低代码开发平台配置有大量组件,简单的拖拉拽动作即可完成大量开发工作,程序员不再需要厉害的技术能力。

那么,上述理由真的站得住脚吗?我们一一来看。

低代码的门槛真的低吗?

低代码开发过程常被比作拼积木:像拼搭积木一样,以可视化的方式,通过拖拉拽组件快速开发出数据填报、流程审批等应用程序,满足企业里比较简单的办公需求。

但这并不意味着低代码开发平台只能做到这些。

Gartner在2020年9月发布的《企业级低代码开发平台的关键能力报告》(Critical Capabilities for Enterprise Low-Code Application Platforms)中,列举了低代码的11项关键能力。

图源:http://www.gartner.com/en/document…

这里我们着重来看其中三项关键能力。

  • 数据建模和管理:该指标就是通常所讲的 “模型驱动” 。相比于表单驱动,模型驱动能够提供满足数据库设计范式的数据模型设计和管理能力。开发的应用复杂度越高,系统集成的要求越高,这个能力就越关键。

  • 流程和业务逻辑:流程应用与业务逻辑开发能力和效率。这个能力有两层,第一层是指使用该低代码开发平台能否开发出复杂的工作流和业务处理逻辑;第二层是开发这些功能时的便利性和易用性程度有多高。

  • 接口和集成:编程接口与系统集成能力。为了避免“数据孤岛”现象,企业级应用通常需要与其他系统进行集成,协同增效。此时,内置的集成能力和编程接口就变得至关重要。除非确认可预期的未来中,项目不涉及系统集成和扩展开发,开发者都应该关注这个能力。

这些关键能力表明低代码平台在建模与逻辑方面具备较强的能力,而接口和集成能力可使专业开发人员完成低代码无法实现的部分,通过低代码与专业代码开发的协作实现复杂应用的开发。 在涉及高价值或复杂的核心业务时,专业开发人员需要理解业务需求,厘清业务逻辑。从这个层面上看,低代码开发的门槛并不低。事实也是如此:海比研究在《2021 年中国低代码/无代码市场研究报告》中提到,截至 2020 年底,技术人员在低代码使用者中的比例超 75%,占主体地位。

低代码什么都能做吗?

程序员的工作围绕开发需求展开。在选择开发工具时,程序员通常考虑的首要问题是:这款工具能否覆盖所有需求?如果需求增加或变更,该工具是否支持相关操作?这些问题同样适用于低代码平台的选型。

在实际项目交付过程中,如果我们仅可以满足99%的需求,另外1%的需求满足不了,那么真实用户大概率是不会买单的。因此,在评估低代码产品的时候,我们一定要保证该平台可以支撑所有系统模块类型的开发,同时也要具备足够的扩展性,确保使用纯代码开发出的模块能够与低代码模块进行无缝集成,而这离不开编程接口。

以国内主流低代码开发平台活字格为例。该平台提供开箱即用的开发组件,同时为系统的各个分层均提供编程扩展能力,以满足企业级应用开发对扩展性的高要求。借助分层编程接口,开发者可以用纯代码的方式实现新增功能,无需受限于低代码开发平台的版本和现有功能。


活字格的编程扩展能力

当然,就具体应用领域而言,低代码开发平台也有其擅长和不擅长的地方。目前,低代码开发更多地被应用于2B企业应用开发,而对于用户量特大的头部互联网应用、对算法和复杂数据结构要求较高的应用,低代码平台则不太适合。

低代码开发不可控?

“低代码开发平台是个黑盒子,内部出问题无法排查和解决。开发过程中发现有问题怎么办?迭代升级难以实现怎么办?”很多程序员会有这种疑惑。

但我们需要注意的是,低代码开发平台本质上仍是软件开发工具,用户模型与软件开发周期支持是其关键能力之一。也就是说,成熟的低代码开发平台具备软件开发全生命周期所需的各项功能,从而大大简化开发者的技术栈,进一步提高开发效率。

具体而言,在面对频繁的需求变更、棘手的问题排查时,低代码开发平台引入了版本管理机制,从而更高效地进行代码审查、版本管理与协调,以及软件的迭代升级。至于debug,日志分析无疑是个好办法。例如,活字格把执行过程及细节以日志方式输出,方便程序员高效debug。

对程序员而言,低代码平台是限制还是助力?

“低代码”意味着更少的代码。代码都不怎么写了,程序员又该怎么成长,怎么获得职业成就感呢?

其实不然。

首先,开发 ≠ 写代码。低代码平台可以减少大量重复工作,提升开发效率,把专业开发人员从简单、重复的开发需求中解放出来,把精力投入到更有价值的事情上,比如精进技术、理清业务逻辑。

其次,低代码平台的组件化和拖拽式配置降低了开发门槛,新手程序员能够借助此类平台快速入门,加速升级打怪;有经验的程序员也有机会参与更多项目,甚至带团队,积累更多经验值,实现快速成长。

宁波聚轩就是一个例子。这家公司自2009年起就专注于智能制造、工业4.0、系统方案集成等领域的探索研究。在接触了低代码之后,项目负责人发现开发效率得到极大提升,采用传统方式需要一个月开发量的项目,现在需要半个月甚至更短的时间就可以完成。此外,其实践经验表明,低代码开发的学习成本较低,毕业新生经过一周学习,两周就可做项目,一个月就能熟练开发。

该公司在2021企业级低代码应用大赛中获得了应用创新奖,获奖作品是一套轴承行业数字化智造系统。这套系统主要集成了ERP、MES、WMS和设备机联网系统,覆盖了销售、采购、仓库、计划、生产、财务等全流程功能,且已经在生产现场投入使用。在开发过程中,宁波聚轩的开发团队利用低代码平台成功解决了定制化要求高、多终端需求等难题,及时完成项目交付。

结语

当迷雾散尽,低代码开发平台重新露出高效率开发工具的本色时,你会选择它吗?


作者:SegmentFault思否
来源:https://juejin.cn/post/7023579572096466974

收起阅读 »

2021年爆火的“元宇宙”,是真风口还是资本骗局下的割韭菜?

Metaverse——元宇宙。一个出自1992年科幻小说「雪崩」(Snow Crash)的概念,在2021年突然火爆了起来。各路资本也纷纷下场,似乎这个「元宇宙」马上就要实现了一样。甚至有人称2021年是「元宇宙」元年。 就在今年,字节跳动、Facebook...
继续阅读 »

Metaverse——元宇宙。一个出自1992年科幻小说「雪崩」(Snow Crash)的概念,在2021年突然火爆了起来。各路资本也纷纷下场,似乎这个「元宇宙」马上就要实现了一样。甚至有人称2021年是「元宇宙」元年。


2021年爆火的“元宇宙”,是真风口还是资本骗局下的割韭菜?


就在今年,字节跳动、Facebook、腾讯、英伟达等从国外到国内的科技巨头纷纷宣布自己的元宇宙未来发展战略,把元宇宙视为公司未来存亡的关键。


Facebook创始人扎克伯格,更是雄心勃勃的表示,未来5年Facebook要变成「元宇宙公司」,可以说,在资本的涌入之下,元宇宙已经成为互联网公司的第一赛道。


一、什么是元宇宙?


各位有没有看过前两年大火的电影《头号玩家》,影片讲述2045年,由于现实生活无趣,无数年轻人迷失在一款超级火爆的游戏《绿洲》,当你穿上一身装备,站在一个可以自由奔跑的平台上时,一个崭新的世界便呈现了出来,这个新世界,就是元宇宙。


2021年爆火的“元宇宙”,是真风口还是资本骗局下的割韭菜?


在这个绿洲世界,你既可以升级打怪,也可以修仙摸鱼;既可以感受江湖恩怨、武侠情仇;又可以躲在悠远的溪谷,享受采菊东篱下的恬静。在这个世界,你可以为所欲为,体验你幻想中的一切。


在极具娱乐性的同时,这个世界并非完全脱离现实世界,例如游戏世界的金钱可以和现实世界互通共用,例如服务可以同向切换,例如你在虚拟世界也可以叫滴滴代驾,这个虚拟的世界与现实世界在很多方面重叠互通,你可以随时通过VR设备、电脑、手机登陆这个线上世界,又可以通过线上世界的行为影响到真实生活,在它脱胎于现实世界,又与现实世界平行。


这就是一个完美的元宇宙构想,是互联网高度发展后的一种延伸。


2021年爆火的“元宇宙”,是真风口还是资本骗局下的割韭菜?


很多朋友要问,我们已经有互联网了,为什么还要探索元宇宙呢?难道仅仅是为了一款游戏吗?


那肯定不是,元宇宙的思想,其实是为了提升世界的互联互通,在现实世界中,互联网技术和协议本身是开放的,但商业世界却大多是封闭的,《头号玩家》里人们在各个虚拟世界穿梭,但有着统一的账号身份、好友关系、实时联通的数据流动、资产流转。


这在今天的互联网世界里就是不存在的,比如,你在央行的征信状况和支付宝信用分就不是互通的,你的比特币和微信钱宝更不互通,人民币和美元汇率也不是实时对等,这种世界处处存在着摩擦,公司与公司之间,国际之间的信息都具有极高的壁垒。


元宇宙正是要打破这种壁垒,把所有公司和人都链接起来,构建起新的商业、金融、贸易、交流规则,实现更广阔互联网。


乍一听,这样的想法简直是天方夜谈,根本不可能实现,但是这几年区块链和VR技术的发展,让这种构想逐渐成为现实。


二、已经处于技术爆发的前夜


凌乐作为一个保守派的投资者,当看到这个概念的时候我也是一脸懵逼?这不就是一个VR游戏吗?有什么值得吹的?VR概念都炒作这么久了,无论是产品力还是实用程度,都离现实差了很远,凭什么现在来炒作?怎么看,都是一种虚假的概念。


但是仔细去研究之后,我发现,技术框架已经逐渐成型,这个飘渺的未来,或许正在实现。


区块链让框架规则出现了一定雏形:


在头号玩家中,最大的BOSS是游戏公司,也就是那个世界的神,可以制定规则,改变规则。但是在现实中,我们不需要这么一个神,这么庞大的工程,需要很多企业和用户共同搭建,需要让主流科技公司彼此合作,需要说服全球的监管机构,让他们相信互联网公司们打造的世界对社会是有利的,更需要让用户相信,这个世界没有潜规则,在部分规则方面绝对平等。


如果是在以前,根本不存在这种技术,但是区块链的产生,这种开始产生使用场景,NFT(Non-fungible Token)技术可以让区块链标识成为新的价值承载物,使虚拟物品资产化,从而实现数据内容的价值流转,规避黑箱操作,比特币的成功,又让世界意识到去中心化真的可以实现。


可以说,区块链技术,让这个绝对公平的商业规则出现了基本雏形。


VR设备快速升级,已处于爆发前夜:


2021年爆火的“元宇宙”,是真风口还是资本骗局下的割韭菜?


不管新世界的内容有多么丰富,都需要一道门来链接人和新世界,目前来看,VR设备就是开启这道新宇宙的门。


VR概念早在2016年就开始炒作了,不过因为技术水平低,实际体验感并不好,Facebook也是意识到VR设备的重要性,它们在 2016 年以 20 亿美元买下VR设备企业Oculus,并且每年在VR业务上的研发投入上百亿美元研发VR,在多家大厂入局的情况,现在VR设备技术突飞猛进,我专门去淘宝看了销量,Facebook下的这款VR眼镜居然都有四千多条评价,京东上面的销量更高。


扎克伯格认为,当产品销量突破1000万的时候,就标志着VR市场开始爆发,根据市场统计,Facebook旗下的VR设备Quest 2今年年底的销量就会突破1000万台,工信部预计2021年VR年复合增长率达91.2%,这个市场已经处于爆发前夜。


作者:IT技术管理的那些事儿
链接:https://juejin.cn/post/7008408876961759262
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

一篇完整的Swift属性参考,轻松让你提高一个档次!

iOS
属性 提供了更多关于声明和类型的信息。在 Swift 中有两种类型的属性,一种用于声明,一种用于类型。例如,required 属性-当用于类的指定或者便利初始化声明时-指明每个子类都必须实现它的初始化函数。noreturn 属性-当用于函数或者方法的类型时-指...
继续阅读 »

属性 提供了更多关于声明和类型的信息。在 Swift 中有两种类型的属性,一种用于声明,一种用于类型。例如,

required 属性-当用于类的指定或者便利初始化声明时-指明每个子类都必须实现它的初始化函数。noreturn 属性

-当用于函数或者方法的类型时-指明函数或者方法无需返回值。

咋们一起好好看,好好学

你可以用字符 @ 加上属性名和属性参数来指定一个属性:

@属性名
@属性名(属性参数)

含有参数的声明属性可以为属性指定更多的信息,可以用于特殊的声明。这些属性参数 被包含在圆括号里,参数的格式由属性决定。

声明属性

声明属性只能用于声明,当然,你也可以使用 noreturn 属性作为函数或者方法的类型。

assignment

此属性可用于修饰重载复合赋值运算符的函数。这个重载复合赋值运算符的函数必须用 inout 来标记初始输入参数。assignment属性示例参见复合赋值运算符

class_protocol

此属性可用于定义类类型协议。

如果你使用 objc 属性的协议, 那么这个协议就隐式含有 class_protocol 属性,你无需显式标记 class_protocol 属性。

exported

此属性可用于内部声明,可以将当前模块的内部模块、子模块或者声明暴露给外部其他模块。如果另一个模块引用了当前模块,那么这个模块就可以访问当前模块中暴露出来的部分。

final

此属性可用于修饰类或者类的属性、方法或成员下标运算符。用于一个类的时候,表明这个类是不能被继承的。用于类的属性、方法或成员下标运算符的时候,表明这个类的这些成员函数不能在任何子类中重写。

lazy

此属性可用于修饰类或者结构体中的存储变量属性,表明这个属性在第一次被访问时,其初始值最多只能被计算和存储一次。lazy 属性示例参见惰性存储属性

noreturn

此属性用于函数或者方法的声明,表明其函数或者方法相应的类型T是@noreturn T。当一个函数或者方法无需返回其调用者时,你可以用这个属性来修饰其类型。

你可以重写没有标示noreturn属性的函数或者方法。也就是说,你不能够重写有noreturn属性的函数或者方法。当你实现此类型的协议方法时,也有相似的规则。

NSCopying

此属性可用于修饰类中的存储变量属性。被修饰的这个属性的赋值函数是由这个属性值的拷贝组成-由copyWithZone方法返回-而不是这个属性本身的值。此属性类型必须符合NSCopying协议。
NSCopying属性类似于Objective-C中的copy属性。

NSManaged

用于修饰类中的存储变量属性,此类继承于NSManagedObject,表明这个属性的存储和实现是由Core Data基于相关的实体描述实时动态提供的。

objc

此属性可用于能用Objective-C表示的任何声明中-例如,非嵌套的类、协议、类和协议的属性和方法(包括取值函数和赋值函数)、初始化函数、析构函数以及下标运算符。objc属性告诉编译器此声明在Objective-C代码中可用。

如果你使用objc属性修饰类或者协议,它会显式的应用于这个类或者协议中的所有成员。当一个类继承于标注objc属性的另一类时,编译器会显式的为这个类添加objc属性。标注objc属性的协议不能够继承于不含有objc属性的协议。

objc属性可以接受由标识符组成的单个属性参数。当你希望暴露给Objective-C的部分是一个不同的名字时,你可以使用objc属性。你可以使用这个参数去命名类、协议、方法、取值函数、赋值函数以及初始化函数。下面的示例就是ExampleClass的enabled属性的取值函数,它暴露给Objective-C代码的是isEnabled,而不是这个属性的原名。

1.  @objc
2. class ExampleClass {
3. var enabled: Bool {
4. @objc(isEnabled) get {
5. // Return the appropriate value
6. }
7. }
8. }

optional

此属性可用于协议的属性、方法或者成员下标运算符,用来表明实现那些成员函数时,此类型的不是必需实现的。

optional属性只能用于标注objc属性的协议。因此,包含可选成员的协议只有类类型适用。更多的关于怎样使用optional属性,以及怎样访问可选协议成员的指导-例如,当你不确定它们是否实现了此类型时-参见可选协议需求

required

此属性用于类的指定或者便利初始化函数时,表明这个类的每个子类都必须实现这个初始化函数。

需求的指定初始化函数必须被显式的包含。当子类直接实现所有超类的指定初始化函数时(或者子类使用便利初始化函数重写了指定初始化函数时),需求的便利初始化函数必须被显式的包含或者继承。

使用Interface Builder声明属性

Interface Builder属性就是使用Interface Builder声明属性以与Xcode同步。Swift提供了如下几种Interface Builder属性:IBAction,IBdesignable,IBInspectable以及IBOutlet。这些属性理论上与Objective-C中相应的属性一样。

IBOutlet和IBInspectable属性可用于类的属性声明,IBAction属性可用于类的方法声明,IBDesignable属性可用于类的声明。

类型属性

类型属性可以只用于类型。当然noreturn属性也可以用于函数或者方法的声明。

auto_closure

此属性用于延迟表达式的赋值计算,将表达式自动封装成一个无参数的闭包。此属性还可作为函数或者方法的类型,此类型无参数并且其返回的是表达式类型。auto_closure属性示例参见函数类型

noreturn

此属性用于函数或者方法时表明此函数或者方法无返回值。你也可以用此属性标记函数或者方法的声明,以表明其函数或者方法相应的类型T是@noreturn T。

属性语法
attribute → @­attribute-name attribute-argument-clause opt
attribute-name → identifier
attribute-argument-clause → (balanced-tokens­ opt)
attributes → attribute­ attributes­ opt­
balanced-tokens → balanced-token ­balanced-tokens­ opt­
balanced-token → (­balanced-tokens­ opt­)­
balanced-token → [balanced-tokens­ opt­]­
balanced-token → {balanced-tokens­ opt­­}­
balanced-token → 任意标识符,关键字,常量,或运算符
balanced-token → 任意的标点符号 (­, )­, [­, ]­, {­, 或 }­

由于文章篇幅有限,只能点到即止地介绍当前一些工作成果和思考,各个 Swift 还有一些新的方向在探索,如果你对 iOS 底层原理、架构设计、构建系统、如何面试有兴趣了解,你也可以关注我及时获取最新资料以及面试相关资料。如果你有什么意见和建议欢迎给我留言!

写的不好的地方欢迎大家指出,希望大家多留言讨论,让我们共同进步!

喜欢iOS的小伙伴可以关注我,一起学习交流!!!

链接:juejin.cn/post/698169…


作者:在做开发的信哥
链接:https://juejin.cn/post/6988459235797368862

收起阅读 »

啥?iOS长列表还可以这么写

iOS
一般说,iOS界面的一些长列表,比如首页,活动页,长的会比较长,那么写起来总感觉没有那么优雅,那么如何才能做到优雅呢? 我在实践工作利用swift枚举的关联值和自定义组模型方法来实现了 下面是gif图效果 可以看到,有些组是杂乱无章的排列着,而且运营那边...
继续阅读 »

一般说,iOS界面的一些长列表,比如首页,活动页,长的会比较长,那么写起来总感觉没有那么优雅,那么如何才能做到优雅呢?
我在实践工作利用swift枚举的关联值和自定义组模型方法来实现了



  • 下面是gif图效果



可以看到,有些组是杂乱无章的排列着,而且运营那边要求,他们可以在后台自定义这些组的顺序
这可怎么办!🥺
下面看我的实现方式


定义一个组模型枚举



  • 包含可能的定义,每个枚举关联当前组需要显示的数据模型,有可能是一个对象数组,也有可能是一个对象


/// 新版首页组cell的类型
enum OriginGroupCellType {
case marquee(list: [MarqueeModel]) // 跑马灯
case beltAndRoad(list: [GlobalAdModel]) // 一带一路广告位
case shoppingCarnival(list: [GlobalAdModel]) // 购物狂欢节
case walletCard(smallWelfare: WelfareSmallResutlModel) // 钱包卡片
case wallet(list: [HomeNavigationModel]) // 钱包cell
case otc(list: [GlobalAdModel]) // OTC
case hxPrefecture(list: [GlobalAdModel]) // HX商品专区
case middleNav(list: [HomeNavigationModel]) // 中部导航
case bottomNav(list: [HomeNavigationModel]) // 底部导航
case broadcast(topSale: HomeNavigationModel, hot: OriginBroadcastModel, choiceness: OriginBroadcastModel) // 直播cell
case middleAd(list: [GlobalAdModel]) // 中间广告cell
case localService(list: [LocalServiceModel]) // 本地服务cell
case bottomFloat(headerList: [OriginBottomFloatHeaderModel]) // 底部悬停cell
}


  • 考虑到要下拉刷新等问题,可以这些枚举都得遵守Equatable协议


  extension OriginGroupCellType: Equatable {
public static func == (lhs: OriginGroupCellType, rhs: OriginGroupCellType) -> Bool {
switch (lhs, rhs) {
case (.marquee, .marquee): return true
case (.beltAndRoad, .beltAndRoad): return true
case (.shoppingCarnival, .shoppingCarnival): return true
case (.walletCard, .walletCard): return true
case (.wallet, .wallet): return true
case (.otc, .otc): return true
case (.hxPrefecture, .hxPrefecture): return true
case (.middleNav, .middleNav): return true
case (.bottomNav, .bottomNav): return true
case (.broadcast, .broadcast): return true
case (.middleAd, .middleAd): return true
case (.localService, .localService): return true
case (.bottomFloat, .bottomFloat): return true
default:
return false
}
}
}

接下来就是组模型的定义



  • 同时我抽取一个协议GroupProvider,方便复用


protocol GroupProvider {
/// 占位
associatedtype GroupModel where GroupModel: Equatable

/// 是否需要往组模型列表中添加当前组模型
func isNeedAppend(with current: GroupModel, listMs: [GroupModel]) -> Bool
/// 获取当前组模型在组模型列表的下标
func index(with current: GroupModel, listMs: [GroupModel]) -> Int
}

extension GroupProvider {
func isNeedAppend(with current: GroupModel, listMs: [GroupModel]) -> Bool {
return !listMs.contains(current)
}

func index(with current: GroupModel, listMs: [GroupModel]) -> Int {
return listMs.firstIndex(of: current) ?? 0
}
}




  • OriginGroupModel,同样也遵守Equatable协议,防止重复添加


func addTo(listMs: inout [OriginGroupModel]) 



  • 这个方法是方便于下拉刷新时,替换最新数据所用


public struct OriginGroupModel: GroupProvider {
typealias GroupModel = OriginGroupModel

/// 组模型的类型
var cellType: OriginGroupCellType
/// 排序
var sortIndex: Int

/// 把groupModel添加或替换到listMs中
func addTo(listMs: inout [OriginGroupModel]) {
if isNeedAppend(with: self, listMs: listMs) {
listMs.append(self)
} else {
let index = self.index(with: self, listMs: listMs)
listMs[index] = self
}
}
}

extension OriginGroupModel: Equatable {
public static func == (lhs: OriginGroupModel, rhs: OriginGroupModel) -> Bool {
return lhs.cellType == rhs.cellType
}
}


  • 考虑要自定义顺序,所以需要定义一个排序的实体


// MARK: - 新版首页组模型的排序规则模型
struct OriginGroupSortModel {
/// 搜索历史的排序
var marqueeIndex: Int
var beltAndRoadIndex: Int
var shoppingCarnivalIndex: Int
var walletCardIndex: Int
var walletIndex: Int
var otcIndex: Int
var hxPrefectureIndex: Int
var middleNavIndex: Int
var bottomNavIndex: Int
var broadcastIndex: Int
var middleAdIndex: Int
var localServiceIndex: Int
var bottomFloatIndex: Int

static var defaultSort: OriginGroupSortModel {
return OriginGroupSortModel(
marqueeIndex: 0,
beltAndRoadIndex: 1,
shoppingCarnivalIndex: 2,
walletCardIndex: 3,
walletIndex: 4,
otcIndex: 5,
hxPrefectureIndex: 6,
middleNavIndex: 7,
bottomNavIndex: 8,
broadcastIndex: 9,
middleAdIndex: 10,
localServiceIndex: 11,
bottomFloatIndex: 99)
}
}


控制器里定义一个 组模型数组



  • 这里有关键代码是


listMs.sort(by: { return $0.sortIndex < $1.sortIndex }) 



  • 所有的数据加载完毕后,会根据我们的自定义排序规则去排序


    /// 组模型数据
public var listMs: [OriginGroupModel] = [] {
didSet {
listMs.sort(by: {
return $0.sortIndex < $1.sortIndex
})
collectionView.reloadData()
}
}

/// 组模型排序规则(可以由后台配置返回,在这里我们先给一个默认值)
/// 需要做一个请求依赖,先请求排序接口,再请求各组的数据
public lazy var sortModel: OriginGroupSortModel = OriginGroupSortModel.defaultSort


网络请求代码


func loadData(_ update: Bool = false, _ isUHead: Bool = false) {
// 定义队列组
let queue = DispatchQueue.init(label: "getOriginData")
let group = DispatchGroup()

// MARK: - 文字跑马灯
group.enter()
queue.async(group: group, execute: {
HomeNetworkService.shared.getMarqueeList { [weak self] (state, message, data) in
guard let `self` = self else { return }
self.collectionView.uHead.endRefreshing()

defer { group.leave() }
let groupModel = OriginGroupModel(cellType: .marquee(list: data), sortIndex: self.sortModel.marqueeIndex)
guard !data.isEmpty else { return }

/// 把groupModel添加到listMs中
groupModel.addTo(listMs: &self.listMs)
}
})

/// .... 此处省略其它多个请求

group.notify(queue: queue) {
// 队列中线程全部结束,刷新UI
DispatchQueue.main.sync { [weak self] in
self?.collectionView.reloadData()
}
}
}


collectionView的代理方法处理


    func numberOfSections(in collectionView: UICollectionView) -> Int {
return listMs.count
}

func collectionView(_: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let groupModel = listMs[section]
switch groupModel.cellType {
case .marquee, .beltAndRoad, .walletCard, .wallet, .otc, .hxPrefecture, .shoppingCarnival, .middleAd:
return 1
case .middleNav(let list):
return list.count
case .bottomNav(let list):
return list.count
case .broadcast:
return 1
case .localService(let list):
return list.count
case .bottomFloat:
return 1
}
}



  • 同理,collectionView的代理方法中,都是先拿到 cellType 来判断,达到精准定位, 举个栗子


    /// Cell大小
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let groupModel = listMs[indexPath.section]
let width = screenWidth - 2 * margin
switch groupModel.cellType {
case .marquee:
return CGSize(width: screenWidth, height: 32)
case .beltAndRoad:
return CGSize(width: width, height: 46)
case .walletCard:
return CGSize(width: width, height: 85)
case .wallet:
return CGSize(width: width, height: OriginWalletCell.eachHeight * 2 + 10)
case .otc, .hxPrefecture:
return CGSize(width: width, height: 60)
case .middleNav:
let row: CGFloat = 5
let totalWidth: CGFloat = 13 * (row - 1) + 2 * margin
return CGSize(width: (screenWidth - totalWidth) / row, height: CGFloat(98.zh(80).vi(108)))
case .bottomNav:
let isFirstRow: Bool = indexPath.item < 2
let row: CGFloat = isFirstRow ? 2 : 3
let totalWidth: CGFloat = 4 * (row - 1) + 2 * margin
let width = (screenWidth - totalWidth) / row
return CGSize(width: floor(Double(width)), height: 70)
case .shoppingCarnival:
return CGSize(width: width, height: 150)
case .broadcast:
return CGSize(width: screenWidth - 20, height: 114)
case .middleAd:
return CGSize(width: width, height: 114)
case .localService:
let width = (82 * screenWidth) / 375
return CGSize(width: width, height: 110)
case .bottomFloat:
let h = bottomCellHeight > OriginBottomH ? bottomCellHeight : OriginBottomH
return CGSize(width: screenWidth, height: h)
}
}


总结一下这种写法的优势




  • 方便修改组和组之前的顺序问题,甚至可以由服务器下发顺序




  • 方便删减组,只要把数据的添加组注释掉




  • 用枚举的方式,定义每个组,更清晰,加上swift的关联值优势,可以不用在控制器里定义多个数组




  • 考虑到要下拉刷新,所以抽取了一个协议 GroupProvider,里面提供两个默认的实现方法



    • 方法一:获取当前cellType在listMs中的下标

    • 方法二:是否要添加到listMs中




  • 界面长什么样,全部由数据来驱动,这组没有数据,界面就对应的不显示(皮之不存,毛将焉附),有数据就按预先设计好的显示




源码地址(源码内容和gif图中有差异,但是思路是一致的)


github.com/XYXiaoYuan/…


作者:Bruceyuan
链接:https://juejin.cn/post/6939767696846225421

收起阅读 »