注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

iOS Runtime (三)Runtime的消息机制

iOS
消息发送 消息机制就是向接收者发送消息,并带有参数,根据接收者对象的数据结构,找到相关发放实现,最后达到这个消息的目的。 objc_msgSend是Runtime的核心,Objective-C中调用对象方法就是消息传递。 objc_msgSend并不是直接调用...
继续阅读 »

消息发送


消息机制就是向接收者发送消息,并带有参数,根据接收者对象的数据结构,找到相关发放实现,最后达到这个消息的目的。


objc_msgSendRuntime的核心,Objective-C中调用对象方法就是消息传递。

objc_msgSend并不是直接调用方法实现(IMP)而是发送消息,让类的结构体去动态查到方法实现,所以在为查找到方法实现之前我们可以动态的去修改这个方法的实现


在Object-C中,我们其实可以直接调用C的代码也就是Runtime的C语言代码,需要添加message.h头文件。

#import 
#import

编写Runtime的时候会遇到没有提示的尴尬,那是因为在Xcode5.0以后的版本,Apple不建议我们写比较底层的代码,So,在target->info搜索msgYES改成NO,然后可以尽情的使用Runtime代码


Objc-msgSend所做的事情



1,找到方法的实现,由于通过单独的类以不同方式创建相同的方法,因此这个方法的实现的确定取决于接收消息的类对象,也即是说多个实例类对戏那个可以创建同样的方法,每个实例对象中的该方法都是独立存在的。

2,调用该方法实现,将接收消息类指针,以及该方法的参数传递给这个类。

3,最后将过程的返回值作为自己的返回值传递



消息传递的发送过程和关键要素



1,指向superclass的指针。消息发送给对象时,消息传递函数遵循对象的isa指针指向类结构的指针,在该结构中它查询结构体变量methodLists中的方法SEL(方法选择器).

2,会有一个SEL方法实现的地址(这个地址是基于独立的类)关联的表

    当创建一个新的对象时,分配内存,初始化变量,对象变量中的第一个是指向该类结构的指针,这个名字为isa的指针能让对象可以访问它的类,并通过该类访问它继承的所有类

    isa指针是对象使用Objective-C运行时系统所必需的,在结构中定义的任何字段中,对象需要与结构体objc_object(objc/objc.h中的定义)"等效",日常开发中很少有创见自己的根对象的这种情况,一般从NSObject或者NSProxy继承的对象会自动拥有isa变量

    如在isa指向的类结构中找不到SEL(方法选择器),Objc_msgSend会跟随指向Supercalss(父类)指针并再次尝试查找该SEL。如连续失败直到NSObject类,它的superclass也就是它自己本身。一旦找到SEL,该函数就会调用methodLists的方法并将接收对象的指针传给它。



加速消息发送




  • 1,有的时候在一个类会有继承关系,Objective-C中大部分对象都是继承于NSObject、自己自定义类,在这种继承体系当中有很多的方法,这些方法有可能不会用到,在向类发送消息的时候,去methodLists中查找无疑会拖慢程序的运行速度,所以Apple在开发的时候加入了缓存cache的概念,也就是缓存。

  • 2,在每个类中都会有一个单独的缓存cache,它可以包含继承过来的方法SEL以及自定义的SEL,在搜索methodLists之前,消息传递程序会检查接受者对象的告诉缓存cache,如果找到,就不会在去搜索庞大的methodLists列表,一旦在缓存当中存在你需要的SEL,这样以后也就比函数调用稍微慢一点。

  • 3,理论上cache缓存的是一些会再次调用的SEL,当写的程序预热足够时间,那么所有发送过的SEL都会在cache中找到

  • 4,cache会动态增长,容纳新的消息,知道程序中所有调用的SEL运行一遍为止

  • 5,原理时:好比是通常小圈子找人总比大圈子找人要快



Runtime的发送消息隐藏的参数


每次当我们向一个对象发送消息时,也就是Objective-C调用方法的时候,传递的所有参数,还包括两个隐藏的参数:



接收者对象

调用的方法SEL _cmd



这两个参数没有在定义中声明,而是在编译代码时插入方法实现的。

/*
* _cmd 就是你调用的方法的SEL
**/
NSLog(@"%@",NSStringFromSelector(_cmd));

规避动态绑定的方法,获取方法地址


代码正常编译的时候,需要使用消息传递Objc-msgSend才能找到方法的IMP中间就有了这个消息传递的过程。

有时候我们不希望调用消息传递的,或者节省消息传递的开销,就需要我们拿到方法的IMP,代码直接使用IMP中的方法。

下面的示例显示了如何调用实现setFilled:方法的过程:

@interface ViewController (){
NSInteger num;
}
@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
void (*setter)(id, SEL, BOOL);
int i;

setter = (void (*)(id, SEL, BOOL))[self methodForSelector:@selector(setFilled:)];
for ( i = 0 ; i < 1000 ; i++ )
setter(self, @selector(setFilled:), YES);
}

- (void)setFilled:(NSInteger)number{
NSLog(@"%ld",++num);
}

传递给方法实现的前两个参数是: 接收对象(self)方法选择器对象(SEL),这些参数隐藏在方法的语法中,方法作为函数调用时必须使它显式化。


使用methodForSelector绕过动态绑定可以节省消息传递的大部分时间,在特定的消息多次重复的情况下才会节省的更加显著


methodForSelector是由Cocoa运行时系统提供,它并不是Objective-C语言本身的一个特性


3人点赞


链接:https://www.jianshu.com/p/04760fc66276
收起阅读 »

iOS Runtime (二) Runtime底层详解

iOS
Runtime的定义? 为了更好的认识类是怎么工作的,我们将要将一段Object-C的代码用clang看下底层的C/C++的写法。 在Object-C中的NSObject对象中@interface NSObject <NSObject> { ...
继续阅读 »

Runtime的定义?


为了更好的认识类是怎么工作的,我们将要将一段Object-C的代码用clang看下底层的C/C++的写法


在Object-C中的NSObject对象中

@interface NSObject <NSObject> {
Class isa OBJC_ISA_AVAILABILITY;
}

Objective-C类是由Class类型来表示的,它实际上是一个指向objc_class结构体的指针

/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;
/// Represents an instance of a class.
struct objc_object {
Class _Nonnull isa OBJC_ISA_AVAILABILITY;
};

/// A pointer to an instance of a class.
typedef struct objc_object *id;

由此可见可以看到id是指向objc_object的一个指针

objc_class结构体中的定义如下:

struct objc_class {
Class _Nonnull isa OBJC_ISA_AVAILABILITY;

#if !__OBJC2__
Class _Nullable super_class OBJC2_UNAVAILABLE;
const char * _Nonnull name OBJC2_UNAVAILABLE;
long version OBJC2_UNAVAILABLE;
long info OBJC2_UNAVAILABLE;
long instance_size OBJC2_UNAVAILABLE;
struct objc_ivar_list * _Nullable ivars OBJC2_UNAVAILABLE;
struct objc_method_list * _Nullable * _Nullable methodLists OBJC2_UNAVAILABLE;
struct objc_cache * _Nonnull cache OBJC2_UNAVAILABLE;
struct objc_protocol_list * _Nullable protocols OBJC2_UNAVAILABLE;
#endif

} OBJC2_UNAVAILABLE;
/* Use `Class` instead of `struct objc_class *` */

runtime使用当中,我们经常需要用到的字段,它们的定义


isaClass对象,指向objc_class结构体的指针,也就是这个Class的MetaClass(元类)

类的实例对象的 isa 指向该类;该类的 isa 指向该类的MetaClassMetaCalssisa对象指向RootMetaCalss


super_class Class对象指向父类对象



  • 如果该类的对象已经是RootClass,那么这个super_class指向nil


  • MetaCalssSuperClass指向父类的MetaCalss


  • MetaCalssRootMetaCalss,那么该MetaClassSuperClass指向该对象的RootClass


ivars 类中所有 属性的列表,使用场景:我们在字典转换成模型的时候需要用到这个列表找到属性的名称,去取字典中的值,KVC赋值,或者直接Runtime赋值


methodLists 类中 所有的方法的列表,使用场景:如在程序中写好方法,通过外部获取到方法名称字符串,然后通过这个字符串得到方法,从而达到外部控制App已知方法。


cache 主要用于 缓存常用方法列表,每个类中有很多方法,我平时不用的方法也会在里面,每次运行一个方法,都要去 methodLists遍历得到方法,如果类的方法不多还行,但是基本的类中都会有很多方法,这样势必会影响程序的运行效率,所以 cache在这里就会被用上,当我们使用这个类的方法时先判断 cache是否为空,为空从 methodLists找到调用,并保存到 cache,不为空先从 cache中找方法,如果找不到在去 methodLists,这样提高了程序方法的运行效率。


protocols故名思义,这个类中都遵守了 哪些协议,使用场景:判断类是否遵守了某个协议上


在介绍runtime的时候,需要了解下类的本质。


类底层代码、类的本质?


为了更好的认识类是怎么工作的,我们将要将一段Object-C的代码用clang看下底层的C/C++的写法

typedef enum : NSUInteger {
ThisRPGGame = 0,
ThisActionGame = 1,
ThisBattleFlagGame = 2,
} ThisGameType;

@interface Game : NSObject
@property (copy,nonatomic)NSString *Name;
@property (assign,nonatomic)ThisGameType Type;
@end

@implementation Game
@synthesize Name,Type;

- (void)GiveThisGameName:(NSString *)name{
Name = name;
}

- (void)GiveThisGameType:(ThisGameType)type{
Type = type;
}
@end

使用命令,在当前文件夹中会出现Game.cpp的文件

# clang -rewrite-objc Game.m

由于生成的文件很庞大,可以仔细去研读,受益匪浅

/*
* 顾名思义存放property的结构体
* 当我们使用perproty的时候,会生成这样一个结构体
* 具体存储的数据为
* 实际内容:"Name","T@\"NSString\",C,N,VName"
* 原型:@property (copy,nonatomic)NSString *Name;
* 这个具体是怎么实现的,我会在后面继续深入研究,本文主要来理解runtime的理解
**/
struct _prop_t {
const char *name; //名字
const char *attributes; //属性
};

/*
*类中方法的结构体,cmd和imp的关系是一一对应的关系
*创建对象生成isa指针,指向这个对象的结构体时
*同时生成了一个表"Dispatch table"通过这个_cmd的编号找到对应方法
*使用场景:
*例如方法交换,方法判断。。。
**/
struct _objc_method {
struct objc_selector * _cmd; //SEL 对应着OC中的@selector()
const char *method_type; //方法的类型
void *_imp; //方法的地址
};


/*
* method_list_t 结构体:
* 原型:
* - (void)GiveThisGameName:(NSString *)name;
* 实际存储的方式:
* {(struct objc_selector *)"GiveThisGameName:", "v24@0:8@16", (void *)_I_Game_GiveThisGameName_}
* 其主要目的是存储一个数组,基本的数据类型是 _objc_method
* 扩展:当然这其中有你的属性,自动生成的setter、getter方法
**/

static struct _method_list_t {
unsigned int entsize; // sizeof(struct _objc_method)
unsigned int method_count;
struct _objc_method method_list[6];
}

/*
* 表示这个类中所遵守的协议对象
* 使用场景:
* 判断类是否遵守这个协议,从而动态添加、重写、交换某些方法,来达到某些目的
*
**/

struct _protocol_t {
void * isa; // NULL
const char *protocol_name;
const struct _protocol_list_t * protocol_list; // super protocols
const struct method_list_t *instance_methods; // 实例方法
const struct method_list_t *class_methods; //类方法
const struct method_list_t *optionalInstanceMethods; //可选的实例方法
const struct method_list_t *optionalClassMethods; //可选的类方法
const struct _prop_list_t * properties; //属性列表
const unsigned int size; // sizeof(struct _protocol_t)
const unsigned int flags; // = 0
const char ** extendedMethodTypes; //扩展的方法类型
};

/*
* 类的变量的结构体
* 原型:
* NSString *Name;
* 存储内容:
* {(unsigned long int *)&OBJC_IVAR_$_Game$Name, "Name", "@\"NSString\"", 3, 8}
* 根据存储内容可以大概了解这些属性的工作内容
**/
struct _ivar_t {
unsigned long int *offset; // pointer to ivar offset location
const char *name; //名字
const char *type; //属于什么变量
unsigned int alignment; //未知
unsigned int size; //大小
};


/*
* 这个就是类中的各种方法、属性、等等信息
* 底层也是一个结构体
* 名称、方法列表、协议列表、变量列表、layout、properties。。
*
**/
struct _class_ro_t {
unsigned int flags;
unsigned int instanceStart;
unsigned int instanceSize;
unsigned int reserved;
const unsigned char *ivarLayout; //布局
const char *name; //名字
const struct _method_list_t *baseMethods;//方法列表
const struct _objc_protocol_list *baseProtocols; //协议列表
const struct _ivar_list_t *ivars; //变量列表
const unsigned char *weakIvarLayout; //弱引用布局
const struct _prop_list_t *properties; //属性列表
};

/*
* 类本身
* oc在创建类的时候都会创建一个 _class_t的结构体
* 我的理解是在runtime中的object-class结构体在底层就会变成_class_t结构体
**/
struct _class_t {
struct _class_t *isa; //元类的指针
struct _class_t *superclass; //父类的指针
void *cache; //缓存
void *vtable; //表信息、未知
struct _class_ro_t *ro; //这个就是类中的各种方法、属性、等等信息
};


/*
* 类扩展的结构体
* 在OC中写的分类
**/
struct _category_t {
const char *name; //名称
struct _class_t *cls; //这个是哪个类的扩展
const struct _method_list_t *instance_methods; //实例方法列表
const struct _method_list_t *class_methods; //类方法列表
const struct _protocol_list_t *protocols; //协议列表
const struct _prop_list_t *properties; //属性列表
};

类就是多个结构体组合的一个集合体,类中的行为、习惯、属性抽象,按照机器能懂的数据存储到我们底层的结构体当中,在我们需要使用的时候直接获取使用。


那么就开始研究一下,类是如何使用,类的基本使用过程以及过程中runtime所做的事情。


类底层是如何调用方法?


了解了类的组成,那么类是通过什么样的形式去获取方法属性并得到应用?

在Object-C开发中我们经常会说到,对象调用方法,其本质就是想这个对象发送消息,为什么会有这么一说?下面我们来验证一下。

Object-C代码

int main(int argc, char * argv[]) {

Game *game = [Game alloc];
[game init];
[game Play];
return 0;
}

底层代码的实现

int main(int argc, char * argv[]) {

Game *game = ((Game *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Game"), sel_registerName("alloc"));
game = ((Game *(*)(id, SEL))(void *)objc_msgSend)((id)game, sel_registerName("init"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)game, sel_registerName("Play"));
return 0;
}

代码中使用了



objc_msgSend 消息发送

objc_getClass 获取对象

sel_registerName 获取方法的SEL



因为目前重点是objc_msgSend,其他的Runtime的方法会在后面继续一一道来, So 一个对象调用其方法,在`=Object-C中就是向这个对象发送一条消息,消息的格式

objc_msgSend("对象","SEL","参数"...)
objc_msgSend( id self, SEL op, ... )


收起阅读 »

iOS Runtime (一) 什么是Runtime?

iOS
一:Runtime是什么? 1,运行时(Runtime)是指将数据类型的确定由编译时推迟到了运行时。 2,Runtime是一套比较底层的纯C语言API, 属于1个C语言库, 包含了很多底层的C语言API。 3,平时编写的OC代码,在程序运行过程中,其实最终会...
继续阅读 »

一:Runtime是什么?



1,运行时(Runtime)是指将数据类型的确定由编译时推迟到了运行时

2,Runtime是一套比较底层的纯C语言API, 属于1个C语言库, 包含了很多底层的C语言API。

3,平时编写的OC代码,在程序运行过程中,其实最终会转换成Runtime的C语言代码,Runtime是Object-C的幕后工作者

4,Object-C需要Runtime来创建对象,进行消息发送转发



二:Runtime用在哪些地方?



1,在程序运行过程中,动态的创建类,动态添加、修改这个类的属性方法

2,遍历一个类中所有的成员变量、属性、以及所有方法

2,消息传递、转发



三:Runtime具体应用?



1,创建类,给类添加属性、方法

2,方法交换

3,获取对象的属性、私有属性

4,字典转换模型

5,KVC、KVO

6,归档(编码、解码)

7,NSClassFromString class<->字符串

8,block



常见用法


1,使用objc_allocateClassPair可在运行时创建新的类

2,使用class_addMethodclass_addIvar可向类中增加方法实例变量

3,最后使用objc_registerClassPair注册后,就可以使用此类了。

这体现了OC作为运行时语言的强大之一:在代码运行中动态创建并添加方法变量


a.使用objc_allocateClassPair创建一个类Class

const char * className = "Calculator";
Class kclass = objc_getClass(className);
if (!kclass)
{
Class superClass = [NSObject class];
kclass = objc_allocateClassPair(superClass, className, 0);
}


b.使用class_addIvar添加一个成员变量

  NSUInteger size;
NSUInteger alignment;
NSGetSizeAndAlignment("*", &size, &alignment);
class_addIvar(kclass, "expression", size, alignment, "*");

注:



1.type定义参考

2."*"星号代表字符( )iOS字符为4位,并采用4位对齐kclass



c.使用class_addMethod添加成员方法

    class_addMethod(kclass, @selector(setExpressionFormula:), (IMP)setExpressionFormula, "v@:@");
class_addMethod(kclass, @selector(getExpressionFormula), (IMP)getExpressionFormula, "@@:");

static void setExpressionFormula(id self, SEL cmd, id value)
{
NSLog(@"call setExpressionFormula");
}

static void getExpressionFormula(id self, SEL cmd)
{
NSLog(@"call getExpressionFormula");
}

注:



1.type定义参考

2."v@:@",解释v-返回值void类型,@-self指针id类型,:-SEL指针SEL类型,@-函数第一个参数为id类型。

3."@@:",解释@-返回值id类型,@-self指针id类型,:-SEL指针SEL类型。



d.注册到运行时环境

objc_registerClassPair(kclass);

e.实例化类

id instance = [[kclass alloc] init];

f.给变量赋值

object_setInstanceVariable(instance, "expression", "1+1"); 

g.获取变量值

void * value = NULL;
object_getInstanceVariable(instance, "expression", &value);

h.调用函数

[instance performSelector:@selector(getExpressionFormula)];

说明:objc_allocateClassPair函数的作用是创建一个新类newClass及其元类,三个参数依次为newClass的父类newClass的名称,第三个参数通常为0。然后可向newClass中添加变量及方法,注意若要添加类方法,需用objc_getClass(newClass)获取元类,然后向元类中添加类方法。接下来必须把newClass注册到运行时系统,否则系统是不能识别这个的。



链接:https://www.jianshu.com/p/e7586587ccf7
收起阅读 »

Android中Window 和 WindowManager

Window 是一个抽象类,具体实现是 PhoneWindow,通过 WindowManager 创建。WindowManager是外界访问Window的入口,Window 的具体实现位于 WindowManagerService 中WindowManager...
继续阅读 »

Window 是一个抽象类,具体实现是 PhoneWindow,通过 WindowManager 创建。

WindowManager是外界访问Window的入口,Window 的具体实现位于 WindowManagerService 中

WindowManager 和 WindowManagerService 的交互是一个 IPC 的过程

Andorid 中所有的视图都是通过 Window 来呈现的

不管是 Activity 、Dialog 还是 Taost

因此, Window 实际是 View 的直接管理者 !!!

1. Window 和 WindowManager

WindowManager 添加 Window 的过程

将一个 Button 添加到屏幕坐标为(100,,300)的位置上。其中,Flags 和 type 这两个参数比较重要。

mFloatingButton = new Button(this);
mFloatingButton.setText("button");
mLayoutParams = new WindowManager.LayoutParams(
LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, 0 ,0 , PixelFormat.TRANSPARENT);
mLayoutParams.flags = LayoutParams.FLAG_NOT_TOUCH_MODAL
   | LayoutParams.FLAG_NOT_FOCUSABLE
   | LayoutParams.FLAG_SHOW_WHEN_LOCKED
   
mLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
mLayoutParams.x = 100;
mLayoutParams.y =300;
mWindowManager.addView (mFloatignButton, mLayoutParams);

Flags 参数表示 Window 的属性

  • FLAG_NOT_FOCUSABLE

    表示Window 不需要获取焦点,也不需要接收各种输入事件,此标记会同时启用 FLAG_NOT_TOUCH_MODAL ,最终事件会直接传递给下层的具体焦点的 Window

  • FLAG_NOT_TOUCH_MODAL

    在此模式下,系统会将当前 Window 区域以外的单击事件传递给底层的 Window ,当前 Window 区域以内的单击事件则自己处理。这个标记很重要,一般来说都需要开启此标记,否则其他 Window 将无法收到单击事件。

  • FLAG_SHOW_WHEN_LOCKED

    开启此模式可以让 Window 显示在锁屏的界面上。

Type 参数表示 Window 的类型, 应用 Window 、子 Window 和 系统 Window

  • 应用 Window 对应着一个 Activity , 层级 1-99
  • 子Window 不能单独存在,需要依附在特定的父Window之中 , 层级 1000-1999
  • 系统Window 是需要声明权限在能创建的 Window, 比如 Toast 和 系统状态栏这些都是系统 Window , 2000-2999

    一般选用 TYPE_SYSTEM_OVERLAY 或者 TYPE_SYSTEM_ERROR

注: Window 是分层的,每个Window 都有对应的 z-ordered , 层级大的会覆盖在层级小的 Window 的上面。

WindowManager 所提供的功能很简单,常用方只有三个方法:

  • 添加 View
  • 更新 View
  • 删除View

这三个方法定义在 ViewManager 中,WindowManager 继承了 ViewManager

public interface ViewManager
{
public void addView(View view , ViewGroup.LayoutParams params);
public void updateViewLayout(View view, ViewGroup.LayoutParams params);
public void removeView(View view);
}

拖动 Window 的效果

根据手指的位置来设定 LayoutParams 中 的 x 和 y 的值即可改变 Window 的位置。

首先给 View 设置 onTouchListener : mFloatingButton.setOnTouchListener(this)

然后在onTouch 方法中不断更新View 的位置即可。

public boolean onTouch(View v, MotionEvent envet){
int rawX = (int) event.getRawX();
int rawY = (int) event.getRwaY();
switch(event.getAction()){
case MotionEvent.ACTION_MOVE:{
mLayoutParams.x = rawX;
mLayoutParams.y = rawY;
mWindowManager.updateViewLayout(mFloatingButton, mLayoutParams);
break;
}
default:
break;
}
return false;
}

2. Window 的内部机制

Window 是一个抽象的概念,每一个Window 都对应着一个 View 和一个 ViewRootImpl 。

Window 和 View 通过 ViewRootImpl 来建立联系,因此Window 并不是实际存在的,它是以View 的形式存在。

View 才是 Window 存在的实体。

1. Window 的添加过程

Window 的添加过程需要通过 WindowManager 的 addView 来实现, WindowManager 是一个接口,它的真正实现是 WindowManagerImpl 类。


@Override
public void addView(View view, ViewGroup.LayoutParams params){
   mGlobal.addView(view, params , mDisplay, mParentWindow);
}

@Override
public void updateViewLayout(View view , ViewGroup.LayoutParams params){
   mGlobal.updateViewLayout(view ,params);
}

@Override
public void removeView(View view){
   mGlobal.removeView(view, false);
}

交给 WindowManagerGlobal 来处理

WindowManagerGlobal 以工厂的形式向外提供自己的实例。

WindowManagerGlobal 的 addView 方法主要分为如下步

  1. 检查参数是否合法,如果是子 Window 那么还需要调整一些布局参数

    if (view == null){
    throw new IllegalArgumentException("view must not be null");
    }
    if (display == null){
       throw new IllegalArgumentException("display must be null");
    }
    if(!(params instanceof WindowManager.LayoutParams)){
       throw new IllegalArgumentException("Params must be WindowManager.LayoutParams");
    }

    final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams)params;
    if (parentWindow != null){
       parentWindow.adjustLayoutParamsForSubWindow(wparams);
    }
  2. 创建 ViewRootImpl 并将 View 添加到列表中

在 WindowManagerGlobal 内部有如下几个列表比较重要:

private final ArrayList<Viwe>mViews = new ArrayList<View>();
private final ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>();
private final ArrayList<WindowManager.LayoutParams> mParams = new ArrayList<WindowManager.LayoutParams>();
private final ArraySet<View> mDyingVies = new ArraySet<View>();
  • mViews 存储的是所有 Window 所对应的 View
  • mRoots 存储的是所有 Window 所对应的 ViewRootImpl
  • mParams 存储的是所有 Window 所对应的布局参数
  • mDyingViews 则存储了那些正在被删除的 View 对象。或者是那些已经调用 removeView 方法但是删除操作还未完成的 Window 对象。

在 addView 中通过如下方式将 Window 的一系列对象添加到列表中

root = new ViewRootImpl(view.getContext(), display);
view.setLayoutParams(wparams);

mViews.add(view);
mRoot.add(root);
mParams.add(wparams);
  1. 通过 ViewRootImpl 来更新界面并完成 Window 的添加过程

    由ViewRootImpl 的 setView 方法来完成。View 的绘制过程是由 ViewRootImpl 来完成的。

    setView 内部通过 requestLayout 来完成异步刷新请求。

public void requestLayout(){
if (!mHandlingLayoutInLayoutRequest){
checkThread();
mLayoutRequested = true;
// 实际是VIEW 绘制的入口
scheduleTraversals();
}
}

接着会通过 WindowSession 最终来完成 Window 的添加过程。

在下面的代码中 mWindowSession 的类型是 IWindowSession , 它是一个 Binder 对象,真正实现类是 Session, 也就是 Window 的添加过程是一次IPC 调用

try{
   mOrigWindowType = mWindowAttributes.type;
   mAttachInfo.mRecomputeGlobalAttributes = true;
   collectViewAttributes();
   res =  mWindsSeesion.addToDisplay(mWindow, mSeq, mWindowAttributes, getHostVisibility(), mDisplay.getDisplayId(),mAttachInfo.mContentInsets, mInputChannel);
}catch (RemoteException e){
   mAdded = false;
   mView = null;
   mAttachInfo.mRootView = null;
   mInputChannel = null;
   mFallbackEventHandler.setView(null);
   unscheduleTraversals();
   setAccessibilityFocus(null, null);
   throw new RuntimeException("Adding window failed", e);
}

在 Session 内部会通过 WindowManagerService 来实现 Window 的添加

public int addToDisplay(IWindow window, int seq, WindowManager.LayoutParams attrs,
                      int viewVisibility, int displayId, Rect outCotentInsets,
                      InputChannel outInputChannel){
   return mService.addWindow(this, window, seq, attrs, viewVisibility, disalayId, outContentInsets, outInputChannel);
}

如此一来,Window 的添加请求就给 WindowManagerService 去处理了。在WindowManagerService 内部会为每一个应用保留一个单独的 Session.

2. Window 的删除过程

先通过 WindowManagerImpl后,再进一步通过 WindowManagerGlobal 来实现的。

public void removeView(View view , boolean immediate){
if (view == null){
throw new IllegalArgumentException("view must not be null");
}

synchronized(mLock){
       // 先通过 findViewLocked 来查找待删除的 View 的索引,建立数组遍历
int index = findViewLocked(view, true);
View curView = mRoots.get(index).getView();
       // 调用removeViewLocked 来做进一步的删除
removeViewLocked(index, immediate);
if (curView == view){
return;
}
throw new IllegalStateExceotion("Calling with view " + view + "but the
ViewAncestor is attached to " + curView);
}
}
private void removeViewLocked(int index, boolean immediate){
   ViewRootImpl root = mRoots.get(index);
   View view = root.getView();
   
   if (view != null){
       InputMethodManager imm = InputMethodManager.getInstance();
       if ( imm != null){
           imm.windowDismissed(mViews.get(index).getWindowToken());
      }
  }
   boolean deferred = root.die(immediate);
   if(view != null){
       view.assignParent(null);
       if (deferred){
           mDyingViews.add(view);
      }
  }
}

removeViewLocked 是通过 ViewRootImpl 来完成删除操作。

WindowManager 中提供了两种删除接口 removeView 和 removeImmdiate

分别代表 异步删除和 同步删除

3. Window 的更新过程

由WindowManagerGlobal 的 updateViewLayout 方法实现。

4. Window 的创建过程

Activity 的 Window 是通过 PolicyManager 的一个工厂方法来创建的。

Activity 的视图是如何附属在 Window 上,由于Activity 的视图由setContentView 方法提供。

public void setContentView(int layoutResID){
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}

这里可以看出,Activity 将具体实现交给了 Window 处理,而Window 的具体实现是 PhoneWindow .

这里主要看 PhoneWindow

  1. 如果没有 DecorCView ,那么就创建它

    DecorView 是一个 FrameLayout , 是Activity 中的顶级 View , 一般来说它的内部包含标题栏和内部栏。

    不管怎样,内容栏是一定要存在的,并且内容来具体固定的 id ,那就是 “ content" .完整的 id 是 android.R.content.

    DecorView 的创建过程由 installDecor 方法来完成,在方法内部会通过 generateDecor 来直接创建 DecorView ,这时其还是一个空白的FrameLayout:

    protected DecorView generateDecor(){
       return new DecorView(getContext(), -1);
    }

    为了初始化 DecorView 的结构,PhoneWindow 还需要通过 generateLayout 方法来加载具体的布局文件到 DecorView 中。

    View in = mLayoutInflater.inflate(layoutResource, null);
    decor.addView(in , new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
    mContentRoot = (ViewGroup) in;
    ViewGroup contentParent = (ViewGroup)findViewById(ID_ANDROID_CONTENT);
// 该id 对应的ViewGroup 就是 mContentParent
public static final inT ID_ANDROID_CONTENT = com.android.internal.R.id.content
  1. 将View 添加到 DecorView 的 mContentParent 中
// 直接将 Activity 的视图添加到 Decorview 的 mContentParent 中
mLayoutInflater.inflate(layoutResID, mContentParent)
   
// Activity 的布局文件被添加到 DecorView 的 mContentParent中, setContentView
  1. 回调Activity 的 onContentChanged 方法通知 Activity 视图已经改变
final Callback cb =  getCallback();
if (cb != null && !isDestroyed()){
cb.onContentChanged();
}

在 makeVisible 方法中,DecorView 真正地完成了添加和显示这两个过程,此时 Activity 的视图才能被用户看到。

void makeVisible(){
if (!mWindowAdded){
ViewManager wm = getWindiowManager();
wm.addView(mDecor, getWindow().getAttributes());
mWindowAdded = true;
}
mDecor.setVisihility(View.VISIBLE);
}


在 makeVisible 方法中,DecorView 真正地完成了添加和显示这两个过程,此时 Activity 的视图才能被用户看到。

void makeVisible(){
if (!mWindowAdded){
ViewManager wm = getWindiowManager();
wm.addView(mDecor, getWindow().getAttributes());
mWindowAdded = true;
}
mDecor.setVisihility(View.VISIBLE);
}
收起阅读 »

一篇文章带你走近Android自定义view

前言从专科到本科,目前本科大四,已经是学习Android的第四个年头了,本打算积累一下冲23考研,但是最近被大佬洗脑后准备冲一冲22的考研,所以后续出文章的几率会很小,但是在前不久答应粉丝整理一个较为详细的Android自定义view教程,恰巧最近报名被华为选...
继续阅读 »

前言

从专科到本科,目前本科大四,已经是学习Android的第四个年头了,本打算积累一下冲23考研,但是最近被大佬洗脑后准备冲一冲22的考研,所以后续出文章的几率会很小,但是在前不久答应粉丝整理一个较为详细的Android自定义view教程,恰巧最近报名被华为选入2021年鸿蒙公开课的学生代表之一,在学校为请假条奔波的路上,所以抽出一下午写一篇文章。(有点小感冒,如发现错误请见谅,感谢指正!!!)。


下文为正文内容,所有链接案例注解都比较详细

一、为什么要自定义view

随着各大产品经理的内卷,Android系统内置的View早已无法满足我们的需求,我们需要针对自己的业务来定制我们需要的view,以达到更好的用户体验感,从而增加用户的黏性。

二、先看看一个超级简单的自定义view(三个构造函数)

需求:一个界面两个跑马灯(在xml中实现) 出现的问题:Textview在xml文件中实现跑马灯,如果有两个跑马灯,则会出现抢焦点的现象,只会跑一个。 解决方式:自定义一个Textview,设置其自动获得焦点: isFocused();

public class MyTextView extends TextView {
//在用代码创建的时候调用
public MyTextView(Context context) {
this(context, null);
}

//在识别XML的时候会调用此方法创建Textview,底层会用反射去AttribestSet去取属性值
public MyTextView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

//给第一个构造函数和第二个使用
public MyTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

//解决一个问题,需要Textview天生获取焦点
@Override
public boolean isFocused() {
return true;
}
}

从以上代码中,本人已将函数的作用写入到备注中。

三、了解手机的坐标系

4bdd208ec73144d1be51e84291c3651f_tplv-k3u1fbpfcp-watermark.webp

具体案例文章:Android用Canvas画一个真正能跑的跑马灯

四、使用Canvas画一个折线图(重写onDraw()方法)

此文章案例主要为canvas.drawLine(),drawText()的简单使用。

具体案例文章:Android用Canvas画一个折线图,并加以简单封装

五、如何自定义属性,且在view中获取到属性的值(小提,在六中会有案例)

以颜色为例。

//attrs文件
<attr name="leftcolor" format="reference|color"/>
<attr name="rightcolor" format="reference|color"/>
//java文件 ---TaiJiView为自定义view名称
//获取自定义属性。
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TaiJiView);
//获取颜色
int leftcolor = ta.getColor(R.styleable.TaiJiView_leftcolor, Color.BLACK);
int rightcolor=ta.getColor(R.styleable.TaiJiView_rightcolor, Color.WHITE);
//回收
ta.recycle();
//布局中
app:leftcolor="@color/colorPrimary"
app:rightcolor="#ff0000"

六、绘制图案以及加入设置简单的动画(案例讲解很详细)

canvas.drawCircle ,旋转动画

具体案例文章:Android自定义view之太极图

七、自定义view的实现分类以及自定义组合控件的案例

  • 自定义组合控件:将多个控件组合成为一个新的控件。(本案例)
  • 继承系统控件:如标题二的案例
  • 继承View:如标题六的案例
  • 继承ViewGroup:继承自LinearLayout等系统控件,在系统控件的基础功能上进行扩展。

具体案例文章:Android自定义view之模仿登录界面文本输入框(华为云APP)

八、简单测量以及自定义接口实例来控制动画的更新计算表达式(onMeasure,TypeEvaluator)

项目源码贴在链接文章末尾

具体案例文章:Android自定义view之围棋动画

九 、通过改变变量的值达到动画效果

Android自定义view之利用drawArc方法实现动态效果

Android自定义view之围棋动画(化繁为简)

Android自定义view之利用PathEffect实现动态效果

Android自定义view之线条等待动画(灵感来源:金铲铲之战)

小提:把绘制点移动到中间。代码看起来会简洁点

十、当界面更新频繁(SurfaceView)

讲讲Android为自定义view提供的SurfaceView

十一、GLSurfaceView(继承自SurfaceView,3D效果)

Android自定义view之3D正方体

如需继续深入还请了解openGL相关内容。

十二 、关于SVG

Android利用SVG实现动画效果 Android SVG动画详细例子

十三 、上一个简单github案例

Android线条等待动画JMWorkProgress(可添加依赖直接使用)

十四 、还没来得及具体写的(关键词)

贝塞尔曲线,事件分发机制。枚举(可在框架中用于确定动画状态)

十五 、两道面试相关八股(根据本人面试大厂整理)

1.View绘制流程

View的绘制是从 ViewRootImpl的 performTraversals()方法开始,从最顶层的 View(ViewGroup)开始逐层对每个 View进行绘制操作 。

View 绘制中主要流程分为measure,layout, draw 三个阶段。

measure :根据父 view 传递的 MeasureSpec 进行计算大小, 自定义View的过程中都会在onMeasure中进行宽高的测量,这个方法会从父布局中接收两个参数 widthMeasureSpac和 heightMeasureSpac,所以子布局的宽高大小需要受限于父布局。

layout :根据 measure 子 View 所得到的布局大小和布局参数,将子View放在合适的位置上, 结合源码可知 layout()会将四个位置参数传递给 setOpticalFrame()或者 setFrame(),而 setOpticalFrame()内部会调用 setFrame(),所以最终通过 setFrame()确定 View在 ViewGroup中的位置。位置确定完毕会调用 onLayout(l,t,r,b)对子View进行摆放。

draw :把 View 对象绘制到屏幕上。

  • Canvas:画布,不管是文字,图形,图片都要通过画布绘制而成
  • Paint:画笔,可设置颜色,粗细,大小,阴影等等等等,一般配合画布使用
  • Path:路径,用于形成一些不规则图形。
  • Matrix:矩阵,可实现对画布的几何变换。

2.View 的事件分发机制

触摸事件的类型

触摸事件对应的是 MotionEvent 类,事件的类型主要有如下三种:

  • ACTION_DOWN
  • ACTION_MOVE(移动的距离超过一定的阈值会被判定为 ACTION_MOVE 操作)
  • ACTION_UP

View 事件分发本质就是对 MotionEvent 事件分发的过程。即当一个 MotionEvent 发生后,系统将这个点击事件传递到一个具体的 View 上。

事件分发流程

事件分发过程由三个方法共同完成:

dispatchTouchEvent: 方法返回值为 true 表示事件被当前视图消费掉;返回为 super.dispatchTouchEvent 表示继续分发该事件,返回为 false 表示交给父类的 onTouchEvent 处理。

onInterceptTouchEvent: 方法返回值为 true 表示拦截这个事件并交由自身的 onTouchEvent 方法进行消费;返回 false 表示不拦截,需要继续传递给子视图。 如果 return super.onInterceptTouchEvent(ev), 事件拦截分两种情况:

  • 1.如果该View存在子View且点击到了该子View, 则不拦截, 继续分发 给 子 View 处理, 此时相当于 return false。

  • 2.如果该 View 没有子 View 或者有子 View 但是没有点击中子 View(此时 ViewGroup 相当于普通 View), 则交由该 View 的 onTouchEvent 响应,此时相当于 return true。

注意:一般的 LinearLayout、 RelativeLayout、FrameLayout 等 ViewGroup 默认不拦截, 而 ScrollView,ListView 等 ViewGroup 则可能拦截,得看具体情况。

onTouchEvent: 方法返回值为 true 表示当前视图可以处理对应的事件;返回值 为 false 表示当前视图不处理这个事件,它会被传递给父视图的 onTouchEvent 方法进行处理。如果 return super.onTouchEvent(ev),事件处理分为两种情况:

  • 1.如果该 View 是 clickable 或者 longclickable 的,则会返回 true, 表示消费 了该事件, 与返回 true 一样;

  • 2.如果该 View 不是 clickable 或者 longclickable 的,则会返回 false, 表示不 消费该事件,将会向上传递,与返回 false 一样。

注意:在 Android 系统中,拥有事件传递处理能力的类有以下三种:

  • Activity:拥有分发和消费两个方法。

  • ViewGroup:拥有分发、拦截和消费三个方法。

  • View:拥有分发、消费两个方法。


收起阅读 »

Retrofit流程极简解析

Retrofit流程极简解析以SandwichDemo为例子来解析。github地址创建Retrofitprivate val retrofit: Retrofit = Retrofit.Builder() .client(okHttpClient) .bas...
继续阅读 »

Retrofit流程极简解析

以SandwichDemo为例子来解析。github地址

创建Retrofit

  • private val retrofit: Retrofit = Retrofit.Builder()
    .client(okHttpClient)
    .baseUrl(
    "https://gist.githubusercontent.com/skydoves/aa3bbbf495b0fa91db8a9e89f34e4873/raw/a1a13d37027e8920412da5f00f6a89c5a3dbfb9a/"
    )
    .addConverterFactory(GsonConverterFactory.create())

    /* asynchronous supports */
    // .addCallAdapterFactory(DataSourceCallAdapterFactory.create())

    /* coroutines supports */
    .addCallAdapterFactory(CoroutinesResponseCallAdapterFactory.create())
    //.addCallAdapterFactory(CoroutinesDataSourceCallAdapterFactory.create())
    .build()

    创建接口类

    val disneyService: DisneyCoroutinesService = retrofit.create(DisneyCoroutinesService::class.java)

    获取接口返回的数据

    val apiResponse:ApiResponse<List<Poster>> = disneyService.fetchDisneyPosterList()

    就是这么简单,数据获取完成


细分流程解析

    1. 创建Retrofit。这里使用了创建者模式,通过Retrofit.Builder来创建Retrfofit实例,一般项目里都会做成单例
    2. Builder().client(OkHttpClient client)设置网络请求的最终调用者,这里和OkHttp是绝配
    3. baseUrl(Url baseUrl)设置baseUrl链接
    4. addConverterFactory(Converter.Factory factory)添加网络参数和返回类的转换器,例如Gson,Moshi
    5. addCallAdapterFactory(CallAdapter.Factory factory)添加接口请求结果的转换器
    6. build()方法中,会通过platform.defaultCallAdapterFactories(callbackExecutor)来添加默认的CallAdapter.Factory转换器和我们自定义的转换器。而ConvertorFactory转换器,默认加入new BuiltInConverters()和平台默认转换器platform.defaultConverterFactories()以及我们自定义的转换器
  • 通过Retrofit创建接口类 1.调用create(Class<T> service)方法来创建对应的接口类

    return (T)
    Proxy.newProxyInstance(
    service.getClassLoader(),
    new Class<?>[] {service},
    new InvocationHandler() {
    private final Platform platform = Platform.get();
    private final Object[] emptyArgs = new Object[0];

    @Override
    public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args)
    throws Throwable {
    // If the method is a method from Object then defer to normal invocation.
    if (method.getDeclaringClass() == Object.class) {
    return method.invoke(this, args);
    }
    args = args != null ? args : emptyArgs;
    return platform.isDefaultMethod(method)
    ? platform.invokeDefaultMethod(method, service, proxy, args)
    : loadServiceMethod(method).invoke(args);
    }
    });

    这里就是通过动态代理来。动态代理的理论网上很多,可以自己搜索;简单说下,就是比如代理的接口类,调用它的方法时候,会进入到动态代理类里InvocationHandlerinvoke()中,这里参数有method提供Method的各种方法,args参数提供方法的各个参数。这里就是完全代理了接口方法,来自己实现,这里思想多大,舞台就有多大。

  • invoke方法解析

    1. 解析loadServiceMethod(method).invoke(args)loadServiceMethod()方法返回ServiceMethod抽象类,实际是HttpServiceMethod类。
    2. 核心方法HttpServiceMethod.parseAnnotations方法调用并返回HttpServiceMethod类,这里是核心解析方法;上面的invoke(args)方法最终是调用了HttpServiceMethod类的invoke方法,最终是调用如下:
     @Override
    final @Nullable ReturnT invoke(Object[] args) {
    Call<ResponseT> call = new OkHttpCall<>(requestFactory, args, callFactory, responseConverter);
    return adapt(call, args);
    }

    这里记住这个类OkHttpCall 3. 解析HttpServiceMethod<ResponseT, ReturnT>.parseAnnotations()方法: 这里会通过RequestFactory来解析参数和返回值,其中 java if (Utils.getRawType(parameterType) == Continuation.class) { isKotlinSuspendFunction = true; return null; } 这个解析判断是否是suspend函数。 这里会根据是否挂起函数来确定不同的返回值。 继续:根据是否是挂起函数,来获取对应的adapterType,即类似Call<UserData>里的UserData类型,或者suspendUserData返回值类型。

    CallAdapter<ResponseT, ReturnT> callAdapter =
    createCallAdapter(retrofit, method, adapterType, annotations);
    Type responseType = callAdapter.responseType();

    这里通过返回类型,来匹配我们加入的CallAdapter来进行返回的Response的包装或者逻辑处理

    Converter<ResponseBody, ResponseT> responseConverter =
    createResponseConverter(retrofit, method, responseType);

    这里通过responseType来获取我们添加的返回结果转换器,比如GsonFactory,MothiFactory来 4.

        if (!isKotlinSuspendFunction) {
    return new CallAdapted<>(requestFactory, callFactory, responseConverter, callAdapter);
    } else if (continuationWantsResponse) {
    //noinspection unchecked Kotlin compiler guarantees ReturnT to be Object.
    return (HttpServiceMethod<ResponseT, ReturnT>)
    new SuspendForResponse<>(
    requestFactory,
    callFactory,
    responseConverter,
    (CallAdapter<ResponseT, Call<ResponseT>>) callAdapter);
    } else {
    //noinspection unchecked Kotlin compiler guarantees ReturnT to be Object.
    return (HttpServiceMethod<ResponseT, ReturnT>)
    new SuspendForBody<>(
    requestFactory,
    callFactory,
    responseConverter,
    (CallAdapter<ResponseT, Call<ResponseT>>) callAdapter,
    continuationBodyNullable);
    }

    这里会返回最终的各式各样的HttpMethod的实现类 如果非suspend函数,则直接返回CallAdapter这里,Java代码非协程一般都是这种情况; 如果是suspend函数且返回值为Response类型的,则返回SuspendForResponse 其余的suspend函数情况,则返回SuspendForBodykotlin+协程里一般是这种情况 5. 分析CallAdapterSuspendForBody的区别,最大区别,就是Suspend会再adapt里自动调用OkHttp的请求接口方法并返回对应的Response,而CallAdapter则不会,而是需要使用者自己去调用。

    至此,简略版的Retrofit流程已经梳理完毕

    我们自己可以自定义的部分:ConverterFactoryCallFactory这里官方都给了默认的和常用的,例如Converter转换类就有gson,guava,jackson,moshi,jaxb....;而默认的CallFactory,除了库里自带的默认的DefualtCallFactory,还有官方写的库:guava,java8,rxjava,rxjava2,rxjava3,scala,这里常用的是rxjava2,rxjava3,还有例如我现在用的Sandwich库里封装的CoroutinesResponseCallAdapterFactorykotlin协程配合起来非常好用

收起阅读 »

FLutter即时通讯

1. 即时通讯简述 即时通讯是端开发工作中常见的需求,本篇文章以作者工作中使用FLutter开发社交软件即时通讯需求为背景,描述一下即时通讯功能设计的要点。 2. 重要概念 即时通讯需要前后端配合,约定消息格式与消息内容。本次IM客户端需求开发使用了公司已有的...
继续阅读 »

1. 即时通讯简述


即时通讯是端开发工作中常见的需求,本篇文章以作者工作中使用FLutter开发社交软件即时通讯需求为背景,描述一下即时通讯功能设计的要点。


2. 重要概念


即时通讯需要前后端配合,约定消息格式与消息内容。本次IM客户端需求开发使用了公司已有的基于Socket.io搭建的后台,下文描述涉及到的一些概念。


2.1 WebSocket协议


WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket协议与传统的HTTP协议的主要区别为,WebSocket协议允许服务端主动向客户端推送数据,而传统的HTTP协议服务器只有在客户端主动请求之后才能向客户端发送数据。在没有WebSocket之前,即时通讯大部分采用长轮询方式。


2.2 Socket.io和WebSocket的区别


Socket.io不是WebSocket,它只是将WebSocket和轮询 (Polling)机制以及其它的实时通信方式封装成了通用的接口,并且在服务端实现了这些实时机制的相应代码。也就是说,WebSocket仅仅是Socket.io实现即时通信的一个子集。因此WebSocket客户端连接不上Socket.io服务端,当然Socket.io客户端也连接不上WebSocket服务端。


2.3 服务端socket消息


理解了服务端socket消息也就理解了服务器端的即时通讯逻辑,服务器发出的socket消息可以分为两种:




  1. 服务器主动发出的消息:


    例如,社交软件中的A用户给B用户发出了消息,服务器在收到A用户的消息后,通过socket链接,将A用户的消息转发给B用户,B用户客户端接收到的消息就属于服务器主动发出的。其他比较常见的场景例如直播软件中,全平台用户都会收到的礼物消息广播。




  2. 服务器在接收到客户端消息后的返回消息:


    例如,长链接心跳机制,客户端向服务器发送ping消息,服务器在成功接受客户端的ping消息后返回的pong消息就属于服务器的返回消息。其他常见的场景如社交软件中A用户给B用户发出了消息,服务器在收到A用户的消息后,给A客户端返回一条消息,供A客户端了解消息的发送状态,判断发送是否成功。大部分场景,服务器在接收到客户端主动发出的消息之后都需要返回一条消息。




3. 客户端实现流程


几个设计客户端即时通讯的重点。


3.1 心跳机制


所谓心跳就是客户端发出ping消息,服务器成功收到后返回pong消息。当客户端一段时间内不在发送ping消息,视为客户端断开,服务器就会主动关闭socket链接。当客户端发送ping消息,服务器一段时间内没有返回pong消息,视为服务器断开,客户端就会启动重连机制。


启动流程


3.2 重连机制


重连机制为客户端重新发起连接,常见的重连条件如下:



  • 客户端发送ping消息,服务器一段时间内没有返回pong。

  • 客户端网络断开。

  • 服务器主动断开连接。

  • 客户端主动连接失败。


当出现极端情况(客户端断网)时,频繁的重连可能会导致资源的浪费,可以设置一段时间内的最大重连次数,当重连超过一定次数时,休眠一段时间。


3.3 消息发送流程



  1. 将消息存储到本地数据库,发送状态设为等待。

  2. 发送socket消息。

  3. 接收到服务器返回的socket消息后,将本地数据库等待状态的消息改为成功。


注意事项:


将消息存储到本地数据库时需要生成一个id存入数据库,同时传给服务器,当收到消息时根据id判断更新本地数据库的哪一条消息。


3.4 消息接收流程



3.5 其他相关



  • 聊天页消息的排序:在查询本地数据库时使用order by按时间排序。

  • 消息列表:也推荐做本地存储,当收到消息的时候需要先判断本地消息列表是否有当前消息用户的对话框,如果没有就先插入,有就更新。消息列表的维护就不展开说了,感兴趣可以看代码。

  • 图片语音消息:将图片和语言先上传到专门的服务器上(各种专门的云存储服务器),sokcet消息和本地存储传递的是云服务器上的URL。

  • 多人聊天(群聊):与单人聊天逻辑基本一致,区别位本地数据库需要添加一个会话ID字段,打开一个群就查询对应会话ID的数据。聊天消息不再是谁发给谁,而是在哪个群聊下。


4. 客户端Flutter代码


把部分代码贴上来,完整项目在作者的github上。


4.1 心跳机制


  heart() {
pingTimer = Timer.periodic(Duration(seconds: 30), (data) {
if (pingWaitTime >= 60) {
socket.connect();
pingWaitTime = 0;
pingWaitTimer!.cancel();
ping();
}
if (!pingWaitFlag) ping();
});
}

ping() {
debugPrint("ping");
String pingData =
'{"type":"ping","payload":{"front":true},"msg_id":${DateTime.now().millisecondsSinceEpoch}}';
socket.emit("message", pingData);
pingWaitFlag = true;
pingWaitTime = 0;
pingWaitTimer = Timer.periodic(Duration(seconds: 1), (data) {
pingWaitTime++;
print(data.hashCode);
if (pingWaitTime % 10 == 0) debugPrint(pingWaitTime.toString());
});
}
//pong
if (socketMessage.type == PONG && socketMessage.code == 1000) {
pingWaitFlag = false;
pingWaitTimer!.cancel();
pingWaitTime = 0;
}

4.2 本地数据库设计


数据库表的设计是比较重要的,理解了数据库设计,读代码也就无压力了。


      //消息表
CREATE TABLE chatDetail (
chat_id TEXT PRIMARY KEY,//主键
from_id TEXT,//发送人
to_id TEXT,//接收人
created_at TEXT,
content TEXT,//消息内容
image TEXT,//UI展示用,用户头像
name TEXT,//UI展示用,用户名
sex TEXT,//UI展示用,用户性别
status TEXT,//消息状态
type INTEGER,//消息类型,图片/文字/语音等
chat_object_id TEXT//聊天对象ID,对当前用户而言的聊天对象,是一系列本地操作的核心
)
//消息列表表
CREATE TABLE chatList (
cov_id TEXT,
unread_count INTEGER,
last_msg_text TEXT,
last_msg_at TEXT,
image TEXT,
name TEXT,
sex TEXT,
chat_object_id TEXT PRIMARY KEY)

5. 总结


无论是Flutter技术,或是IOS/Android/Web。只要掌握了即时通讯的核心开发流程,不同的技术只是API有些变化。API往往看文档就能解决,大前端或是特定平台的工程师还是要掌握核心开发流程,会几种做同样事情的API意义不大。


demo写的比较简单,有问题可以评论。


项目github地址


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

看动画学算法之:平衡二叉搜索树AVL Tree

简介 平衡二叉搜索树是一种特殊的二叉搜索树。为什么会有平衡二叉搜索树呢? 考虑一下二叉搜索树的特殊情况,如果一个二叉搜索树所有的节点都是右节点,那么这个二叉搜索树将会退化成为链表。从而导致搜索的时间复杂度变为O(n),其中n是二叉搜索树的节点个数。 而平衡二叉...
继续阅读 »

简介


平衡二叉搜索树是一种特殊的二叉搜索树。为什么会有平衡二叉搜索树呢?


考虑一下二叉搜索树的特殊情况,如果一个二叉搜索树所有的节点都是右节点,那么这个二叉搜索树将会退化成为链表。从而导致搜索的时间复杂度变为O(n),其中n是二叉搜索树的节点个数。


而平衡二叉搜索树正是为了解决这个问题而产生的,它通过限制树的高度,从而将时间复杂度降低为O(logn)。


AVL的特性


在讨论AVL的特性之前,我们先介绍一个概念叫做平衡因子,平衡因子表示的是左子树和右子树的高度差。


如果平衡因子=0,表示这是一个完全平衡二叉树。


如果平衡因子=1,那么这棵树就是平衡二叉树AVL。


也就是是说AVL的平衡因子不能够大于1。


先看一个AVL的例子:



总结一下,AVL首先是一个二叉搜索树,然后又是一个二叉平衡树。


AVL的构建


有了AVL的特性之后,我们看下AVL是怎么构建的。


public class AVLTree {

//根节点
Node root;

class Node {
int data; //节点的数据
int height; //节点的高度
Node left;
Node right;

public Node(int data) {
this.data = data;
left = right = null;
}
}

同样的,AVL也是由各个节点构成的,每个节点拥有data,left和right几个属性。


因为是二叉平衡树,节点是否平衡还跟节点的高度有关,所以我们还需要定义一个height作为节点的高度。


在来两个辅助的方法,一个是获取给定的节点高度:


//获取给定节点的高度
int height(Node node) {
if (node == null)
return 0;
return node.height;
}

和获取平衡因子:


//获取平衡因子
int getBalance(Node node) {
if (node == null)
return 0;
return height(node.left) - height(node.right);
}

AVL的搜索


AVL的搜索和二叉搜索树的搜索方式是一致的。


先看一个直观的例子,怎么在AVL中搜索到7这个节点:



搜索的基本步骤是:



  1. 从根节点15出发,比较根节点和搜索值的大小

  2. 如果搜索值小于节点值,那么递归搜索左侧树

  3. 如果搜索值大于节点值,那么递归搜索右侧树

  4. 如果节点匹配,则直接返回即可。


相应的java代码如下:


//搜索方法,默认从根节点搜索
public Node search(int data){
return search(root,data);
}

//递归搜索节点
private Node search(Node node, int data)
{
// 如果节点匹配,则返回节点
if (node==null || node.data==data)
return node;

// 节点数据大于要搜索的数据,则继续搜索左边节点
if (node.data > data)
return search(node.left, data);

// 如果节点数据小于要搜素的数据,则继续搜索右边节点
return search(node.right, data);
}

AVL的插入


AVL的插入和BST的插入是一样的,不过插入之后有可能会导致树不再平衡,所以我们需要做一个再平衡的步骤。


看一个直观的动画:



插入的逻辑是这样的:



  1. 从根节点出发,比较节点数据和要插入的数据

  2. 如果要插入的数据小于节点数据,则递归左子树插入

  3. 如果要插入的数据大于节点数据,则递归右子树插入

  4. 如果根节点为空,则插入当前数据作为根节点


插入数据之后,我们需要做再平衡。


再平衡的逻辑是这样的:



  1. 从插入的节点向上找出第一个未平衡的节点,这个节点我们记为z

  2. 对z为根节点的子树进行旋转,得到一个平衡树。


根据以z为根节点的树的不同,我们有四种旋转方式:



  • left-left:



如果是left left的树,那么进行一次右旋就够了。


右旋的步骤是怎么样的呢?



  1. 找到z节点的左节点y

  2. 将y作为旋转后的根节点

  3. z作为y的右节点

  4. y的右节点作为z的左节点

  5. 更新z的高度


相应的代码如下:


Node rightRotate(Node node) {
Node x = node.left;
Node y = x.right;

// 右旋
x.right = node;
node.left = y;

// 更新node和x的高度
node.height = max(height(node.left), height(node.right)) + 1;
x.height = max(height(x.left), height(x.right)) + 1;

// 返回新的x节点
return x;
}


  • right-right:


如果是right-right形式的树,需要经过一次左旋:



左旋的步骤正好和右旋的步骤相反:



  1. 找到z节点的右节点y

  2. 将y作为旋转后的根节点

  3. z作为y的左节点

  4. y的左节点作为z的右节点

  5. 更新z的高度


相应的代码如下:


//左旋
Node leftRotate(Node node) {
Node x = node.right;
Node y = x.left;

//左旋操作
x.left = node;
node.right = y;

// 更新node和x的高度
node.height = max(height(node.left), height(node.right)) + 1;
x.height = max(height(x.left), height(x.right)) + 1;

// 返回新的x节点
return x;
}


  • left-right:



如果是left right的情况,需要先进行一次左旋将树转变成left left格式,然后再进行一次右旋,得到最终结果。



  • right-left:



如果是right left格式,需要先进行一次右旋,转换成为right right格式,然后再进行一次左旋即可。


现在问题来了,怎么判断一个树到底是哪种格式呢?我们可以通过获取平衡因子和新插入的数据比较来判断:




  1. 如果balance>1,那么我们在Left Left或者left Right的情况,这时候我们需要比较新插入的data和node.left.data的大小


    如果data < node.left.data,表示是left left的情况,只需要一次右旋即可


    如果data > node.left.data,表示是left right的情况,则需要将node.left进行一次左旋,然后将node进行一次右旋




  2. 如果balance<-1,那么我们在Right Right或者Right Left的情况,这时候我们需要比较新插入的data和node.right.data的大小
    如果data > node.right.data,表示是Right Right的情况,只需要一次左旋即可


    如果data < node.left.data,表示是Right left的情况,则需要将node.right进行一次右旋,然后将node进行一次左旋




插入节点的最终代码如下:


//插入新节点,从root开始
public void insert(int data){
root=insert(root, data);
}

//遍历插入新节点
Node insert(Node node, int data) {

//先按照普通的BST方法插入节点
if (node == null)
return (new Node(data));

if (data < node.data)
node.left = insert(node.left, data);
else if (data > node.data)
node.right = insert(node.right, data);
else
return node;

//更新节点的高度
node.height = max(height(node.left), height(node.right)) + 1;

//判断节点是否平衡
int balance = getBalance(node);

//节点不平衡有四种情况
//1.如果balance>1,那么我们在Left Left或者left Right的情况,这时候我们需要比较新插入的data和node.left.data的大小
//如果data < node.left.data,表示是left left的情况,只需要一次右旋即可
//如果data > node.left.data,表示是left right的情况,则需要将node.left进行一次左旋,然后将node进行一次右旋
//2.如果balance<-1,那么我们在Right Right或者Right Left的情况,这时候我们需要比较新插入的data和node.right.data的大小
//如果data > node.right.data,表示是Right Right的情况,只需要一次左旋即可
//如果data < node.left.data,表示是Right left的情况,则需要将node.right进行一次右旋,然后将node进行一次左旋

//left left
if (balance > 1 && data < node.left.data)
return rightRotate(node);

// Right Right
if (balance < -1 && data > node.right.data)
return leftRotate(node);

// Left Right
if (balance > 1 && data > node.left.data) {
node.left = leftRotate(node.left);
return rightRotate(node);
}

// Right Left
if (balance < -1 && data < node.right.data) {
node.right = rightRotate(node.right);
return leftRotate(node);
}

//返回插入后的节点
return node;
}

AVL的删除


AVL的删除和插入类似。


首先按照普通的BST删除,然后也需要做再平衡。


看一个直观的动画:



删除之后,节点再平衡也有4种情况:




  1. 如果balance>1,那么我们在Left Left或者left Right的情况,这时候我们需要比较左节点的平衡因子


    如果左节点的平衡因子>=0,表示是left left的情况,只需要一次右旋即可


    如果左节点的平衡因<0,表示是left right的情况,则需要将node.left进行一次左旋,然后将node进行一次右旋




  2. 如果balance<-1,那么我们在Right Right或者Right Left的情况,这时候我们需要比较右节点的平衡因子


    如果右节点的平衡因子<=0,表示是Right Right的情况,只需要一次左旋即可


    如果右节点的平衡因子>0,表示是Right left的情况,则需要将node.right进行一次右旋,然后将node进行一次左旋




相应的代码如下:


Node delete(Node node, int data)
{
//Step 1. 普通BST节点删除
// 如果节点为空,直接返回
if (node == null)
return node;

// 如果值小于当前节点,那么继续左节点删除
if (data < node.data)
node.left = delete(node.left, data);

//如果值大于当前节点,那么继续右节点删除
else if (data > node.data)
node.right = delete(node.right, data);

//如果值相同,那么就是要删除的节点
else
{
// 如果是单边节点的情况
if ((node.left == null) || (node.right == null))
{
Node temp = null;
if (temp == node.left)
temp = node.right;
else
temp = node.left;

//没有子节点的情况
if (temp == null)
{
node = null;
}
else // 单边节点的情况
node = temp;
}
else
{ //非单边节点的情况
//拿到右侧节点的最小值
Node temp = minValueNode(node.right);
//将最小值作为当前的节点值
node.data = temp.data;
// 将该值从右侧节点删除
node.right = delete(node.right, temp.data);
}
}

// 如果节点为空,直接返回
if (node == null)
return node;

// step 2: 更新当前节点的高度
node.height = max(height(node.left), height(node.right)) + 1;

// step 3: 获取当前节点的平衡因子
int balance = getBalance(node);

// 如果节点不再平衡,那么有4种情况
//1.如果balance>1,那么我们在Left Left或者left Right的情况,这时候我们需要比较左节点的平衡因子
//如果左节点的平衡因子>=0,表示是left left的情况,只需要一次右旋即可
//如果左节点的平衡因<0,表示是left right的情况,则需要将node.left进行一次左旋,然后将node进行一次右旋
//2.如果balance<-1,那么我们在Right Right或者Right Left的情况,这时候我们需要比较右节点的平衡因子
//如果右节点的平衡因子<=0,表示是Right Right的情况,只需要一次左旋即可
//如果右节点的平衡因子>0,表示是Right left的情况,则需要将node.right进行一次右旋,然后将node进行一次左旋
// Left Left Case
if (balance > 1 && getBalance(node.left) >= 0)
return rightRotate(node);

// Left Right Case
if (balance > 1 && getBalance(node.left) < 0)
{
node.left = leftRotate(node.left);
return rightRotate(node);
}

// Right Right Case
if (balance < -1 && getBalance(node.right) <= 0)
return leftRotate(node);

// Right Left Case
if (balance < -1 && getBalance(node.right) > 0)
{
node.right = rightRotate(node.right);
return leftRotate(node);
}
return node;
}

本文的代码地址:


learn-algorithm


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

密码学系列之:加密货币中的scrypt算法

简介 为了抵御密码破解,科学家们想出了很多种方法,比如对密码进行混淆加盐操作,对密码进行模式变换和组合。但是这些算法逐渐被一些特制的ASIC处理器打败,这些ASIC处理器不做别的,就是专门来破解你的密码或者进行hash运算。 最有名的当然是比特币了,它使用的是...
继续阅读 »

简介


为了抵御密码破解,科学家们想出了很多种方法,比如对密码进行混淆加盐操作,对密码进行模式变换和组合。但是这些算法逐渐被一些特制的ASIC处理器打败,这些ASIC处理器不做别的,就是专门来破解你的密码或者进行hash运算。


最有名的当然是比特币了,它使用的是为人诟病的POW算法,谁的算力高,谁就可以挖矿,这样就导致了大量无意义的矿机的产生,这些矿机什么都不能干,就算是用来算hash值。结果浪费了大量的电力。


普通人更是别想加入这个只有巨头才能拥有的赛道,如果你想用一个普通的PC机来挖矿,那么我估计你挖到矿的几率可能跟被陨石砸中差不多。


为了抵御这种CPU为主的密码加密方式,科学家们发明了很多其他的算法,比如需要占用大量内存的算法,因为内存不像CPU可以疯狂提速,所以限制了很多暴力破解的场景,今天要将的scrypt算法就是其中一种,该算法被应用到很多新的加密货币挖矿体系中,用以表示他们挖矿程序的公平性。


scrypt算法


scrypt是一种密码衍生算法,它是由Colin Percival创建的。使用scrypt算法来生成衍生key,需要用到大量的内存。scrypt算法在2016年作为RFC 7914标准发布。


密码衍生算法主要作用就是根据初始化的主密码来生成系列的衍生密码。这种算法主要是为了抵御暴力破解的攻击。通过增加密码生成的复杂度,同时也增加了暴力破解的难度。


但是和上面提到的原因一样,之前的password-based KDF,比如PBKDF2虽然提高了密码生成的遍历次数,但是它使用了很少的内存空间。所以很容易被简单的ASIC机器破解。scrypt算法就是为了解决这样的问题出现的。


scrypt算法详解


scrypt算法会生成非常大的伪随机数序列,这个随机数序列会被用在后续的key生成过程中,所以一般来说需要一个RAM来进行存储。这就是scrypt算法需要大内存的原因。


接下我们详细分析一下scrypt算法,标准的Scrypt算法需要输入8个参数,如下所示:



  • Passphrase: 要被hash的输入密码

  • Salt: 对密码保护的盐,防止彩虹表攻击

  • CostFactor (N): CPU/memory cost 参数,必须是2的指数(比如: 1024)

  • BlockSizeFactor (r): blocksize 参数

  • ParallelizationFactor (p): 并行参数

  • DesiredKeyLen (dkLen): 输出的衍生的key的长度

  • hLen: hash函数的输出长度

  • MFlen: Mix函数的输出长度


这个函数的输出就是DerivedKey。


首先我们需要生成一个expensiveSalt。首先得到blockSize:


blockSize = 128*BlockSizeFactor 

然后使用PBKDF2生成p个blockSize,将这p个block组合成一个数组:


[B0...Bp−1] = PBKDF2HMAC-SHA256(Passphrase, Salt, 1, blockSize*ParallelizationFactor)

使用ROMix对得到的block进行混合:


   for i ← 0 to p-1 do
Bi ← ROMix(Bi, CostFactor)

将B组合成新的expensiveSalt:


expensiveSalt ← B0∥B1∥B2∥ ... ∥Bp-1

接下来使用PBKDF2和新的salt生成最终的衍生key:


return PBKDF2HMAC-SHA256(Passphrase, expensiveSalt, 1, DesiredKeyLen);

下面是ROMix函数的伪代码:


Function ROMix(Block, Iterations)

Create Iterations copies of X
X ← Block
for i ← 0 to Iterations−1 do
Vi ← X
X ← BlockMix(X)

for i ← 0 to Iterations−1 do
j ← Integerify(X) mod Iterations
X ← BlockMix(X xor Vj)

return X

其中BlockMix的伪代码如下:


Function BlockMix(B):

The block B is r 128-byte chunks (which is equivalent of 2r 64-byte chunks)
r ← Length(B) / 128;

Treat B as an array of 2r 64-byte chunks
[B0...B2r-1] ← B

X ← B2r−1
for i ← 0 to 2r−1 do
X ← Salsa20/8(X xor Bi) // Salsa20/8 hashes from 64-bytes to 64-bytes
Yi ← X

return ← Y0∥Y2∥...∥Y2r−2 ∥ Y1∥Y3∥...∥Y2r−1

scrypt的使用


Scrypt被用在很多新的POW的虚拟货币中,比如Tenebrix、 Litecoin 和 Dogecoin。感兴趣的朋友可以关注一下。


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

java流太太太..............好用了

情景:一个集合对象list,现在想获取这个集合中每个对象的id,并将这些id值存放在另一个集合中,方便我去查询数据。如果是你来实现这个需求,你会用什么方法去实现呢。 我猜会有许多人会选择循环变量这个集合对象,取出id存放在集合里面,代码是这样的: List&...
继续阅读 »
  • 情景:一个集合对象list,现在想获取这个集合中每个对象的id,并将这些id值存放在另一个集合中,方便我去查询数据。如果是你来实现这个需求,你会用什么方法去实现呢。


我猜会有许多人会选择循环变量这个集合对象,取出id存放在集合里面,代码是这样的:


List<Clazz> list = clazzes;
List<Long> ids = new ArrayList();
for (Clazz clazz : list) {
ids.add(clazz.getId());
}

但是!实际上,这个需求可以只用一行代码就可以解决,那是用的什么呢?“流”请看代码:


List<Clazz> list = clazzes;
List<Long> collect = list.stream().map(Clazz::getId).collect(Collectors.toList());

使用流一行代码就可以解决关键看着清晰明了。
上面list.stream().map(Clazz::getId).collect(Collectors.toList())这一行代码用了JAVA8 的两个新特性



  • 双冒号 双冒号就是把方法当作参数传递给需要的方法,或者是传递到stream()中去。在这里就是将其传到stream中去其语法格式 类名::方法名

  • stream 流 通过Collectors 类将流转换成集合元素 流的操作还有许多,可以参考搜索网络


再分享一下 最近根据echart图来查询数据,我在写查询语句筛选条件使用了大量的stream流,发现使用stream流是真的舒服。


我先描述我最近的一个接口:这个接口需要展示四个饼图。而四个饼图是:1.男女教师占比;2.各年龄段占比 3.学历占比,4.职称统计
我想在一个接口中完成这个四个的查询 我的思路有几个:


1.是写多个查询语句 需要一个查询一个(但是各种筛选条件下来 很麻烦)


2.利用视图 可以用来多次调用(但是在查询中会存在in操作 觉得麻烦)


3.利用stream流 根据筛选条件查出符合的教师信息 对每一个操作进行筛选


 通过各种筛选条件查出的结果: teacherList (集合类型)
Long count1 = teacherList.stream().filter(e -> e.getGender().equals(0)).count(); //男生数量
Long count2 = teacherList.stream().filter(e -> e.getGender().equals(1)).count(); //女生数量

通过这样可以直接算出数量 而不用去便利算数据


而更多详细的stream流的信息可以去网上搜索学习


我对stream流的学习还在表面 还有许多灵活的用法我还需要继续学习 欢迎大佬指导!


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

为什么需要Java内存模型?

面试官:今天想跟你聊聊Java内存模型,这块你了解过吗? 候选者:嗯,我简单说下我的理解吧。那我就从为什么要有Java内存模型开始讲起吧 面试官:开始你的表演吧。 候选者:那我先说下背景吧 候选者:1. 现有计算机往往是多核的,每个核心下会有高速缓存。高速缓存...
继续阅读 »

面试官今天想跟你聊聊Java内存模型,这块你了解过吗?


候选者:嗯,我简单说下我的理解吧。那我就从为什么要有Java内存模型开始讲起吧


面试官:开始你的表演吧。


候选者:那我先说下背景吧


候选者:1. 现有计算机往往是多核的,每个核心下会有高速缓存。高速缓存的诞生是由于「CPU与内存(主存)的速度存在差异」,L1和L2缓存一般是「每个核心独占」一份的。


候选者:2. 为了让CPU提高运算效率,处理器可能会对输入的代码进行「乱序执行」,也就是所谓的「指令重排序」


候选者:3. 一次对数值的修改操作往往是非原子性的(比如i++实际上在计算机执行时就会分成多个指令)


候选者:在永远单线程下,上面所讲的均不会存在什么问题,因为单线程意味着无并发。并且在单线程下,编译器/runtime/处理器都必须遵守as-if-serial语义,遵守as-if-serial意味着它们不会对「数据依赖关系的操作」做重排序。



候选者:CPU为了效率,有了高速缓存、有了指令重排序等等,整块架构都变得复杂了。我们写的程序肯定也想要「充分」利用CPU的资源啊!于是乎,我们使用起了多线程


候选者:多线程在意味着并发,并发就意味着我们需要考虑线程安全问题


候选者:1. 缓存数据不一致:多个线程同时修改「共享变量」,CPU核心下的高速缓存是「不共享」的,那多个cache与内存之间的数据同步该怎么做?


候选者:2. CPU指令重排序在多线程下会导致代码在非预期下执行,最终会导致结果存在错误的情况。



候选者:针对于「缓存不一致」问题,CPU也有其解决办法,常被大家所认识的有两种:


候选者:1.使用「总线锁」:某个核心在修改数据的过程中,其他核心均无法修改内存中的数据。(类似于独占内存的概念,只要有CPU在修改,那别的CPU就得等待当前CPU释放)


候选者:2.缓存一致性协议(MESI协议,其实协议有很多,只是举个大家都可能见过的)。MESI拆开英文是(Modified (修改状态)、Exclusive (独占状态)、Share(共享状态)、Invalid(无效状态))


候选者:缓存一致性协议我认为可以理解为「缓存锁」,它针对的是「缓存行」(Cache line) 进行”加锁”,所谓「缓存行」其实就是 高速缓存 存储的最小单位。



面试官:嗯…


候选者:MESI协议的原理大概就是:当每个CPU读取共享变量之前,会先识别数据的「对象状态」(是修改、还是共享、还是独占、还是无效)。


候选者:如果是独占,说明当前CPU将要得到的变量数据是最新的,没有被其他CPU所同时读取


候选者:如果是共享,说明当前CPU将要得到的变量数据还是最新的,有其他的CPU在同时读取,但还没被修改


候选者:如果是修改,说明当前CPU正在修改该变量的值,同时会向其他CPU发送该数据状态为invalid(无效)的通知,得到其他CPU响应后(其他CPU将数据状态从共享(share)变成invalid(无效)),会当前CPU将高速缓存的数据写到主存,并把自己的状态从modify(修改)变成exclusive(独占)


候选者:如果是无效,说明当前数据是被改过了,需要从主存重新读取最新的数据。



候选者:其实MESI协议做的就是判断「对象状态」,根据「对象状态」做不同的策略。关键就在于某个CPU在对数据进行修改时,需要「同步」通知其他CPU,表示这个数据被我修改了,你们不能用了。


候选者:比较于「总线锁」,MESI协议的”锁粒度”更小了,性能那肯定会更高咯


面试官但据我了解,CPU还有优化,你还知道吗?


候选者:嗯,还是了解那么一点点的。


候选者:从前面讲到的,可以发现的是:当CPU修改数据时,需要「同步」告诉其他的CPU,等待其他CPU响应接收到invalid(无效)后,它才能将高速缓存数据写到主存。


候选者:同步,意味着等待,等待意味着什么都干不了。CPU肯定不乐意啊,所以又优化了一把。


候选者:优化思路就是从「同步」变成「异步」。


候选者:在修改时会「同步」告诉其他CPU,而现在则把最新修改的值写到「store buffer」中,并通知其他CPU记得要改状态,随后CPU就直接返回干其他事了。等到收到其它CPU发过来的响应消息,再将数据更新到高速缓存中。


候选者:其他CPU接收到invalid(无效)通知时,也会把接收到的消息放入「invalid queue」中,只要写到「invalid queue」就会直接返回告诉修改数据的CPU已经将状态置为「invalid」



候选者:而异步又会带来新问题:那我现在CPU修改完A值,写到「store buffer」了,CPU就可以干其他事了。那如果该CPU又接收指令需要修改A值,但上一次修改的值还在「store buffer」中呢,没修改至高速缓存呢。


候选者:所以CPU在读取的时候,需要去「store buffer」看看存不存在,存在则直接取,不存在才读主存的数据。【Store Forwarding】


候选者:好了,解决掉第一个异步带来的问题了。(相同的核心对数据进行读写,由于异步,很可能会导致第二次读取的还是旧值,所以首先读「store buffer」。


面试官还有其他?


候选者:那当然啊,那「异步化」会导致相同核心读写共享变量有问题,那当然也会导致「不同」核心读写共享变量有问题啊


候选者:CPU1修改了A值,已把修改后值写到「store buffer」并通知CPU2对该值进行invalid(无效)操作,而CPU2可能还没收到invalid(无效)通知,就去做了其他的操作,导致CPU2读到的还是旧值。


候选者:即便CPU2收到了invalid(无效)通知,但CPU1的值还没写到主存,那CPU2再次向主存读取的时候,还是旧值…


候选者:变量之间很多时候是具有「相关性」(a=1;b=0;b=a),这对于CPU又是无感知的…


候选者:总体而言,由于CPU对「缓存一致性协议」进行的异步优化「store buffer」「invalid queue」,很可能导致后面的指令很可能查不到前面指令的执行结果(各个指令的执行顺序非代码执行顺序),这种现象很多时候被称作「CPU乱序执行」


候选者:为了解决乱序问题(也可以理解为可见性问题,修改完没有及时同步到其他的CPU),又引出了「内存屏障」的概念。



面试官:嗯…


候选者:「内存屏障」其实就是为了解决「异步优化」导致「CPU乱序执行」/「缓存不及时可见」的问题,那怎么解决的呢?嗯,就是把「异步优化」给”禁用“掉(:


候选者:内存屏障可以分为三种类型:写屏障,读屏障以及全能屏障(包含了读写屏障),屏障可以简单理解为:在操作数据的时候,往数据插入一条”特殊的指令”。只要遇到这条指令,那前面的操作都得「完成」。


候选者:那写屏障就可以这样理解:CPU当发现写屏障的指令时,会把该指令「之前」存在于「store Buffer」所有写指令刷入高速缓存。


候选者:通过这种方式就可以让CPU修改的数据可以马上暴露给其他CPU,达到「写操作」可见性的效果。


候选者:那读屏障也是类似的:CPU当发现读屏障的指令时,会把该指令「之前」存在于「invalid queue」所有的指令都处理掉


候选者:通过这种方式就可以确保当前CPU的缓存状态是准确的,达到「读操作」一定是读取最新的效果。



候选者:由于不同CPU架构的缓存体系不一样、缓存一致性协议不一样、重排序的策略不一样、所提供的内存屏障指令也有差异,为了简化Java开发人员的工作。Java封装了一套规范,这套规范就是「Java内存模型」


候选者:再详细地说,「Java内存模型」希望 屏蔽各种硬件和操作系统的访问差异,保证了Java程序在各种平台下对内存的访问都能得到一致效果。目的是解决多线程存在的原子性、可见性(缓存一致性)以及有序性问题。



面试官那要不简单聊聊Java内存模型的规范和内容吧?


候选者:不了,怕一聊就是一个下午,下次吧?


本文总结




  • 并发问题产生的三大根源是「可见性」「有序性」「原子性」




  • 可见性:CPU架构下存在高速缓存,每个核心下的L1/L2高速缓存不共享(不可见)




  • 有序性:主要有三方面可能导致打破



    • 编译器优化导致重排序(编译器可以在不改变单线程程序语义的情况下,可以对代码语句顺序进行调整重新排序)

    • 指令集并行重排序(CPU原生就有可能将指令进行重排)

    • 内存系统重排序(CPU架构下很可能有store buffer /invalid queue 缓冲区,这种「异步」很可能会导致指令重排)




  • 原子性:Java的一条语句往往需要多条 CPU 指令完成(i++),由于操作系统的线程切换很可能导致 i++ 操作未完成,其他线程“中途”操作了共享变量 i ,导致最终结果并非我们所期待的。




  • 在CPU层级下,为了解决「缓存一致性」问题,有相关的“锁”来保证,比如“总线锁”和“缓存锁”。



    • 总线锁是锁总线,对共享变量的修改在相同的时刻只允许一个CPU操作。

    • 缓存锁是锁缓存行(cache line),其中比较出名的是MESI协议,对缓存行标记状态,通过“同步通知”的方式,来实现(缓存行)数据的可见性和有序性

    • 但“同步通知”会影响性能,所以会有内存缓冲区(store buffer/invalid queue)来实现「异步」进而提高CPU的工作效率

    • 引入了内存缓冲区后,又会存在「可见性」和「有序性」的问题,平日大多数情况下是可以享受「异步」带来的好处的,但少数情况下,需要强「可见性」和「有序性」,只能”禁用”缓存的优化。

    • “禁用”缓存优化在CPU层面下有「内存屏障」,读屏障/写屏障/全能屏障,本质上是插入一条”屏障指令”,使得缓冲区(store buffer/invalid queue)在屏障指令之前的操作均已被处理,进而达到 读写 在CPU层面上是可见和有序的。




  • 不同的CPU实现的架构和优化均不一样,Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果


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

手把手教你利用XSS攻击

前两天我收到安全部门的一个通知:高风险XSS攻击漏洞。 我们部门首先确定风险来源,并给出了解决方案。前端部分由我解决,并紧急修复上线。 一:那么什么是XSS攻击呢? 人们经常将跨站脚本攻击(Cross Site Scripting)缩写为CSS,但...
继续阅读 »

前两天我收到安全部门的一个通知:高风险XSS攻击漏洞。


906501ADEAF08AD26A3F225744EA44BB.jpg





我们部门首先确定风险来源,并给出了解决方案。前端部分由我解决,并紧急修复上线。


5C92478016448CBE2BB5650DAEB40955.jpg



一:那么什么是XSS攻击呢?


人们经常将跨站脚本攻击(Cross Site Scripting)缩写为CSS,但这会与层叠样式表(Cascading Style Sheets,CSS)的缩写混淆。因此,有人将跨站脚本攻击缩写为XSS。恶意攻击者往Web页面里插入恶意html代码,当用户浏览该页之时,嵌入其中Web里面的html代码会被执行,从而达到恶意用户的特殊目的。主要指的自己构造XSS跨站漏洞网页或者寻找非目标机以外的有跨站漏洞的网页。XSS是web安全最为常见的攻击方式,在近年来,常居web安全漏洞榜首。


光看这个定义,很多同学一定不理解是什么意思,下面我会模拟XSS攻击,同学们应该就知道怎么回事了。
在模拟XSS攻击之前,我们先来看看XSS攻击的分类。


二:XSS攻击有几种类型呢?


①反射型XSS攻击(非持久性XSS攻击)

②存储型XSS攻击(持久型XSS攻击)

③DOM-based型XSS攻击


三:接下来我们将模拟这几种XSS攻击


第一种:反射型XSS攻击(非持久性XSS攻击)


反射型XSS攻击一般是攻击者通过特定手法,诱使用户去访问一个包含恶意代码的URL,当受害者点击这些专门设计的链接的时候,恶意代码会直接在受害者主机上的浏览器执行。此类XSS攻击通常出现在网站的搜索栏、用户登录口等地方,常用来窃取客户端Cookies或进行钓鱼欺骗。


下面我们来看一个例子:


image.png


这是一个普通的点击事件,当用户点击之后,就执行了js脚本,弹窗了警告。


image.png


你会说,这能代表啥,那如果这段脚本是这样的呢?


image.png


当浏览器执行这段脚本,就盗用了用户的cookie信息,发送到了自己指定的服务器。你想想他接下来会干什么呢?


第二种:存储型XSS攻击(持久型XSS攻击)


攻击者事先将恶意代码上传或者储存到漏洞服务器中,只要受害者浏览包含此恶意代码的页面就会执行恶意代码。这意味着只要访问了这个页面的访客,都有可能会执行这段恶意脚本,因此存储型XSS攻击的危害会更大。此类攻击一般出现在网站留言、评论、博客日志等交互处,恶意脚本存储到客户端或者服务端的数据库中。


增删改查在web管理系统中中很常见,我们找到一个新增功能页面,这以一个富文本输入框为例,输入以下语句,点击保存,再去查看详情,你觉得会发生什么?


image.png


没错,如果是前端的同学或许已经猜到了,h是浏览器的标签,这样传给服务器,服务器再返回给前端,浏览器渲染的时候,会把第二行当成h1标签来渲染,就会出现以下效果,第二行文字被加粗加大了。


image.png


这里我只是输入了普通的文本,而近几年随着互联网的发展,出现了很多h5多媒体标签,那要是我利用它们呢?
不清楚的同学,可自行打开W3cschool网站查看:


image.png


黑客是怎么攻击我们的呢?黑客会自己写一些脚本,来获取我们的cookies敏感等信息,然后他发送到他自己的服务器,当他拿到我们这些信息后,就能绕过前端,直接调后端的接口,比如提现接口,想想是不是很恐怖!!!


image.png


这里我利用一个在线远程网站来模拟XSS攻击。地址如下:
svg.digi.ninja/xss.svg**
目前网站还能访问,同学们可以自己体验一下,如果后期链接失效不可访问了,同学们可以重新找一个,或者自己手写一个脚本,然后伪装成svg上传到自己的服务器。
我们在地址栏输入上面这个地址,来看看实际效果,提示你已经触发了XSS攻击。
image.png


当我们点击确定,出现了一个黑人,哈哈哈,恭喜你,你银行卡里的钱已经全被黑客取走了。这就是黑客得逞后的样子,他得逞后还在嘲讽你。


image.png


接下来,我们利用多媒体标签和这个脚本来攻击我们实际的的网站。


这里记得在地址前面加上//表示跨越,如图:


image.png
当我们点击保存之后,再去查看详情页面发现。


image.png


哦豁,刚刚那个网站的场景在我们的web管理系统里面触发了,点击确定,那个小黑人又来嘲讽你了。


image.png


这脚本在我们的管理系统成功运行,并获取了我们的敏感信息,就可以直接绕过前端,去直接掉我们后端银行卡提现接口了。并且这类脚本由于保存在服务器中,并存着一些公共区域,网站留言、评论、博客日志等交互处,因此存储型XSS攻击的危害会更大。


第三种:DOM-based型XSS攻击


客户端的脚本程序可以动态地检查和修改页面内容,而不依赖于服务器端的数据。例如客户端如从URL中提取数据并在本地执行,如果用户在客户端输入的数据包含了恶意的JavaScript脚本,而这些脚本没有经过适当的过滤或者消毒,那么应用程序就可能受到DOM-based型XSS攻击。


下面我们来看一个例子


image.png


这段代码的意思是点击提交之后,将输入框中的内容渲染到页面。效果如下面两张图。


①在输入框中输入内容


image.png


②点击确定,输入框中的内容渲染到页面


image.png


那如何我们输内容是不是普通文本,而是恶意的脚本呢?


image.png


没错,恶意的脚本在渲染到页面的时候,没有被当成普通的文本,而是被当成脚本执行了。
image.png


总结:XSS就是利用浏览器不能识别是普通的文本还是恶意代码,那么我们要做的就是阻止恶意代码执行,比如前端的提交和渲染,后端接口的请求和返回都要对此类特殊标签做转义和过滤处理,防止他执行脚本,泄露敏感的数据。感兴趣的同学可以根据我上面的步骤,自己去模拟一个XSS攻击,让自己也体验一次当黑客的感觉。



收起阅读 »

产品经理又开始为难我了???我。。。。

最近做项目的时候,就是产品经理给的图总是很大,不压缩。每天要处理这些图片真的很累哇。于是一怒之下写下了这个**「vscode 插件」。「插件核心功能是压缩,然后上传图片」。 压缩的网站其实就是「tinypng」** 这个网站然后图片压缩后,然后再上传到cdn上...
继续阅读 »

最近做项目的时候,就是产品经理给的图总是很大,不压缩。每天要处理这些图片真的很累哇。于是一怒之下写下了这个**「vscode 插件」「插件核心功能是压缩,然后上传图片」。 压缩的网站其实就是「tinypng」** 这个网站然后图片压缩后,然后再上传到cdn上,然后然后这个压缩过的url 直接放到我们的粘贴板上。下面跟着我的步伐一步一步来写实现它。 先看效果图:


演示gif 图


演示gif 图


效率对比


开发这个主要是提高团队开发效率, 绝不是为了炫技。 看图:


image-20211017224316386


image-20211017224316386


需求分析



  1. 可在vscodde的setting中配置上传所需的参数,可以根据个人的需求单独进行配置;

  2. 2.在开发过程中可在编辑器中直接选择图片并上传到阿里云将图片链接填写到光标位置;


中文文档




一个好的文档可以帮助我们更容易的开发:如果英文比较好的同学可以直接看Vscode英文文档,这里api会比较全,可以找到更简洁的方案实现功能; 不过我的话,还是花很久时间找了这篇比较全的中文文档




搭建项目


vscode 插件的开发需要全局安装脚手架:


 npm install -g yo generator-code

安装成功后,直接使用对应命令 「yo code」 来生成一个插件工程:


vscode开始这个页面


vscode开始这个页面


这就开始脚手架页面了,可以选择自己习惯的配置。输入对应的配置 然后 就创建了对应的项目了。


我们看下项目结构:


插件结构


插件结构


插件运行


这时候我们先要去测试下我的这个插件到底是不是能够成功运行。在项目根目录按住F5 然后运行 「vscode extension」 ,这时候会出现一个新的vscode 窗口,但是我这里遇到的一个问题就是这个:


插件


插件


我大概理解了下就是vscode 插件的依赖版本比较低:


目前是:


插件


插件


这上面说的很清楚 vscode扩展指定 与其兼容的 vscode 版本兼容 很显然我这里太高了, 给他降级。然后给他换成1.60.2 完美解决


插件运行——成功演示


ok, 怎么查看自己查看插件有没有成功运行呢, 分为3步



  1. F5 开始调试 —— 产生一个新的调试窗口

  2. 在新的窗口—— command + shift + P 找到 hello word

  3. 点击运行看见弹窗 显示 表示弹窗运行成功


直接看下面的gif 图吧:


gif 演示


gif 演示


插件开发——配置参数


配置插件的属性面板, 这个主要是要在package.json 配置一些参数


配置参数


配置参数


第一个参数我们稍后再讲其实就是对应你注册的自定义command, 下面的配置 其实就是对应插件属性面板一些参数,然后你可以通过vscode 的一些api 可以获得你配置的这些参数


下面我是我配置的参数,你可以会根据插件自定义去调整


"properties": {
    "upload_image.domain": {
      "type": "string",
      "default": "",
      "description": "设置上传域名"
    },
    "upload_image.accessKey": {
      "type": "string",
      "default": "",
      "description": "设置oss上传accessKey"
    },
    "upload_image.secretKey": {
      "type": "string",
      "default": "",
      "description": "设置oss上传secretKey"
    },
    "upload_image.scope": {
      "type": "string",
      "default": "",
      "description": "设置oss上传上传空间"
    },
    "upload_image.gzip": {
      "type": "boolean",
      "default": "true",
      "description": "是否启用图片压缩"
    }
  }

大概就是这几个参数, 然后我们测试下同样打开f5 然后在新窗口 找到设置然后找到扩展, 设置项其实就是对应我们的 上面的**「title」**


压缩图片。


我们看下效果:


效果


效果


插件开发——配置右键菜单


这个功能描述大概就是,你在写的时候突然要上传,直接点击鼠标右键,然后直接选择图片。 对就是这个简单的东西,做东西需要从用户的角度考虑,一定要爽,能省一步是一步。呵呵哈哈哈


这个配置其实就是在 还是在刚才的**「package.json」** 上继续配置:


"menus": {
    "editor/context": [
      {
        "when": "editorFocus",
        "command": "extension.choosedImage",
        "group": "navigation"
      }
    ]
  }

when:就是你鼠标在编辑的时候


command: 就是自定义的事件,我叫他选择图片, 这个其实就是在extension.js 注册的事件名字 tips: 就是对应的事件名称


let texteditor = vscode.commands.registerTextEditorCommand(
  'extension.choosedImage', ... )

这个其实就是在extension .js 注册对应的事件名,这里的**「事件名」** 一定要和 「package.json」 中文件对应不然会出不来的。 给大家演示下:


图片


图片


重启插件 按下f5 然后按下右键就有我们自定义的右键菜单了。但是问题来了我们按住右键 是不是得弹出一个选择图片的框哇,不然怎么上传对吧?


打开图片上传 弹框


强大的vscode支持了内置的api, 支持打开:


const uri = await vscode.window.showOpenDialog({
    canSelectFolders: false,
    canSelectMany: false,
    filters: {
      images: ['png', 'jpg','apng','jpeg','gif','webp'],
    },
  }); 

就是这个 api, 你可以过滤出想要的图片, 在filters 里面,然后呢 吐出给我们的是对应图片的路径。


我们看下效果:图片选择


读取图片数据


其实这个时候我们我们已经有了图片的路径,这时候就要利用 **「node.js」**的fs 模块 去读取 这个图片的数据 buffer ,这个其实为了方便我们将图片上传到oss 上。 代码如下:


const uri = await vscode.window.showOpenDialog({
    canSelectFolders: false,
    canSelectMany: false,
    filters: {
      images: ['png', 'jpg','apng','jpeg','gif','webp'],
    },
  }); 
let imgBuffer =  await fs.readFile(uri[0].path);

这里还涉及到一个就是说: 本地图片的名字 进行加密, 不能上传到oss 各种中文啥的, 显示的我们很不专业哇


所以这里写了一个MD5的转换


function md5Name(name) {
 const index = name.lastIndexOf('.')
 const sourceFileName = name.substring(0, index)
 const suffix = name.substring(index)
 const fileName = md5(sourceFileName + Date.now()) + suffix
 return fileName.toLowerCase()
}

就是将名字搞成花里胡哨的样子,呵呵哈哈哈!


图片压缩


我们得到图片的buffer 数据后其实要对图片要支持压缩, 其实社区里面有很多方案, 这里的话我调研的很多还是决定使用tinfiy, 他也有对应的**「node.js」** 使用的他主要理由主要是看下面这张图:


apng


apng


对的这家伙支持**「apng」, 其他的不是很清楚。 但是他不是免费的一个人一个月免费「500」** 次, 思考了下还行,我们也用不到辣么多次最终还是考虑用它去实现。


安装


安装npm包并添加到您应用的依赖中,您就可以使用Node.js客户端:


npm install --save tinify

认证


您必须提供您的API密钥来使用API。您可以通过注册您的姓名和Email地址来获取API密钥。 请秘密保存API密钥。


const tinify = require("tinify");
tinify.key = "YOUR_API_KEY";

这个的话其实就是你的邮箱去注册一下,然后把你对应的**「key」** 去激活其实就可以了


如图


如图


其实就是下面这个你的key 设置激活就好了


tinify压缩图片


您可以上传任何JPEG或PNG图片到Tinify API来进行压缩。我们将自动检测图片类型并相应的使用TinyPNG或TinyJPG引擎进行优化。 只要上传文件或提供图片URL,就会开始压缩。


您可以选择一个本地文件作为源并写入到另一个文件中。


const source = tinify.fromFile("unoptimized.webp");
source.toFile("optimized.webp");

您还可以从缓冲区(buffer)(二进制字符串)上传图片并获取压缩后图片的数据。


const fs = require("fs");
fs.readFile("unoptimized.jpg", function(err, sourceData) {
  if (err) throw err;
  tinify.fromBuffer(sourceData).toBuffer(function(err, resultData) {
    if (err) throw err;
    // ...
  });
});


代码实现


function compressBuffer(sourceData, key = 'xxx') {
 return new Promise((resolve,reject) => {
  tinify.key = key;
  tinify.fromBuffer(sourceData).toBuffer(function(err, resultData) {
   if(resultData) {
    resolve(resultData)
   }
   if (err) {
    reject(err);
   }
   // ...
  });
 })
}

基于他这个封装了一个promise, 这个**「fromBuffer」** , 到 「toBuffer」 是真的好用。 哈哈哈哈很香,记得一定要设置key 不然promise 直接会报错的, 设置key的方法 就在上面👆🏻, 然后这样其实我们就获得了压缩的图片数据了。


上传图片到oss


这里的话其实有的使用七牛云、 有的使用阿里云。去上传图片,或者是ajax 去上传其实都可以


一般都是要获取token 啥的以及各种签名信息,然后直接上传就好了, 然后呢你就可以获得一张图片地址了。代码我就不展示了, 都是前端应该都懂。这里我说下我遇到的一些问题



  1. 第一个就是js 跑的 是node js 的环境, 如果使用**「FormData」** 这个类的话 他直接会报找不到, 这个方法是 undefined, 还有**「fetch」**, 所以说要去安装对应node js 包 ,我这里使用的是 「cross-fetch」「form-data」


这里我说一下配置的问题就是你在扩展中如何获得的你配置的参数:


"configuration": [
   {
    "title": "压缩图片",
    "properties": {
     "upload_image.secretKey": {
      "type": "string",
      "default": "",
      "description": "设置tinify的ApIKey"
     },
     "upload_image.secretTokenUrl": {
      "type": "string",
      "default": "",
      "description": "设置得物的tokenUrl"
     }
    }
   }
  ]

每个属性前面对应的 upload_image 其实你在扩展中你可以通过:


const imageConfig =  vscode.workspace.getConfiguration('upload_image')

然后你就可以拿到配置了,upload_image 后面的属性 其实对应的就是对象中的key 然后呢你就可以对吧操作了


这个东西还是具体项目, 具体分析,你们自己 可以针对自己的项目去配置


插件开发——图片链接写入编辑器中


通过上面的方法已经可以获得图片上传后的链接,接下来就是将链接写入编辑器中: 首先判断编辑器选择位置,editor.selection中可以获得光标位置、光标选择首尾位置。若光标有选中内容则editBuilder.replace替换选中内容,否则editBuilder.insert在光标位置插入图片链接:


// 将图片链接写入编辑器
function addImageUrlToEditor(url) {
 let editor = vscode.window.activeTextEditor
 if (!editor) {
   return
 }
 const { start, end, active } = editor.selection
 if (start.line === end.line && start.character === end.character) {
   // 在光标位置插入内容
   const activePosition = active
   editor.edit((editBuilder) => {
  editBuilder.insert(activePosition, url)
   })
 } else {
   // 替换内容
   const selection = editor.selection
   editor.edit((editBuilder) => {
  editBuilder.replace(selection, url)
   })
 }
}

插件发布


到这里,其实一整个vscode插件 其实已经可以开发完成了, 然后我们要把他进行打包发布到vscode 的应用市场


创建账号


我是直接github 登录创建, 首先我们进入文档中提到的主页,完成验证登录后创建一个组织。


创建一个组织


创建一个组织


创建发布者


进入下面这个页面 marketplace.visualstudio.com/manage/publ…** 插件发布者, 给大家看下我的:


发布者


发布者


打包发布


首先全局 安装脚手架


npm install -g vsce

然后 cd 到当前插件目录 使用下面命令


$ cd myExtension
$ vsce package
# myExtension.vsix generated

这里的打包会报一些error:


第一个就是插件的package.json 增加发布者


"publisher": "Fly",

如果给插件加图标: 其实在项目中创建一个文件夹: image 然后把图片放进去: 同时也要在package.json 中配置


"icon": "images/dewu.jpeg",

这里可能有⚠️,不过没什么关系,继续跑就完事了


warn


warn


最后的话其实就是要写readme ,不然 不让你发布。


打包上传


一切准备就绪: 命令行 输入


vsce package 

然后项目中就会出现:


照片


照片


然后可以把这个东西拖到页面这个页面


marketplace.visualstudio.com/manage/publ…


上传


上传


然后点击上传就好了,你就可以在vscode 插件商场可以看到自己写的插件了


插件


作者:Fly
链接:https://juejin.cn/post/7020052159999770632

收起阅读 »

TypeScript 想更深入一层?我推荐自定义 transformer 的 compiler api

现在 JS 的很多库都用 typescript 写了,面试也几乎必问 typescript,可能你对 ts 的各种语法和内置高级类型都挺熟悉了,对 ts 的配置、命令行的使用也没啥问题,但总感觉对 ts 的理解没那么深,苦于没有很好的继续提升的方式。这时候我推...
继续阅读 »

现在 JS 的很多库都用 typescript 写了,面试也几乎必问 typescript,可能你对 ts 的各种语法和内置高级类型都挺熟悉了,对 ts 的配置、命令行的使用也没啥问题,但总感觉对 ts 的理解没那么深,苦于没有很好的继续提升的方式。这时候我推荐你研究下 typescript compiler api


typescript 会把 ts 源码 parse 成 AST,然后对 AST 进行各种转换,之后生成 js 代码,在这个过程中会对 AST 进行类型检查。typescript 把这整个流程封装到了 tsc 的命令行工具里,平时我们一般也是通过 tsc 来编译 ts 代码和进行类型检查的。


但其实 ts 除了提供 tsc 的命令行工具外,也暴露了很多 api,同时也能自定义 transformer。这就像 babel 可以编译 esnext、ts 语法到 js,可以写 babel 插件来转换代码,也暴露了各种 api 一样。只不过 typescript transformer 的生态远远比不上 babel 插件,知道的人也比较少。


其实 typescript transformer 能做到一些 babel 插件做不到的事情:

babel 是从 ts、exnext 等转 js,生成的 js 代码里会丢失类型信息,不能生成 ts 代码。

babel 只是转换 ts 代码,并不会进行类型检查。


这两个 babel 插件做不到的事情,通过 typescript transformer 都可以做到。


而且,学会 typescript compiler 的 api 能够帮助你深入 typescript 的编译流程,更好的掌握 typescript。


说了这么多,我们通过一个例子来入门下 typescript transformer 吧。


案例描述


这样一段 ts 代码:


type IsString<T> = T extends string ? 'Yes' : 'No';

type res = IsString<true>;
type res2 = IsString<'aaa'>;

我们希望能把 res 和 res2 的类型的值算出来,通过注释加在后面。


像这样:


type IsString<T> = T extends string ? 'Yes' : 'No';

type res = IsString<true> //No;
type res2 = IsString<'aaa'> //Yes;

这个案例既用到了 transformer api,又用到了类型检查的 api。


下面我们来分析下思路:


思路分析


我们首先要把 ts 代码 parse 成 AST,然后通过 AST 找到要转换的节点,这里是 TypeReference 节点。


可以用 astexplorer.net 看一下:



IsString 是一个 TypeReference,也就是引用了别的类型,然后有 typeName 是 IsString 和类型参数 typeArguments,这里的类型参数就是 true。


是不是很像一个函数调用,这就是高级类型的本质,通过把类型参数传到引用的高级类型里求出最终的类型。


然后我们找到 TypeReference 的节点之后就可以通过 type checker 的 api 来求出类型值,之后创建一个注释节点添加到后面就行了。


转换完 AST,再把它打印成 ts 代码字符串。


思路就是这样,接下来我们具体来实现下,也熟悉下 ts 的 api。


代码实现


parse 代码成 AST 需要先指定要编译的文件和编译参数(createProgram 的 api),然后就可以拿到不同文件的 AST 了(getSourceFile 的 api)。


const ts = require("typescript");

const filename = "./input.ts";
const program = ts.createProgram([filename], {}); // 第二个参数是 compiler options,就是配置文件里的那些

const sourceFile = program.getSourceFile(filename);

这里的 sourceFile 就是 AST 的根结点。


接下来我们要对 AST 进行转换,使用 transform 的 api:


const  { transformed } = ts.transform(sourceFile, [
function (context) {
return function (node) {
return ts.visitNode(node, visit);

function visit(node) {
if (ts.isTypeReferenceNode(node)) {
// ...
}
return ts.visitEachChild(node, visit, context)
}
};
}
]);

transform 要传入遍历的 AST 以及 transfomerFactory。

AST 就是上面 parse 出的 sourceFile。

transformerFactory 可以拿到 context 中的很多 api 来用,它的返回值就是转换函数 transformer。


transformer 参数是 node,返回值是修改后的 node。


要修改 node 就要遍历 node,使用 visit api 和 vistEachChild 的 api,过程中根据类型过滤出 TypeReference 的节点。


之后对 TypeReference 节点做如下转换:


if (ts.isTypeReferenceNode(node)) {
const type = typeChecker.getTypeFromTypeNode(node);

if (type.value){
ts.addSyntheticTrailingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, type.value);
}
}

也就是通过 typeCheker 来拿到 IsString 这个类型的最终类型值,然后通过 addSyntheticTrailingComment 的 api 在后面加一个注释。


其中用到的 typeChecker 是通过 getTypeChecker 的 api 拿到的:


const typeChecker = program.getTypeChecker();

这样就完成了我们的转换 ts AST 的目的。


然后通过 printer 把 AST 打印成 ts 代码。


const printer =ts.createPrinter();

const code = printer.printNode(false, transformed[0], transformed[0]);

console.log(code);

这样就可以了,我们来测试下。


测试之前,全部代码放这里了:


const ts = require("typescript");

const filename = "./input.ts";
const program = ts.createProgram([filename], {}); // 第二个参数是 compiler options,就是配置文件里的那些

const sourceFile = program.getSourceFile(filename);

const typeChecker = program.getTypeChecker();

const { transformed } = ts.transform(sourceFile, [
function (context) {
return function (node) {
return ts.visitNode(node, visit);
function visit(node) {
if (ts.isTypeReferenceNode(node)) {
const type = typeChecker.getTypeFromTypeNode(node);

if (type.value){
ts.addSyntheticTrailingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, type.value);
}
}
return ts.visitEachChild(node, visit, context)
}
};
}
]);

const printer =ts.createPrinter();

const code = printer.printNode(false, transformed[0], transformed[0]);

console.log(code);

测试效果


经测试,我们达到了求出类型添加到后面的注释里的目的



复盘


激不激动,这是我们第一个 ts transformer 的例子,虽然功能比较简单,但是我们也学会了如何对 ts 代码做 parse、 transform,print,以及 type check。


其实 babel 也有 parse、transform、generate 这 3 步,但没有 type check 的过程,也不能打印成 ts 代码。


用 compiler api 的过程中你会发现原来高级类型就是一个 typeReference,需要传入 typeArguments 来求值的,从而对高级类型的理解更深了。


总结


对 typescript 语法和配置比较熟悉后,想更进一步的话,可以学习下 compiler 的 api 来深入 ts 的编译流程。它包括 transfomer、type checker 等 api,可以达到像 babel 插件一样的转换 ts 代码的目的,而且还能做类型检查。


我们通过一个例子来熟悉了下 typescript 的编译流程和 transformer 的写法。


当你需要修改 ts 代码然后生成 ts 代码的时候,babel 是做不到的,它只能生成 js 代码,这时候可以考虑下 typescript 的自定义 transformer。


而且用 typescript compiler api 能够加深你对 ts 编译流程和类型检查的理解。


ts compiler api 尤其是其中的自定义 transformer 是 typescript 更进一层的不错的方向。



收起阅读 »

JavaScript之彻底理解EventLoop

在正式学习Event Loop之前,先需要解决几个问题:什么是同步与异步?JavaScript是一门单线程语言,那如何实现异步?同步任务和异步任务的执行顺序如何?异步任务是否存在优先级? 同步与异步 计算机领域中的同步与异步和我们现实社会的同步和异步正好相反。...
继续阅读 »

在正式学习Event Loop之前,先需要解决几个问题:

什么是同步与异步?

JavaScript是一门单线程语言,那如何实现异步?

同步任务和异步任务的执行顺序如何?

异步任务是否存在优先级?


同步与异步


计算机领域中的同步与异步和我们现实社会的同步和异步正好相反。现实中的同步,就是同时进行,突出的是"同",比如看足球比赛的时候吃着零食,两件事情同时发生;异步就是不同时。但计算机中与现实存在一定差异。


举个栗子


天气冷了,早上刚醒来想喝点热水暖暖身子,但这每天起早贪黑996,晚上回来太累躺下就睡,没开水啊,没法子,只好急急忙忙去烧水。


现在早上太冷了啊,不由得在被窝里面多躺了一会,收拾的时间紧紧巴巴,不能空等水开,于是我便趁此去洗漱,收拾自己。
洗漱完,水开了,喝到暖暖的热水,舒服啊!


舒服完,开启新的996之日,打工人出发!


烧水和洗漱是在同时间进行的,这就是计算机中的异步


计算机中的同步是连续性的动作,上一步未完成前,下一步会发生堵塞,直至上一步完成后,下一步才可以继续执行。例如:只有等水开,才能喝到暖暖的热水。


单线程却可以异步?


JavaScript的确是一门单线程语言,但是浏览器UI是多线程的,异步任务借助浏览器的线程和JavaScript的执行机制实现。
例如,setTimeout就借助浏览器定时器触发线程的计时功能来实现。


浏览器线程



  1. GUI渲染线程

    • 绘制页面,解析HTML、CSS,构建DOM树等

    • 页面的重绘和重排

    • 与JS引擎互斥(JS引擎阻塞页面刷新)



  2. JS引擎线程

    • js脚本代码执行

    • 负责执行准备好的事件,例如定时器计时结束或异步请求成功且正确返回

    • 与GUI渲染线程互斥



  3. 事件触发线程

    • 当对应的事件满足触发条件,将事件添加到js的任务队列末尾

    • 多个事件加入任务队列需要排队等待



  4. 定时器触发线程

    • 负责执行异步的定时器类事件:setTimeout、setInterval等

    • 浏览器定时计时由该线程完成,计时完毕后将事件添加至任务队列队尾



  5. HTTP请求线程

    • 负责异步请求

    • 当监听到异步请求状态变更时,如果存在回调函数,该线程会将回调函数加入到任务队列队尾




同步与异步执行顺序



  1. JavaScript将任务分为同步任务和异步任务,同步任务进入主线中中,异步任务首先到Event Table进行回调函数注册。

  2. 当异步任务的触发条件满足,将回调函数从Event Table压入Event Queue中。

  3. 主线程里面的同步任务执行完毕,系统会去Event Queue中读取异步的回调函数。

  4. 只要主线程空了,就会去Event Queue读取回调函数,这个过程被称为Event Loop


举个栗子




  • setTimeout(cb, 1000),当1000ms后,就将cb压入Event Queue。

  • ajax(请求条件, cb),当http请求发送成功后,cb压入Event Queue。



EventLoop执行流程


Event Loop执行的流程如下:
在这里插入图片描述


下面一起来看一个例子,熟悉一下上述流程。


// 下面代码的打印结果?
// 同步任务 打印 first
console.log("first");
setTimeout(() => {
// 异步任务 压入Event Table 4ms之后cb压入Event Queue
console.log("second");
},0)
// 同步任务 打印last
console.log("last");
// 读取Event Queue 打印second

常见异步任务

DOM事件

AJAX请求

定时器setTimeoutsetlnterval

ES6Promise


异步任务的优先级


下面继续来看一个案例:


setTimeout(() => {
console.log(1);
}, 1000)
new Promise(function(resolve){
console.log(2);
for(var i = 0; i < 10000; i++){
i == 99 && resolve();
}
}).then(function(){
console.log(3)
});
console.log(4)

按照上面的学习:
可以很轻松得出案例的打印结果:2,4,1,3



Promise定义部分为同步任务,回调部分为异步任务



将案例代码在控制台运行,最终返回结果却有些出人意料:


在这里插入图片描述


刚看到如此结果,我的第一感觉是,setTimeout函数1s触发太慢导致它加入Event Queue的时间晚于Promise.then


于是我修改了setTimeout的回调时间为0(浏览器最小触发时间为4ms),但结果仍为发生改变。


那么也就意味着,JavaScript的异步任务是存在优先级的。


宏任务和微任务


JavaScript除了广义上将任务划分为同步任务和异步任务,还对异步任务进行了更精细的划分。异步任务又进一步分为微任务和宏任务。


在这里插入图片描述




  • history traversal任务(h5当中的历史操作)

  • process.nextTicknodejs中的一个异步操作)

  • MutationObserverh5里面增加的,用来监听DOM节点变化的)



宏任务和微任务分别有各自的任务队列Event Queue,即宏任务队列和微任务队列。


Event Loop执行过程


了解到宏任务与微任务过后,我们来学习宏任务与微任务的执行顺序。

代码开始执行,创建一个全局调用栈,script作为宏任务执行

执行过程过同步任务立即执行,异步任务根据异步任务类型分别注册到微任务队列和宏任务队列

同步任务执行完毕,查看微任务队列

若存在微任务,将微任务队列全部执行(包括执行微任务过程中产生的新微任务)

若无微任务,查看宏任务队列,执行第一个宏任务,宏任务执行完毕,查看微任务队列,重复上述操作,直至宏任务队列为空


更新一下Event Loop的执行顺序图:


在这里插入图片描述


总结


在上面学习的基础上,重新分析当前案例:


setTimeout(() => {
console.log(1);
}, 1000)
new Promise(function(resolve){
console.log(2);
for(var i = 0; i < 10000; i++){
i == 99 && resolve();
}
}).then(function(){
console.log(3)
});
console.log(4)

分析过程见下图:
在这里插入图片描述



收起阅读 »

iOS swiftUI 创建 macos图片 1.1

第六节 组合列表视图与过滤器视图创建一个组列过滤器和列表的视图。为过滤器提供新的状态信息,同时绑定地标选择到主视图的父视图上。步骤1 项目中添加一个新的SwiftUI视图,命名为NavigationPrimary.swift。步骤2 声明一...
继续阅读 »

第六节 组合列表视图与过滤器视图

创建一个组列过滤器和列表的视图。为过滤器提供新的状态信息,同时绑定地标选择到主视图的父视图上。

section 6

步骤1 项目中添加一个新的SwiftUI视图,命名为NavigationPrimary.swift

步骤2 声明一个FilterType状态。这个状态会被绑定到过滤器和列表视图中。

section 6 step2

步骤3 添加过滤器视图并绑定FilterType状态。现在预览是失败的,因为过滤器依赖环境中的用户数据,下一步会处理这块儿。

section 6 step3

步骤4 注入用户数据对角到环境中。导航主视图是不直接需要用户数据的,但它的子视图需要。为了可以进行预览,把用户数据作为环境对象注入到导航主视图中。

section 6 step4

步骤5 添加一个绑定到当前选中地标的关系。

步骤6 添加地标列表视图,并把它绑定到选中的地标和过滤器状态上。预览视图中选中第二个选项,因为输入数据是landmarkData[1]作为用户选中的地标输入数据。

section 6 step6

步骤7 限制导航视图的宽度,防止用户让它变的太宽或太窄。

section 6 step7

第七节 复用CircleImage

有时只需要经过稍微修改,就可以跨平台复用一些视图。当构建macOS平台的地标详情页视图时,会复用iOS版地标应用中的CircleImage视图。为了适配macOS平台下的不同布局要求,会添加一个参数来控件阴影半径。

section 7

步骤1 在项目导航栏中选中Landmarks -> Supporting Views并选择CircleImage.swift文件。

section 7 step1

步骤2 把CircleImage.swift文件添加到时MacLandmarks编译目标。

section 7 step2

步骤3 在CircleImage.swift文件中,修改结构体,使用新的阴影半径参数。通过给新参数提供默认值,可以确保iOSwatchOS平台的应用都能与原来保持一致,同时还能在macOS平台上使用。

section 7 step3

第八节 为macOS扩展MapView

类似于CircleImage,这里要在macOS上复用MapView。然而,MapView要做更大的改动,因为MapView使用的是MapKit依赖于UIKit框架。在macOS平台上使用MapKit需要依赖于AppKit框架,所以需要添加编译器指令,让编译过程在macOS目标上进行正确的依赖。

section 8

步骤1 在项目导航器中,选择Landmarks -> Supporting Views,选中MapView.swift文件。

步骤2 把MapView.swift文件添加到MacLandmarks编译目标上。此时Xcode会报错,因为MapView使用了UIViewRepresentable协议,这个协议在macOS SDK里是没有的。下面的步骤中,会使用NSViewRepresentable协议来扩展MapView,让它能在macOS平台上使用。

section 8 step2

步骤3 插入条件编译指令,用来指定特定平台行为。用条件编译的两个条件分支把协议UIViewRepresentableNSViewRepresentable协议的遵循分开。

步骤4 使用条件编译,把在iOS平台上要实现的协议UIViewRepresentable及协议方法makeUIViewupdateUIView放在MapView的扩展实现中,这样就把MapKit的平台依赖性解耦了。

步骤5 添加在macOS平台上的NSViewRepresentable协议遵循。与UIViewRepresentable协议一样,NSViewRepresentable协议的实现也可以使用主类中的方法。

section 8 step5

第九节 构建详情视图

详情视图展示用户选中的地标信息。创建一个类似iOS平台地标应用的地标详情视图,不同之处在于,macOS平台有不同的数据表示方法,这就需要针对macOS平台对详情视图作一些裁剪,复用一些之前调整过的视图。

section 9

步骤1 项目中添加一个新的视图,命名为NavigationDetail.swift,并添加一个landmark属性。初始化详情视图时会使用landmark属性来指定详情页展示的地标信息。

步骤2 在NavigationDetail.swift内部创建一个滚动视图,滚动视图中包含一个VStackVStack中又包含一个HStack,HStack中展示关于地标的图片CircleImageText地标文本信息。通过设置VStack的最大最小宽度,确保展示的内容保持一定的宽度,以适合用户阅读。跨平台复用视图是非常方便的,定制一下CircleImage视图,以满足当前的布局要求。

section 9 step2

步骤3 把输入的图片变为可缩放,并设置图片按视图大小展示,这样可以让CircleImage视图的大小与Text块文本的大小看上去比较匹配。这种修改方法不需要调整CircleImage的内部实现。

section 9 step3

步骤4 调整阴影半径,以匹配更小的图片。这个修改依赖之前对CircleImage视图所作的参数化改造。

section 9 step4

用户使用按钮标记一个地标是否被收藏。为了让这个动作生效,需要访问用户数据中的对应变量。

步骤5 添加用户数据对应的环境对象,并创建一个基于当前选中地标的存储属性landmarkIndex

section 9 step5

步骤6 添加一个按钮,水平方式对齐地标名称,使用星星图标,并在点击时可以切换用户对这个地标的收藏状态。当用户修改地标数据时,在用户数据中查找被修改的地标数据,并用最新的数据更新原来的数据,让数据保持最新状态。

section 9 step6

步骤7 在分割区载下再添加一个地标的信息,对应数据中新增的字段description

section 9 step7

预览视图中标题块会被挤到左边,因为描述内容比较多,把水平方向的宽度撑满了。

步骤8 在详情视图顶部插入地图,调整地图的偏移,让地图和其它内容有一定区域的重叠。地图占满视图全宽,因此会把详情文本挤到预览视图的底部看不到的位置,但它实际上是存在的。

section 9 step8

步骤9 导入MapKit并添加一个Open in Maps的按钮,当按钮被点击时,打开地图应用并定位到地标位置。

section 9 step9

步骤10 把Open in Maps按钮叠放在地图的右下角。

section 9 step10

第十节 把主视图和详情视图组合起来

已经构建了所有的视图元素,把主视图和详情视图组合起来,共同构成ContentView

section 10

步骤1 在MacLandmarks文件夹中,选择ContentView.swift文件。

步骤2 为选中的地标设置对应的属性selectedLandmark,并用@State属性标识为状态属性。使用可选类型定义selectedLandmark,可以不用为它设置默认值。因此,无论是预览视图还是应用初始化时,都可以不需要用户选中地标进行渲染。

步骤3 把用户数据作为环境对象注入。ContentView本身不会直接依赖用户数据,但它的子视图需要访问用户数据。对于预览视图来说,为了正常预览和编译成功,ContentView需要获取用户数据。

section 10 step3

步骤4 在AppDelegate.swift中,为ContentView注入环境对象,这样可以让它的子视图访问到用户数据,应用也可以编译成功。

section 10 step4

步骤5 在ContentView中添加NavigationView作为顶级视图,并设置一个最小尺寸。

section 10 step5

步骤6 添加主视图,展示选中的地标。当用户选中地标列表中的某个地标时,被选中的地标数据就会被赋值到selectedLandmark属性上。

section 10 step6

步骤7 添加详情视图,详情视图不接收可选地标数据, 因些传入详情视图的地标数据需要确保不为空。用户选中地标前,地标详情视图不会渲染,这就是为会预览视图没有任何改变,还是和之前一样。

section 10 step7

步骤8 构建并运行应用。尝试改变过滤器的设置,或者点击详情页中的收藏按钮,观察视图内容的变化。

section 10 step8


收起阅读 »

iOS swiftUI 创建 macos图片 1.0

创建MACOS应用创建了watchOS平台的Landmarks应用后,下一步就是把Landmarks带到MacOS平台上。运用之前学到的所有知识,完成在iOS、watchOS及macOS的全平台应用。在项目工程中添加macOS编译目标,复用在iOS应用中的代码...
继续阅读 »

创建MACOS应用

创建了watchOS平台的Landmarks应用后,下一步就是把Landmarks带到MacOS平台上。运用之前学到的所有知识,完成在iOSwatchOSmacOS的全平台应用。

在项目工程中添加macOS编译目标,复用在iOS应用中的代码和资源,使用SwiftUI创建macOS平台上的列表和详情视图。

按照步骤来编译工程,或者下载工程查看完成后的代码。


第一节 项目中添加macOS编译目标

项目中添加macOS编译目标,Xcode会自动添加一个文件组与一些初始文件,还会生成一个编译运行方案。

section 1

步骤1 选择File->New->Target,模板选择页面出现后,选择macOS选项卡,选中App模板并点击Next。这个模板会添加一个新的macOS编译目标到项目里。

section 1 step1

步骤2 在信息表中,输入MacLandmarks作为项目的名称,设置编程语言为Swift,界面构建方法为SwiftUI,然后点击Finish

section 1 step2

步骤3 设置运行方案为MacLandmarks -> My Mac。这样就可以编译并运行macOS应用。

section 1 step3

这个应用的运行依赖一些特性,这些特性在早期的macOS上是不支持的,所以可能需要改变部署目标。

步骤4 在项目导航器中,选择顶部的Xcode项目,在可用编译运行目标栏中,选择部署目标为10.15

section 1 step4

步骤5 在MacLandmarks文件夹中,选择ContentView.swift文件,打开预览画布,点击恢复(Resume),查看预览。SwiftUI会提供main视图和它的预览视图提供者,就像iOS应用,可以预览应用的主窗口。

section 1 step5

第二节 共享数据和资源

下一步,复用来自iOS应用的模型和资源文件到macOS应用中。

section 2

步骤1 在项目导航器中,打开Landmarks文件夹并选中所有ModelsResources文件夹。landmarkData.json文件包含在教程的启动项目,里面包含了一个新的description字段,这是之前的教程中所没有的内容。

section 2 step1

步骤2 在文件检查器中,为选中的文件设置目标成员关系为MacLandmarks项目。应用编译时需要访问这些共享资源。要使用新的description字段,需要在Landmark结构体中添加一个对应的字段。

section 2 step2

步骤3 打开Landmark.swift文件,添加一个description属性。因为载入的数据遵循Codable协议,只需要确保属性名称和json文件中对应的字段名称一致就可以导入新增的字段数据了。

section 2 step3

第三节 创建行视图

对于使用SwiftUI来构建视图,一般是自底向上的方式,先创建小视图,然后用小视图组合成更大的视图。下面将创建一个列表的行视图。这个行视图包含地标的名称、地理位置、图片以及一个可选的标记,表标这个地标是否被收藏。

section 3

步骤1 在MacLandmarks文件夹下添加一个新的SwiftUI视图,命名为LandmarkRow.swiftiOS应用下也有一个与之同名的文件,重名文件可以通过设置文件的目标成员为适合的App来解决重名的问题。

section 3 step1

步骤2 添加一个landmark属性到LandmarkRow结构体中,并更新预览视图,让新创建的视图可以在预览视图中展示出来。

section 3 step2

步骤3 用VStack包裹的地标图片视图替换占位文本Text视图。

section 3 step3

步骤4 添加一个包裹在VStack中的描述地标的文本视图。

section 3 step4

步骤5 添加一个收藏指示视图,把它和其它现有的内容用一个Spacer分割开。Spacer会把已有的视图推向左边,但是收藏指示视图要放在右边,目前是不可见状态,因为此时还没有图片资源与之对应。

section 3 step5

步骤6 从Resources文件夹下拖动star-filled.pdfstar-empty.pdf文件到macOS应用的Assets.xcassets文件内。

section 3 step6

步骤7 给行视图添加内边距,现在就能够把黄色的收藏标记显示出来了。行视图的内边距可以提高可读性,当把多个行视图集合到列表视图内时,这一点就能很明显的看出来了。

section 3 step7

第四节 把行视图组合进列表视图中

使用上一节创建的行视图,创建一个列表视图,用来展示用户了解的所有地标。当showFavoritesOnly属性为真时,列表中只展示那些被用户收藏的地标。

section 4

步骤1 添加一个名为LandmarkList.swift的新的SwiftUI视图

section 4 step1

步骤2 添加userData属性作为环境注入对象,并更新预览视图。这样就可以让视图访问全局用户地标数据。

section 4 step2

步骤3 创建一个列表,行使用使用landmarkRow定义的类型。

section 4 step3

步骤4 让列表的行可以被用户选中,需要给列表提供一个绑定可选地标成员的关系,并用地标数据自己来标识行。之后会使用这个被选中的地标来展示地标详情页。

section 4 step4

步骤5 根据showFavoritesOnly的状态值以及地标数据是否被用户标记为收藏来决定列表中展示的行的内容。

section 4 step5

第五节 创建过滤器来管理列表的展示内容

因为用户可以标记地标为收藏状态,所以需要提供方式让用户只看到自己收藏过的地标。现在要创建一个过滤器视图,使用Toggle控件给用户提供一个勾选设置,让用户选择是否过滤列表中的非收藏地标,只展示收藏过的地标。

为了让用户可以快速筛选出自己喜欢的地标,这里会添加一下选择器弹出按钮,让用户可以根据地标的不同类别,选择过滤展示自己收藏的地标数据。

section 5

步骤1 添加一个名为Filter.swiftSwiftUI视图。

步骤2 添加userData属性作为环境注入对象,并更新预览视图。

步骤3 用Toggle控件来展示布尔值showFavoritesOnly属性,并给它一个恰当的标签文本。

section 5 step3

当用户选择勾选框时,列表视图也会跟着一起刷新展示,因为它们都绑定了同一上环境注入对象中的值showFavoritesOnly。除此之外,还可以使用地标的类别来定义额外的过滤条件。

步骤4 创建FilterType类型,用来存放地标的类别以及类别对应的名称。确保FilterType遵循Hashable协议,这样FilterType就可以被用在选择器。FilterType中的名称属性可以展示在选择器中,让用户选择过滤哪一种类别的地标。

section 5 step4

步骤5 定义一个all类型用来表示不使用任何地标类别过滤。这个额外的过滤类别要求FilterType有一个特殊的初始构建器,用来处理类别为空的初始化场景。

section 5 step5

遵循CaseIterableIdentifiable协议,让FilterType可以做为ForEach的初始化入参,之后就可以使用这个FilterType类型了。

步骤6 遵循CaseIterable协议,给列表提供所有可能的类别。

section 5 step6

步骤7 遵循Identifiable协议并定义一个id属性。

section 5 step7

步骤8 在Filter.swift中,给Filter视图添加一个选择器,选择器使用一个FilterType的绑定用来记录用户选择,FilterType的名称用来表示用户在选择器菜单中的选项。使用FilterType的绑定关系可以让父视图观察到用户的选择。

section 5 step8

步骤9 返回到列表视图,添加FilterType绑定关系。对于过滤器视图来说,这允许它和父视图共享变量filter

步骤10 更新列表行的创建逻辑,让它包含类别过滤功能。查找那些与用户选中的过滤类别相匹配的地标类别,或者任何用户选择的特色类别地标。

section 5 step10


收起阅读 »

「一探究竟」迷之序列化

事件起因 今天,我需要上线一个非常小但是又非常重要的系统改动,即给核心接口的RPC接口出参增加序列化接口(由下图可见,原实体类未实现序列化)。 编码、测试、代码审核一气呵成,然后收到驳回通知,架构师说实现序列化接口时注意不要忘记配置serialversio...
继续阅读 »

事件起因


今天,我需要上线一个非常小但是又非常重要的系统改动,即给核心接口的RPC接口出参增加序列化接口(由下图可见,原实体类未实现序列化)。


image-20210907025636984.png




编码、测试、代码审核一气呵成,然后收到驳回通知,架构师说实现序列化接口时注意不要忘记配置serialversionUID,还非常贴心的跟我说,IDEA 有一个插件可以自动生成UID,推荐我下载使用(IDEA serialversionUID 插件地址),按照要求调整之后,提测、编译、发布一气呵成,进入今天的午觉模式 (😎)




梦中惊魂


我突然梦见企业微信以每毫秒弹出一个窗口的速度不停的闪烁,周围的人熙熙攘攘,面露忧色,不知道在说些什么...


线上出问题了?和我有什么关系呢(🤪)肯定不是我的问题,不过为了保险起见,还是回忆一下今天都做了什么事吧。


**做了什么?**中台系统上线。**改了什么?**对部分类增加了序列化接口,并增加了serialversionUID... 会导致什么? 接口调用失败...COE...


蹭的一下,我立即从梦中醒来,开始看企业微信,看监控,看接口可用率,看了一切数据正常无误后才逐渐心安。




纳尼?我们不用Java序列化?


回顾自己所了解的关于序列化的知识,打开了各种关于序列化的文章,都给我指向了一个答案:我这种改动铁定会影响序列化,就像下面这样程序会报错。


Exception in thread "main" java.io.InvalidClassException: ser.demo.StuDemo; local class incompatible: stream classdesc serialVersionUID = 6395135316924936201, local class serialVersionUID = 1
at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:616)
at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1843)
at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1713)
at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2000)
at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1535)
at java.io.ObjectInputStream.readObject(ObjectInputStream.java:422)
at ser.demo.App.main(App.java:27)

现在线上没报错,只有一种可能,即:我们的RPC框架并没有使用原生的序列化方式。遇事不决架构师,咨询完毕之后果然和我猜测的一样,还从架构师的口中知晓了另外几种序列化方式,比如:MessagePack、Hessian等等。




常见序列化方式


Java序列化


Java序列化就是将一个对象转化为一个二进制表示的字节数组,通过保存或则转移这些二进制数组达到持久化的目的。


要实现序列化,需要实现java.io.Serializable接口,反序列化是和序列化相反的过程,就是把二进制数组转化为对象的过程,在反序列化的时候,必须有原始类的模板才能将对象还原,其核心方法在于以下两个方法,其中Serializable接口起到的作用是标识是否实现序列化、以及前后对象是否一致等作用。


序列化:java.io.ObjectOutputStream#writeObject0


反序列化:java.io.ObjectInputStream#readObject0


以测试类(StuDemo)为例,序列化后的结果如下:


// 序列化
FileOutputStream fos = new FileOutputStream("C:\\Users\\Kerwin\\Desktop\\log\\object.out");
ObjectOutputStream oos = new ObjectOutputStream(fos);
StuDemo demo = new StuDemo("Kerwin");
oos.writeObject(demo);
oos.flush();
oos.close();

// 结果如下
//  sr ser.demo.StuDemoX??莅 L namet Ljava/lang/String;xpt Kerwin

一堆乱码,但还是能看出来文件内容大致是指向某一个类,有什么字段、对应的值等信息。




MessagePack 序列化


MessagePack(简写Msgpack)是一个高效的二进制序列化格式,它让你像JSON一样可以在各种语言之间交换数据,但是它比JSON更快、更小。


更快更小就代表着性能更高,它是如何实现的?


Msgpack序列化的时候,字段不会标明Key,仅会按照字段的先后顺序存储,类似数组一样,它的编码方式是类型 + 长度 + 内容,如下所示:


image-20210907041011662.png


这种高效的编码方式就带来一些限制,例如:



  • 服务端不可随意在任意位置增加字段,因为客户端不升级的话会导致反序列化失败

  • 不能使用第三方包提供的集合类工具包作为返回值


使用方式如下:


// 其中 StuDemo 类需要增加 @Message 注解标识需要被MessagePack序列化
// MessagePack 序列化方式不需要依赖 Serializable
public static void main(String[] args) throws IOException {
StuDemo demo = new StuDemo("Kerwin");
MessagePack pack = new MessagePack();

// 序列化
byte[] bytes = pack.write(demo);

// 反序列化
StuDemo res = pack.read(bytes, StuDemo.class);
System.out.println(res.getName());



PS:我司的RPC框架目前就使用的MessagePack序列化方式,也是因为此,所以上述调整 serialVersionUID 时没有发生任何问题
同理,受制于底层序列化的限制,我们的新人文档中也明确提到了上述的限制,比如必须在最末尾增加字段等等。





Hessian2 序列化


Hessian是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架,在Hessian的基础之上,Hessian2的性能和压缩率大大提升。


Hessian会把复杂的对象所有属性存储在一个类似Map的结构中进行序列化,所以在父类、子类中存在同名成员变量的情况下,它先序列化子类,然后序列化父类,因此会导致子类同名成员变量的值被父类覆盖等情况。


它有八大核心设计目标,官网



  • 必须自我描述序列化类型,即不需要外部模式或接口定义

  • 必须与语言无关,包括支持脚本语言

  • 必须在一次传递中可读或可写

  • 必须尽可能紧凑(压缩)

  • 必须简单

  • 必须尽可能快

  • 必须支持Unicode字符串

  • 必须支持8位二进制数据

  • 必须支持加密


使用方式如下:


public class StuHessianDemo implements Serializable {

private static final long serialVersionUID = -640696903073930546L;

private String name;

public StuHessianDemo(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

public static void main(String[] args) throws IOException {
StuHessianDemo hessianDemo = new StuHessianDemo("Kerwin");

ByteArrayOutputStream stream = new ByteArrayOutputStream();
HessianOutput hessianOutput = new HessianOutput(stream);
hessianOutput.writeObject(hessianDemo);

ByteArrayInputStream inputStream = new ByteArrayInputStream(stream.toByteArray());

// Hessian的反序列化读取对象
HessianInput hessianInput = new HessianInput(inputStream);
System.out.println(((StuHessianDemo) hessianInput.readObject()).getName());
}

// 结果:Kerwin



选择的依据


由上文我们得知了几种常用的序列化方式,及其优劣,比如MessagePack就是极致的压缩和快,Hessian2则依赖Serializable接口,在保证安全性、自身描述性的基础上,尽可能的追求空间利用率,效率等,而Java序列化方式则一直被诟病,难等大雅之堂,因此在RPC框架选择底层序列化方式时,需要根据自身所需,有所侧重的选择某一项序列化方式。


选择的依据如下,优先级从高到低:


image-20210907051517893.png




一点思考


JSON序列化的地位


其实JSON序列化才是我们最熟知的序列化方式,它本身也不需要实现Serializable接口,为什么大多数RPC框架没有选择用它作为默认的序列化方式呢?


在了解完上文的内容后,我们知道关键还是在性能,效率、空间开销上,因为JSON是一种文本类型序列化框架,采用KEY-VALUE的方式存储数据,它在进行序列化的额外空间开销相对就更大,在反序列化时更不必说,需要依赖反射,因此性能进一步缩水。


然而JSON本身又具备极强的可读性、因此被作为Web中HTTP协议的事实标准。




为什么还要自定义 serialVersionUID


在《Effect Java》中有一句提到:


不管你选择了哪种序列化方式,都要为自己编写的每个可序列化的类声明一个显式的序列版本UID。


为什么架构师会提醒我实现它?为什么书中也会这么说?


serialVersionUID分解下来全称为:serial Version UID,序列版本UID,每一个可序列化的类都有一个long域中显式地指定该编号,如果编码者未定义的话,系统就会对这个类的结构运用一个加密的散列函数(SHA-1),从而在运行时自动产生该标识号,该编号会受类名称、接口名称、公有及受保护的成员变量所影响,一旦有相关改动例如增加一个不重要的公有方法即会影响UID,导致异常发生。


因此这是一个习惯问题,也是为了避免潜在风险。




总结


截止到这里,我们了解了原来之前学习到的Java序列化是那么的不实用(甚至到了被吐槽的地步),也知晓了一些框架使用注意事项底层的秘密(比如MsgPack增加字段),下面是关于序列化的一些小建议:



  1. 无论是否依赖Serializable,接口出参都建议实现序列化接口。

  2. 如果实现了序列化接口,务必自行实现serialVersionUID。

  3. 接口出参对象不宜使用特殊的数据类型(如MsgPack第三方集合等)、过于复杂的结构(继承等),不然会导致很多莫名其妙的问题发生。

  4. 当发生服务端/客户端数据不一致时,第一时间想到是序列化问题,并针对当前序列化方式的特点,仔细排查。

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

kafka!还好我留了一手

本文面试情节虚假,但知识真实,请在家人或者朋友的陪同下仔细观看,防止在观看的过程发呆、走神导致没学到知识。性能篇一位身穿格子衬衣,头发好似一拳超人的中年人走了过来,没错他就是面试官,他手握简历,若有所思,我当时害怕极了,然后他开口:小伙子啊,我们这边是基础架构...
继续阅读 »

本文面试情节虚假,但知识真实,请在家人或者朋友的陪同下仔细观看,防止在观看的过程发呆、走神导致没学到知识。

性能篇

一位身穿格子衬衣,头发好似一拳超人的中年人走了过来,没错他就是面试官,他手握简历,若有所思,我当时害怕极了,然后他开口:小伙子啊,我们这边是基础架构的中间件组,既然你的简历没提到kafka,那我接下来问问你kafka的知识吧。

:好的,kafka平时看的不多,但也还了解一点,不是特别精通所以没写了。(嘿嘿,我是故意没写的,早就知道你要来这一套,kafka其实是俺最精通的东西了)
面试官捋了捋他那稀疏的胡须:那我们开始吧,先说说kafka的Log文件存在什么地方?
:kafka的topic可以分区,所以Log对应了一个命名形式为topic-partition的文件夹,比如对于一个有两个分区的topic来说,它的log分别存在xxx/topic-1和xxx/topic-2中。
面试官:那按照这样的说法,所以log文件的位置应该就是xxx/topic-1/data.log或者xxx/topic-2/data.log?
:不是的,kafka的log会分段,每个分区文件夹下,其实有很多的log段,它们共同组成了log,每个日志段大小是1G,如果一个日志段写完,会自动写入一个新的段。
面试官:为什么要分段?不分段行不行?
:分段可以很好的维护数据,首先不分段,当查找一条数据的时候会很麻烦,就像在一本没有目录的新华字典里找数据一样,如果分了段我们只要知道数据在哪个段中,然后在对应的段中查找即可。同时由于log是持久化磁盘的,磁盘的空间不可能无穷无尽的,当需要清除一些老数据,通过分段机制,只需要删除较老的数据段即可。
面试官:hold on,hold on~,你说分了段后我们只要知道数据在哪个段中即可,那么我们怎么知道数据在哪个段中的?
:easy,easy~,kafka内部维护一个跳跃表,跳跃表的节点就是每个段的段号。这样当查询数据的时候,先根据跳跃表就可以快速定位到目标数据段。
面试官:跳跃表是可以加速访问,但是每个段的段号是咋确定的?
:kakfa的段号其实就是根据偏移量来的,它代表当前段内偏移量最小的那条数据的offset,比如:

 segment1的段号是200,segment2的段号是500,那么segment1就存储了偏移量200-499的消息。
面试官:嗯嗯,那定位到段后,如何定位到具体的消息,直接遍历吗?
:不是直接遍历,直接遍历效率太低,kafka采用稀疏索引的方式来搜索具体的消息,其实每个log分段后,除了log文件外,还有两个索引文件,分别是.index和.timeindex,

 其中.index就是我说的偏移量索引文件,它不会为每条消息创建索引,它会每隔一个范围区间创建索引,所以称之为稀疏索引。

 比如我们要查找消息6的时候,首先加载稀疏文件索引.index到内存中,然后通过二分法定位到消息5,最后通过消息5指向的物理地址接着向下顺序查找,直至找到消息6。
面试官:那稀疏索引的好处是什么?
:稀疏索引是一个折中的方案,既不占用太多空间,也提供了一定的快速检索能力。
面试官:上面你说到了.timeindex文件,它是干嘛的?
:这和kafka清理数据有着密切的关系,kafka默认保留7天内的数据,对于超过7天的数据,会被清理掉,这里的清理逻辑主要根据timeindex时间索引文件里最大的时间来判断的,如果最大时间与当前时间差值超过7天,那么对应的数据段就会被清理掉。
面试官:说到数据清理,除了你说的根据时间来判断的,还有哪些?
:还有根据日志文件大小和日志起始偏移量的方式,对于日志文件大小,如果log文件(所有的数据段总和)大于我们设定的阈值,那么就会从第一个数据段开始清理,直至满足条件。对于日志起始偏移量,如果日志段的起始偏移量小于等于我们设定的阈值,那么对应的数据段就会被清理掉。
面试官:你知道消息合并吗?如果知道说说消息合并带来的好处。
:了解一点,消息合并就是把多条消息合并在一起,然后一次rpc调用发给broker,这样的好处无疑会减少很多网络IO资源,其次消息会有个crc校验,如果不合并每条消息都要crc,合并之后,多条消息可以一起crc一次。
面试官:那合并之后的消息,什么时候会给broker?
:合并的消息会在缓冲区内,如果缓冲区快满了或者一段时间内没有生产消息了,那么就会把消息发给broker。
面试官:那你知道消息压缩吗?
:知道一点,压缩是利用cpu时间来节省带宽成本,压缩可以使数据包的体积变得更小,生产者负责将数据消息压缩,消费者拿到消息后自行解压。
面试官:所有只有生产者可以压缩?
:不是的,broker也可以压缩,当生产者指定的压缩算法和broker指定压缩算法的不一样的时候,broker会先按照生产者的压缩算法解压缩一下,然后再按照自己的压缩算法压缩一下,这是需要注意的,如果出现这种情况会影响整体的吞吐。还有就是新老版本的问题,如果新老版本的压缩算法不兼容,比如broker版本比较老,不支持新的压缩算法,那么也会发生一样的事情。
面试官:我们知道kafka的消息是要写入磁盘的,磁盘IO会不会很慢?
:是这样的,kafka的消息是磁盘顺序读写的,有关测试结果表明,一个由6块7200r/min的RAID-5阵列组成的磁盘簇的线性(顺序)写入速度可以达到 600MB/s,而随机写入速度只有 100KB/s,两者性能相差6000倍。操作系统可以针对线性读写做深层次的优化,比如预读(read-ahead,提前将一个比较大的磁盘块读入内存)和后写(write-behind,将很多小的逻辑写操作合并起来组成一个大的物理写操作)技术。顺序写盘的速度不仅比随机写盘的速度快,而且也比随机写内存的速度快。
面试官:顺序读写是为了解决了缓慢的磁盘问题,那在网络方面还有其他的优化吗?
:有,零拷贝,在没有零拷贝的时候,消息是这样交互的:

  1. 切到内核态:内核把磁盘数据copy到内核缓冲区
  2. 切到用户态:把内核的数据copy到用户程序
  3. 切到内核态:用户数据copy到内核socket缓冲区
  4. socket把数据copy给网卡

可以发现一份数据经过多次copy,最终兜兜转转又回到了内核态,实属浪费。

当有了零拷贝之后:

  1. 磁盘数据copy到内核缓冲
  2. 内核缓冲把描述符和长度发给socket,同时直接把数据发给网卡

可以发现通过零拷贝,减少了两次copy过程,大大降低了开销。

可靠篇

面试官:(关于性能方面的问的差不多了,接下来换换口味吧),kafka的多消费者模型是怎么做到的?
:如果要支持多个消费者同时消费一个topic,最简单的方式就是把topic复制一份,但这无疑会浪费很多空间,尤其在消费者很多的情况下,

于是kafka设计出一套offset机制,即一份数据,不同的消费者根据位置来获取不同的消息即可。

面试官:那你知道消费者的offset存在哪吗?
:很久以前,是存在zookeeper中的,但是offset需要频繁更新,zookeeper又不适合频繁更新,所以后来就把消费者位移存在了一个叫_consumer_offset的topic中,这个topic会在第一个消费者启动的时候自动创建,默认50个分区,3个副本。
面试官:那你说说这个_consumer_offset里面具体存了什么?
:这里其实主要分为key和value,value可以简单的认为就是我们的消费者位移,关于key,这里要细说下,由于每个消费者都属于一个消费者组,并且每个消费者其实消费的是某个topic的分区,所以通过group-topic-partition就可以关联上对应的消费者了,这也就是key的组成。
面试官:那你能介绍下消费者提交位移的方式吗?
:这里分为自动提交和手动提交。自动提交的话,就不需要我们干预,我们消费完消息后,kafka会自动帮我们提交,手动提交的话,就需要我们在消费到消息后自己主动commit。
面试官:自动提交会有什么问题?
:自动提交的策略是consumer默认每隔5秒提交一次位移,如果consumer在接下来的很长时间内都没有数据消费,那么自动提交策略就会一直提交重复的位移,导致_consumer_offset有很多重复的消息。
面试官:那这有什么解决方案吗?
:有,这种情况的核心问题就是可能会有大量的、重复的位移消息占用存储空间,只要把重复的去掉即可,kafka提供一种类似redis的aofrewrite的功能,叫compact策略,compact是由一个logCleaner线程来完成的,它会把重复的、并且较老的消息清除掉。

面试官:那如果consumer自动重启了,位移没来的及提交咋办?
:这个会造成重复消费,一般业务上需要配合做幂等。
面试官:那手动提交能解决这个问题吗?
:不能,如果我们在业务处理完之后手动提交,但是在还没得及提交的情况下,也发生了重启或者其他原因导致提交不上去,在消费者恢复后也会发生重复消费。
面试官:那如果我是先提交,后处理业务逻辑呢?
:这种情况也不能保证100%没问题,如果提交成功,但是处理业务时出错,正常来说,这时希望重新消费这条数据是不行的,因为已经提交了,除非你重置offset。总之无论哪种方案都不能保证100%的完美,我们需要自己根据业务情况做幂等或者根据log来找到丢失的数据。
面试官:消费者提交消费位移时提交的是是当前消费到的最新消息的offset还是offset+1?
:offset+1。
面试官:从生产者的角度谈谈消息不丢失的看法。
:关于消息丢失问题,kafka的生产者提供了3种策略来供使用者选择,每种策略各有利弊,需要结合业务的实际状况来选择。

  1. 第一种就是生产者不关心消息的情况,只负责发,这种模式无疑速度是最快的,吞吐是最好的,但是可能造成大量的数据丢失,比如在borker出现问题的时候,生产者还不停的发,那么到broker恢复期间的数据都将丢失。
  2. 第二种就是生产者需要所有副本都写入成功,不管是Leader副本还是Follower副本,那么当Follower副本越多,吞吐理论就越差,但是这种模式下,消息是最安全的。
  3. 第三种就是生产者只需要收到Leader副本的ack即可,不用关心Follower副本的写入情况,它是个折中的做法,保证了一定的安全性的同时也不会太影响吞吐。

如果你不在意自己的数据丢失问题,追求吞吐,比如像log这种,可以采用第一种,如果你非常在意自己的数据安全性,那么就选第二种。如果你希望吞吐稍微好点,同时数据又能安全些,建议第三种,但是第三种在Follower副本出现的问题的时候对生产者来说是无法感知的。

面试官:那你说说一个Follower副本如何被选举成Leader的?
:在kafka中有这样几个概念:

  • AR:所有副本集合
  • ISR:所有符合选举条件的副本集合
  • OSR:落后太多或者挂掉的副本集合

AR = ISR + OSR,在正常情况下,AR应该是和ISR一样的,但是当某个Follower副本落后太多或者某个Follower副本节点挂掉了,那么它会被移出ISR放入OSR中,kafka的选举也比较简单,就是把ISR中的第一个副本选举成新的Leader节点。比如现在AR=[1,2,3],1挂掉了,那么ISR=[2,3],这时会选举2为新的Leader。

面试官捋了捋自己左边的刘海:你还有什么要问我的吗?
:老师,请问你会组合拳吗?

 面试官:组合拳我不会,但是等会会有很多人组合过来面你。

未完待续...


作者:假装懂编程
链接:https://juejin.cn/post/7018702635544870948
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

【Java字符串】字符串虽简单,但这些你不一定知道

前言: 字符串是程序开发当中,使用最频繁的类型之一,有着与基础类型相同的地位(字符串不属于基本类型),甚至在 JVM(Java 虚拟机)编译的时候会对字符串做特殊的处理,比如拼加操作可能会被 JVM 直接合成为一个最终的字符串,从而到达高效运行的目的。 1 :...
继续阅读 »

前言:


字符串是程序开发当中,使用最频繁的类型之一,有着与基础类型相同的地位(字符串不属于基本类型),甚至在 JVM(Java 虚拟机)编译的时候会对字符串做特殊的处理,比如拼加操作可能会被 JVM 直接合成为一个最终的字符串,从而到达高效运行的目的。


1 :构造方法:


将字节数组或者字符数组转成字符串。


String s1 = new String();//创建了一个空内容的字符串。

String s2 = null;//s2没有任何对象指向,是一个null常量值。

String s3 = "";//s3指向一个具体的字符串对象,只不过这个字符串中没有内容。

//一般在定义字符串时,不用new。

String s4 = new String("abc");

String s5 = "abc"; 一般用此写法

new String(char[]);//将字符数组转成字符串。

new String(char[],offset,count);//将字符数组中的一部分转成字符串。


2 :一般方法:


    按照面向对象的思想:


2.1 获取:


    2.1.1:获取字符串的长度。length() ;


    2.1.2:指定位置的字符。char charAt(int index);


    2.1.3:获取指定字符的位置。如果不存在返回-1,所以可以通过返回值-1来判断某一个字符不存在的情况。           


 int indexOf(int ch);//返回第一次找到的字符角标

 int indexOf(int ch,int fromIndex); //返回从指定位置开始第一次找到的角标

 int indexOf(String str); //返回第一次找到的字符串角标

int indexOf(String str,int fromIndex);

 int lastIndexOf(int ch);

 int lastIndexOf(int ch,int fromIndex);

 int lastIndexOf(String str);
int lastIndexOf(String str,int fromIndex);


    2.1.4:获取子串。


String substring(int start);//从start位开始,到length()-1为止.
String substring(int start,int end);//从start开始到end为止。//包含start位,不包含end位。
substring(0,str.length());//获取整串


2.2 判断:


    2.2.1:字符串中包含指定的字符串吗?


            boolean contains(String substring);


    2.2.2:字符串是否以指定字符串开头啊?


            boolean startsWith(string);


    2.2.3:字符串是否以指定字符串结尾啊?


            boolean endsWith( string);


    2.2.4:判断字符串是否相同


            boolean equals(string);//覆盖了Object中的方法,判断字符串内容是否相同。


    2.2.5:判断字符串内容是否相同,忽略大小写。


            boolean equalsIgnoreCase(string) ;


2.3 转换:


    2.3.1:通过构造函数可以将字符数组或者字节数组转成字符串。


    2.3.2:可以通过字符串中的静态方法,将字符数组转成字符串。


            static String copyValueOf(char[] );

            static String copyValueOf(char[],int offset,int count);

            static String valueOf(char[]);

            static String valueOf(char[],int offset,int count);


    2.3.3:将基本数据类型或者对象转成字符串。


            static String valueOf(char);

            static String valueOf(boolean);

            static String valueOf(double);

            static String valueOf(float);

            static String valueOf(int);

            static String valueOf(long);

            static String valueOf(Object);


    2.3.4:将字符串转成大小写。


            String toLowerCase();


            String toUpperCase();


    2.3.5:将字符串转成数组。


            char[] toCharArray();//转成字符数组。


            byte[] getBytes();//可以加入编码表。转成字节数组。


    2.3.6:将字符串转成字符串数组。切割方法。


            String[] split(分割的规则-字符串);


    2.3.7:将字符串进行内容替换。注意:修改后变成新字符串,并不是将原字符串直接修改。


            String replace(oldChar,newChar);


            String replace(oldstring,newstring);


    2.3.8: String concat(string); //对字符串进行追加。


            String trim();//去除字符串两端的空格


    int compareTo();//如果参数字符串等于此字符串,则返回值 0;如果此字符串按字典顺序小于字符串参数,则返回一个小于 0 的值;如果此字符串按字典顺序大于字符串参数,则返回一个大于 0 的值。


3.StringBuffer 字符串缓冲区:


构造一个其中不带字符的字符串缓冲区,初始容量为 16 个字符。


特点:


1 :可以对字符串内容进行修改。


2 :是一个容器。


3 :是可变长度的。


4 :缓冲区中可以存储任意类型的数据。


5 :最终需要变成字符串。


容器通常具备一些固定的方法:


1 ,添加。


    StringBuffer append(data):在缓冲区中追加数据。追加到尾部。


    StringBuffer insert(index,data):在指定位置插入数据。


2 ,删除。


    StringBuffer delete(start,end);删除从start至end-1范围的元素


    StringBuffer deleteCharAt(index);删除指定位置的元素


//sb.delete(0,sb.length());//清空缓冲区。


3 ,修改。


     StringBuffer replace(start,end,string);将start至end-1替换成string


    void setCharAt(index,char);替换指定位置的字符


    void setLength(len);将原字符串置为指定长度的字符串


4 ,查找。 (查不到返回-1)


    int indexOf(string); 返回指定子字符串在此字符串中第一次出现处的索引。

    int indexOf(string,int fromIndex);从指定位置开始查找字符串

    int lastIndexOf(string); 返回指定子字符串在此字符串中最右边出现处的索引。

    int lastIndexOf(string,int fromIndex); 从指定的索引开始反向搜索


5,获取子串。


    string substring(start); 返回start到结尾的子串


    string substring(start,end); 返回start至end-1的子串


6 ,反转。


    StringBuffer reverse();字符串反转


4. StringBuilder 字符串缓冲区:


JDK1.5 出现StringBuiler; 构造一个其中不带字符的字符串生成器,初始容量为 16 个字符。该类被设计用作 StringBuffer 的一个简易替换,用在字符串缓冲区被单个线程使用的时候(这种情况很普遍)。


方法和StringBuffer一样;


5.StringBuffer 和 StringBuilder 的区别:


StringBuffer 线程安全。


StringBuilder 线程不安全。


单线程操作,使用StringBuilder 效率高。


多线程操作,使用StringBuffer 安全。


        StringBuilder sb = new StringBuilder("abcdefg");

        sb.append("ak");  //abcdefgak

        sb.insert(1,"et");//aetbcdefg

        sb.deleteCharAt(2);//abdefg

        sb.delete(2,4);//abefg

        sb.setLength(4);//abcd

        sb.setCharAt(0,'k');//kbcdefg

        sb.replace(0,2,"hhhh");//hhhhcdefg
//想要使用缓冲区,先要建立对象。

        StringBuffer sb = new StringBuffer();     

        sb.append(12).append("haha");//方法调用链。

        String s = "abc"+4+'q';

        s = new StringBuffer().append("abc").append(4).append('q').toString();


class  Test{

    public static void main(String[] args) {

        String s1 = "java";

        String s2 = "hello";

        method_1(s1,s2);

        System.out.println(s1+"...."+s2); //java....hello

       

        StringBuilder s11 = new StringBuilder("java");

        StringBuilder s22 = new StringBuilder("hello");

        method_2(s11,s22);

        System.out.println(s11+"-----"+s22); //javahello-----hello

    }

    public static void method_1(String s1,String s2){

        s1.replace('a','k');

        s1 = s2;

    }

    public static void method_2(StringBuilder s1,StringBuilder s2){

        s1.append(s2);

        s1 = s2;

    }

}

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

完蛋,公司被一条 update 语句干趴了!

sql
大家好,我是小林。 昨晚在群划水的时候,看到有位读者说了这么一件事。 大概就是,在线上执行一条 update 语句修改数据库数据的时候,where 条件没有带上索引,导致业务直接崩了,被老板教训了一波 这次我们就来看看: 为什么会发生这种的事故? 又该如何...
继续阅读 »

大家好,我是小林。


昨晚在群划水的时候,看到有位读者说了这么一件事。


在这里插入图片描述


大概就是,在线上执行一条 update 语句修改数据库数据的时候,where 条件没有带上索引,导致业务直接崩了,被老板教训了一波


这次我们就来看看:



  • 为什么会发生这种的事故?

  • 又该如何避免这种事故的发生?


说个前提,接下来说的案例都是基于 InnoDB 存储引擎,且事务的隔离级别是可重复读。


为什么会发生这种的事故?


InnoDB 存储引擎的默认事务隔离级别是「可重复读」,但是在这个隔离级别下,在多个事务并发的时候,会出现幻读的问题,所谓的幻读是指在同一事务下,连续执行两次同样的查询语句,第二次的查询语句可能会返回之前不存在的行。


因此 InnoDB 存储引擎自己实现了行锁,通过 next-key 锁(记录锁和间隙锁的组合)来锁住记录本身和记录之间的“间隙”,防止其他事务在这个记录之间插入新的记录,从而避免了幻读现象。


当我们执行 update 语句时,实际上是会对记录加独占锁(X 锁)的,如果其他事务对持有独占锁的记录进行修改时是会被阻塞的。另外,这个锁并不是执行完 update 语句就会释放的,而是会等事务结束时才会释放。


在 InnoDB 事务中,对记录加锁带基本单位是 next-key 锁,但是会因为一些条件会退化成间隙锁,或者记录锁。加锁的位置准确的说,锁是加在索引上的而非行上。


比如,在 update 语句的 where 条件使用了唯一索引,那么 next-key 锁会退化成记录锁,也就是只会给一行记录加锁。


这里举个例子,这里有一张数据库表,其中 id 为主键索引。



假设有两个事务的执行顺序如下:


在这里插入图片描述


可以看到,事务 A 的 update 语句中 where 是等值查询,并且 id 是唯一索引,所以只会对 id = 1 这条记录加锁,因此,事务 B 的更新操作并不会阻塞。


但是,在 update 语句的 where 条件没有使用索引,就会全表扫描,于是就会对所有记录加上 next-key 锁(记录锁 + 间隙锁),相当于把整个表锁住了


假设有两个事务的执行顺序如下:



可以看到,这次事务 B 的 update 语句被阻塞了。


这是因为事务 A的 update 语句中 where 条件没有索引列,所有记录都会被加锁,也就是这条 update 语句产生了 4 个记录锁和 5 个间隙锁,相当于锁住了全表。



因此,当在数据量非常大的数据库表执行 update 语句时,如果没有使用索引,就会给全表的加上 next-key 锁, 那么锁就会持续很长一段时间,直到事务结束,而这期间除了 select ... from 语句,其他语句都会被锁住不能执行,业务会因此停滞,接下来等着你的,就是老板的挨骂。


那 update 语句的 where 带上索引就能避免全表记录加锁了吗?


并不是。


关键还得看这条语句在执行过程种,优化器最终选择的是索引扫描,还是全表扫描,如果走了全表扫描,就会对全表的记录加锁了


又该如何避免这种事故的发生?


我们可以将 MySQL 里的 sql_safe_updates 参数设置为 1,开启安全更新模式。



官方的解释:



If set to 1, MySQL aborts UPDATE or DELETE statements that do not use a key in the WHERE clause or a LIMIT clause. (Specifically, UPDATE statements must have a WHERE clause that uses a key or a LIMIT clause, or both. DELETE statements must have both.) This makes it possible to catch UPDATE or DELETE statements where keys are not used properly and that would probably change or delete a large number of rows. The default value is 0.


大致的意思是,当 sql_safe_updates 设置为 1 时。


update 语句必须满足如下条件之一才能执行成功:



  • 使用 where,并且 where 条件中必须有索引列;

  • 使用 limit;

  • 同时使用 where 和 limit,此时 where 条件中可以没有索引列;


delete 语句必须满足如下条件之一才能执行成功:



  • 使用 where,并且 where 条件中必须有索引列;

  • 同时使用 where 和 limit,此时 where 条件中可以没有索引列;


如果 where 条件带上了索引列,但是优化器最终扫描选择的是全表,而不是索引的话,我们可以使用 force index([index_name]) 可以告诉优化器使用哪个索引,以此避免有几率锁全表带来的隐患。


总结


不要小看一条 update 语句,在生产机上使用不当可能会导致业务停滞,甚至崩溃。


当我们要执行 update 语句的时候,确保 where 条件中带上了索引列,并且在测试机确认该语句是否走的是索引扫描,防止因为扫描全表,而对表中的所有记录加上锁。


我们可以打开 MySQL sql_safe_updates 参数,这样可以预防 update 操作时 where 条件没有带上索引列。


如果发现即使在 where 条件中带上了列索引列,优化器走的还是全标扫描,这时我们就要使用 force index([index_name]) 可以告诉优化器使用哪个索引。


这次就说到这啦,下次要小心点,别再被老板挨骂啦。


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

庆祝神舟十三号发射成功,来一个火箭发射动画

前言 北京时间10月16日0时23分,神舟十三号飞船成功发射,目前三名航天员已经顺利进驻空间站,开始为期6个月的“太空差旅”生活。 国家的航天技术的突飞猛进也让岛上码农很自豪,今天看 Flutter 的动画知识,看到了 AnimatedPositioned ...
继续阅读 »

前言


北京时间10月16日0时23分,神舟十三号飞船成功发射,目前三名航天员已经顺利进驻空间站,开始为期6个月的“太空差旅”生活。
image.png
国家的航天技术的突飞猛进也让岛上码农很自豪,今天看 Flutter 的动画知识,看到了 AnimatedPositioned 这个组件,可以用于控制组件的相对位置移动。结合这个神舟十三号的发射,灵机一动,正好可以使用AnimatedPositioned 这个组件实现火箭发射动画。话不多说,先上效果!
火箭发射动画.gif


效果说明


这里其实是两张图片叠加,一张是背景地球星空的背景图,一张是火箭。火箭在发射过程中有两个变化:



  • 高度越来越高,其实就是相对图片背景图底部的位置越来越大就可以实现;

  • 尺寸越来越小,这个可以控制整个组件的尺寸实现。


然后是动画取消的选择,火箭的速度是越来越快,试了几个 Flutter 自带的曲线,发现 easeInCubic 这个效果挺不错的,开始慢,后面越来越快,和火箭发射的过程是类似的。


AnimatedPositioned介绍


AnimatedPositioned组件的使用方式其实和 AnimatedContainer 类似。只是AnimatedPositionedPositioned 组件的替代。构造方法定义如下:


const AnimatedPositioned({
Key? key,
required this.child,
this.left,
this.top,
this.right,
this.bottom,
this.width,
this.height,
Curve curve = Curves.linear,
required Duration duration,
VoidCallback? onEnd,
})

前面的参数和 Positioned 一样,后面是动画控制参数,这些参数的定义和 AnimatedContainer 的是一样的:



  • curve:动画效果曲线;

  • duration:动画时长;

  • onEnd:动画结束后回调。


我们可以改变 lefttopwidth等参数来实现动画过渡的效果。比如我们的火箭发射,就是修改 bottom (飞行高度控制)和 width (尺寸大小控制)来实现的。


火箭发射动画实现


有了上面的两个分析,火箭发射动画就简单了!完整代码如下:


class RocketLaunch extends StatefulWidget {
RocketLaunch({Key? key}) : super(key: key);

@override
_RocketLaunchState createState() => _RocketLaunchState();
}

class _RocketLaunchState extends State<RocketLaunch> {
var rocketBottom = -80.0;
var rocketWidth = 160.0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('火箭发射'),
brightness: Brightness.dark,
backgroundColor: Colors.black,
),
backgroundColor: Colors.black,
body: Center(
child: Stack(
alignment: Alignment.bottomCenter,
children: [
Image.asset(
'images/earth.jpeg',
height: double.infinity,
fit: BoxFit.fill,
),
AnimatedPositioned(
child: Image.asset(
'images/rocket.png',
fit: BoxFit.fitWidth,
),
bottom: rocketBottom,
width: rocketWidth,
duration: Duration(seconds: 5),
curve: Curves.easeInCubic,
),
],
),
),
floatingActionButton: FloatingActionButton(
child: Text(
'发射',
style: TextStyle(
color: Colors.white,
),
textAlign: TextAlign.center,
),
onPressed: () {
setState(() {
rocketBottom = MediaQuery.of(context).size.height;
rocketWidth = 40.0;
});
},
),
);
}
}

其中一开始设置 bottom 为负值,是为了隐藏火箭的焰火,这样会更有感觉一些。然后就是在点击发射按钮的时候,通过 setState 更改底部距离和火箭尺寸就可以搞定了。


总结


通过神舟十三飞船发射,来一个火箭动画是不是挺有趣?其实这篇主要的知识点还是介绍 AnimatedPositioned 的应用。通过 AnimatedPositioned可以实现很多层叠组件的相对移动变化效果,比如进度条的滑块,滑动开关等。各位 Flutter 玩家也可以利用 AnimatedPositioned 这个组件自己来玩一下好玩的动画效果哦!


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

iOS SwiftUI 框架集成 1.1

第三节 在SwiftUI视图的状态下跟踪页面如果要添加一个自定义的UIPageControl控件,就需要一种方式能够在PageView中跟踪当前展示的页面。这就需要在PageView中声明一个@State属性,并传递一个针对该属性的绑定关系给PageViewC...
继续阅读 »

第三节 在SwiftUI视图的状态下跟踪页面

如果要添加一个自定义的UIPageControl控件,就需要一种方式能够在PageView中跟踪当前展示的页面。这就需要在PageView中声明一个@State属性,并传递一个针对该属性的绑定关系给PageViewController视图,在PageViewController中通过绑定关系更新状态属性,来反映当前展示的页面。

section 3

步骤1 在PageViewController中添加一个绑定属性currentPage。除了使用关键字@Binding声明属性为绑定属性外,还需要更新一下函数setViewControllers(_:direction:animated:),给它传入currentPage绑定属性

section 3 step 1

做到这一步还不能正常运行,继续进行下一步。

步骤2 在PageView中声明@State变量,并在创建PageViewController时把绑定属性传入。注意使用$语法创建一个针对状态变量的绑定关系。

section 3 step 2

步骤3 通过改变PageView视图中的currentPage初始值来测试绑定关系是否正常生效。也可以做一个测试按钮,点击按钮时让第二个页面展示出来

section 3 step 3

步骤4 添加一个TextView控件来展示状态变量currentPage的值,拖动页面切换时观察TextView上的值,目前不会发生变化。因为PageViewController内部没有在切换页面的过程中更新currentPage的值。

section 3 step 4

步骤5 在PageViewController.swift中让coordinator作为UIPageViewController的代理,并添加pageViewController(_:didFinishAnimating:previousViewControllers:transitionCompleted completed: Bool) 方法。因为SwiftUI在页面切换动画完成时会调用这个方法,这样就可以这个方法内部获取当前正在展示的页面的下标,并同时更新绑定属性currentPage的值。

section 3 step 5

步骤6 coordinator除了是UIPageViewController数据源外,再把它赋值为UIPageViewController的代理。由于绑定关系是双向的,所以当页面切换时,PageView视图上的Text就会实时展示当前的页码。

section 3 step 6

section 3 step 6 gif

第四节 添加一个自定义PageControl

我们已经为包裹在UIViewRepresentable视图中的子视图上添加了一个自定义UIPageControl

section 4

步骤1 创建一个新的SwiftUI视图,命名为PageControl.swift,并使用PageControl类型遵循UIViewRepresentable协议。UIViewRepresentableUIViewControllerRepresentable类型有相同的生命周期,在UIKit类型中都有对应的生命周期方法。

section 4 step 1

步骤2 在PageView中用PageControl替换Text,并把VStack换成ZStack。因为总页数和当前页面都已经传入PageControl,所以PageControl已经可以正确的显示。

section 4 step 2

下一步要处理PageControl与用户的交互,让它可以被用户点击任意一边进行页面间的切换。

步骤3 在PageControl中创建一个嵌套类型Coordiantor,添加一个makeCoordinator()方法创建并返回一个coordinator实例。因为UIControl子类(包括UIPageControl)使用Target-Action模式,Coordinator实现一个@objc方法来更新currentPage绑定属性的值。

section 4 step 3

步骤4 把coordinator作为PageControl值改变事件的目标处理器,并指定updateCurrentPage(sender:)方法为处理函数

section 4 step 4

步骤5 现在就可以尝试PageControl的各种交互来切换页面,PageView展示了SwiftUIUIKit视图如何混合使用。

section 4 step 5 gif

检查是否理解

问题1 下面哪个协议可以用来把UIKit中的视图控件器桥接进SwiftUI

  •  UIViewRepresentable
  •  UIHostingController
  •  UIViewControllerRepresentable

问题2 对于UIViewControllerRepresentable类型,下面哪个方法可以为它创建一个代理或数据源?

  •  在makeUIViewController(context:)方法中创建UIViewController实例的地方
  •  在UIViewControllerRepresentable类型的初始化器中
  •  在makeCoordinator()方法中
收起阅读 »

iOS SwiftUI 框架集成 1.0

框架集成混合使用SwiftUI框架和平台相关的其它UI框架(视图和视图控制器)包含章节与UIKit交互创建watchOS应用创建macOS应用与UIKIT交互SwiftUI可以在苹果全平台上无缝兼容现有的UI框架。例如,可以在SwiftUI视图中嵌入UIKit...
继续阅读 »

框架集成

混合使用SwiftUI框架和平台相关的其它UI框架(视图和视图控制器)

framework and integeration

包含章节

与UIKIT交互

SwiftUI可以在苹果全平台上无缝兼容现有的UI框架。例如,可以在SwiftUI视图中嵌入UIKit视图UIKit视图控制器,反过来在UIKit视图UIKit视图控制器中也可以嵌入SwiftUI视图。

本篇教程展示如何把landmark应用的主页混合使用UIPageViewControllerUIPageControl。使用UIPageViewController来展示由SwiftUI视图构成的轮播图,使用状态变量和绑定来操作用户界面数据的更新。

跟着教程一步步走,可以下载工程文件进行实践。


第一节 创建一个用来展示UIPageViewController的SwiftUI视图

为了在SwiftUI视图中展示UIKit视图和UIKit视图控制器,需要创建遵循UIViewRepresentableUIViewControllerRepresentable协议的类型。创建的自定义视图类型,用来创建和配置所要展示的UIKit类型,SwiftUI框架来管理UIKIt类型的生命周期并在适当的时机更新它们。

section 1

步骤1 创建一个新的SwiftUI视图文件,命名为PageViewController.swift,并且声明PageViewController类型遵循UIViewControllerRepresentable。这个页面视图控制器存放一个UIViewController实例数组,数组中的每一个元素代表在地标滚动过程中的一页视图。

section 1 step 1

下一步添加UIViewControllerRepresentable协议的两个实现, 目前因为协议方法没有完成实现,会有报错提示。

步骤2 添加一个makeUIViewController(context:)方法,方法内部以指定的配置创建一个UIPageViewControllerSwiftUI会在准备显示视图时调用一次makeUIViewController(context:)方法创建UIViewController实例,并管理它的生命周期。

section 1 step 2

由于还缺少一个协议方法没有实现,所以目前还是会报错。

步骤3 添加updateUIViewController(_:context:)方法,这个方法里调用setViewControllers(_:direction:animated:)方法展示数组中的第一个视图控制器

section 1 step 3

创建另一个SwiftUI视图展示遵循UIViewControllerRepresentable协议的视图

步骤4 创建一个名为PageView.swift的视图,声明一个PageViewController作为子视图。初始化时使用一个视图数组来初始化,并把每一个视图都嵌入在一个UIHostingController中。UIHostingController是一个UIViewController的子类,用来在UIKit环境中表示一个SwiftUI视图。

section 1 step 4

步骤5 更新预览视图,并传入视图数组,预览视图就会开始工作了

section 1 step 5

步骤6 在继续下面的步骤前,先把PageView的预览视图固定住,以避免在文件切换时不能实现预览到PageView的改变。

section 1 step 6

第二节 创建视图控制器的数据源

短短几个步骤就做了很多事,PageViewController使用UIPageViewController去展示来自SwiftUI内容。现在是时候添加挥动手势进行页面之间的翻动了。

section 2

一个展示UIKit视图控制器的SwiftUI视图可以定义一个Coordinator类型,这个Coordinator类型由SwitUI管理,用来作为视图展示的环境

步骤1 在PageViewControlelr中定义一个嵌套类型CoordiantorSwiftUI管理UIViewController Representable类型的coordinator,并在调用方法时把它作为环境的一部分。

section 2 step 1

步骤2 在PageView Controller中添加另一个方法,创建coordinatorSwiftUI在调用makeUIViewController(context:)前会先调用makeCoordinator()方法,因此在配置视图控制器时是可以访问到coordiantor对象的。可以使用coordinator为实现通用的Cocoa模式,例如:代理模式数据源以及目标-动作

section 2 step 2

步骤3 让Coordinator类型添加UIPageViewControllerDataSource协议遵循,并且实现两个必要方法。这两个必要方法会建立起视图控制器之间的联系,因此可以实现页面之前的前后切换。

section 2 step 3

步骤4 把coordiantor作为UIPageViewController的数据源

section 2 step 4

步骤5 打开实时预览,并测试一下前后页面切换的功能是否正常

swipe landmarks

收起阅读 »

iOS SwiftUI 应用设计与布局 1.2

玩转UI控件在Landmarks应用中,用户可以创建一个简介来描述他们自已的个人情况。为了让用户可以编辑自己的简介,我们需要添加一个编辑模式并设计一个偏好设置界面。这里使用多种通用控件来展示用户的各种数据,并在用户保存他们所做的数据修改时更新地标数据模型。按照...
继续阅读 »

玩转UI控件

Landmarks应用中,用户可以创建一个简介来描述他们自已的个人情况。为了让用户可以编辑自己的简介,我们需要添加一个编辑模式并设计一个偏好设置界面。

这里使用多种通用控件来展示用户的各种数据,并在用户保存他们所做的数据修改时更新地标数据模型。

按照步骤在下面的项目工程中一步步进行实践。


第一节 展示用户简介

Landmarks应用在本地存储了一些配置和用户偏好设置。在用户编辑这些数据前,会被展示在一个没有编辑按钮的概要视图上。

secion 1

步骤1

在项目文件导航栏的Landmarks文件组下面新建一个名为Profile的文件组,并在这个新建的文件组下面添加一个新视图ProfileHost, 这个新视图包含一个TextView,用来展示用户名称。ProfileHost将会展示静态概要信息,同时支持编辑模式

secion 1 step 2

步骤2 用步骤1创建的ProfileHost替换Home.swift中的静态文本Text视图。现在主页中的profile按钮点击时可以调起一个用户简介页面了

secion 1 step 2

步骤3 创建一个新的视图命名为ProfileSummary,它会持有一个Profile实例,并显示一些用户的基本信息。Profile概要视图持有一个Profile对像的原因是,因为它的父视图ProfileHost管理着视图的状态,它不能与Profile进行绑定。

secion 1 step 3

步骤4 更新ProfileHost文件,显示新的概要视图

secion 1 step 4

步骤5 创建一个名为HikeBadge的新视图,这个新视图由Badge视图和一些描述性文字构成。Badge仅仅是一个图形,在HikeBadge视图中的文本与accessibility(label:)属性修改器一起,可以让这个徽章对用户更加清晰。注意frame(width:height:)的两种不同的用法用来配置徽章以不同的缩放尺寸显示。

secion 1 step 5

步骤6 更新ProfileSummary文件,添加几个不同的徽章代表用户得到的不同徽章

secion 1 step 6

步骤7 把HikeView包含在ProfileSummary页面中后,就完成了第一节的实践内容了。

secion 1 step 7

第二节 添加编辑模式

用户需要能够在浏览模式和编辑模式之间进行切换来查看或者修改用户简介的信息。通过在ProfileHost上添加一个Edit Button,然后创建一个用来编辑简介信息的页面。

secion 2

步骤1 添加一个Enviornment视图属性,用来使用\.edit模式。可以使用这个属性来读写当前编辑模式。

secion 2 step 1

步骤2 创建一个编辑按钮,可以切换编辑模式

secion 2 step 2

步骤3 更新UserData类,包含一个Profile实例,即使用户简介页面消失后也可以存储编辑后的信息

secion 2 step 3

步骤4 从环境变量中读取用户简介信息,并把数据传递给ProfileHost视图的控件上进行展示。为了在编辑状态下修改简介信息后确认修改前避免更新全局状态(例如在编辑用户名的过程中),编辑视图在一个备份属性中进行相应的修改操作,确认修改后,才把备份属性同步到全局应用状态中。

secion 2 step 4

步骤5 添加一个条件视图,可以用来显示静态用户简介视图或者是用户简介视图的编辑模式。当前的编辑模式只支持静态文本框的编辑。

secion 2 step 5

第三节 定义简介编辑器

用户简介编辑器包含几个单独的控件用来修改对应简介信息。在简介中,一些项例如徽章是不可以编辑修改的,所以它们不会出现在简介编辑器中。为了保持简介在编辑模式和浏览模式的一致性,需要按照简介页面各项相同的顺序进行添加。

步骤1 创建一个名为ProfileEditor的新视图,并绑定用户简介中的草稿。视图中的第一个控件是TextField,用来更新用户名字段值。创建TextField时要提供一个标签和一个绑定字符串。

secion 3 step 1

步骤2 更新ProfileHost中的条件内容,让它包含条件编辑器并把简单的绑定关系传递给简介编辑器。现在当你点击Edit按钮,简介视图就会变成编辑模式了。

secion 3 step 2

步骤3 添加一个切换开关,用来设置用户是否接收相关地标事件的推送通知。这个Toggle控件打开和关闭正好对应着布尔值的truefalse

secion 3 step 3

步骤4 把一个Picker和一个Text放在VStack结构里,让这个地标可以选择不同季节。

secion 3 step 4

步骤5 最后,在季节图片选择器下方添加一个DatePicker,用来修改地标的目标浏览日期

secion 3 step 5

第四节 延迟编辑传播

在编辑模式时,使用用户简介信息的备份进行修改,当用户确认进行修改后,再用修改的备份信息覆盖真正的用户信息。直到用户退出编辑模式前都不让编辑的备份生效。

secion 4

步骤1 在ProfileHost视图上添加一个取消按钮。不像编辑模式按钮提供的完成按钮,取消按钮不会应用修改后的简介备份信息到实际的简介数据上。

secion 4 step 1

步骤2 当用户点击完成按钮后,使用onAppear(perform:)onDisappear(perform:)来更新或保存用户简介数据。下一次进入编辑模式时,使用上一次的用户简介数据来展示。

secion 4 step 2

检查是否理解

问题1 编辑状态改变时,怎样更新一个视图,例如,当用户编辑了用户简介信息后点击完成按钮的情况下,是怎么更新一个视图的

  • problem 1 answer 1
  • problem 1 answer 2
  • problem 1 answer 3

问题2 什么情况下需要添加一个accessiblity标签,使用accessibility(label:)修改器?

  •  在应用的每一个视图都添加一个accessibility标签
  •  当可以让用户界面元素对用户变的更清晰时,添加一个accessibility标签
  •  只有当你没有给视图清加tag时才可以使用accessibility(label:)

问题3 模态和非模态视图展示有什么差别?

  •  当模态展示一个视图时,源视图设置目标视图的编辑模式
  •  当非模态展示一个视图时,目标视图会盖住源视图并且替代当前的导航栈
  •  当模态展示一个视图时,目标视图盖住源视图并替换当前导航栈
收起阅读 »

iOS SwiftUI 应用设计与布局 1.1

第四节 组合首页Landmarks应用的首页在用户点击查看地标详情前需要先把地标的一些简单信息展示出来。复用之前创建的视图构建具体某一类别地标的行视图步骤1 在CategoryRow.swift文件中,与CategoryRow类型并列,创建一个新的自...
继续阅读 »

第四节 组合首页

Landmarks应用的首页在用户点击查看地标详情前需要先把地标的一些简单信息展示出来。复用之前创建的视图构建具体某一类别地标的行视图

section 4

步骤1 在CategoryRow.swift文件中,与CategoryRow类型并列,创建一个新的自定义视图类型CategoryItem,用这个新的视图类型替换CategoryRow的地标名称Text控件

section 4 step 1

步骤2 在CategoryHome.swift中,添加一个名为FeaturedLandmarks的简单视图,这个视图用来显示地标数据中isFeatured属性为真的那些地标。在之后的教程中,会把FeaturedLandmarks这个视图修改成一个交互式轮播图。目前,这个视图仅仅展示一张缩放和剪裁后的地标图片。

section 4 step 2

步骤3 把视图的边距设置为0,让展示内容可以尽量贴着屏幕边沿

section 4 step 3

第五节

现在所有类别的地标都可以在首页视图中展示出来,用户还需要能够进入应用其它页面的方法。使用页面导航和相关API来实现用户从应用首页到地标详情页、收藏列表页及用户个人中心页的跳转。

section 5

步骤1 在CategoryRow.swift中,把CategoryItem视图包裹在NavigationLink视图中。CategoryItem这时做为跳转按钮的内容,destination指定点击NavigationLink按钮时要跳转的目标视图。

section 5 step 1

section 5 step 1 gif

步骤2 使用renderingMode(_:)foregroundColor(_:)这两个属性修改器来改变地标类别项的导航样式。做为NavigationLink标签的CategoryItem中的文本会使用Environment中的强调颜色,图片可能以模板图片的方式渲染,这些都可以使用属性修改器来调整,达到最佳效果。

section 5 step 2

步骤3 在CategoryHome.swift中,添加一个模态展示的用户信息展示页,点击了用户图标时弹出展示。当状态showProfile被置为true时,展示用户信息页,当showProfile状态置为false时,用户信息页消失。

section 5 step 3

步骤4 在导航条上添加一个按钮,用来切换showProfile状态的值:true或者false

section 5 step 4

section 5 step 4 gif

步骤5 在CategoryHome.swift中添加一个跳转链接,点击时跳转到全部地标的筛选页面。

section 5 step 5

section 5 step 5 gif

步骤6 把LandmarkList.swift中的把包裹地标列表视图的NavigationView移动到对应的预览视图中。因为在应用中,LandmarkList总是会被展示在CategoryHome.swift定义的导航视图中。

section 5 step 6

检查是否理解

问题1 对于Landmarks这个应用来说,哪一个视图是它的根视图?

  •  SceneDelegate
  •  Landmarks
  •  CategoryHome

问题2 CategoryHome这个视图是如何与应用的其它视图联动起来的

  •  在不同地标之间复用图片资源
  •  与其它视图使用一致的命名规范和属性修改器语法
  •  使用导航结构把地标应用中所有视图连接在一起


收起阅读 »

iOS SwiftUI 应用设计与布局 1.0

应用设计与布局深入了解使用SwiftUI创建的复杂的用户界面的结构和布局包含章节组合复杂用户界面组合复杂用户界面Landmarks应用的首页是一个纵向滚动的地标类别列表,每一个类别内部是一个横向滑动列表。随后将构建应用的页面导航,这个过程中可以学习到如果组合各...
继续阅读 »

应用设计与布局

深入了解使用SwiftUI创建的复杂的用户界面的结构和布局

app design and layout

包含章节

组合复杂用户界面

Landmarks应用的首页是一个纵向滚动的地标类别列表,每一个类别内部是一个横向滑动列表。随后将构建应用的页面导航,这个过程中可以学习到如果组合各种视图,并让它们适配不同的设备尺寸和设备方向。


第一节 添加一个首页视图

已经创建了所有在Landmarks应用中需要的视图,现在给应用创建一个首页视图,把之前创建的视图整合起来。首页不仅仅包含之前创建的视图,它还提供页面间导航的方式,同时也可以展示各种地标信息。

section 1

步骤1 创建一个名为CategoryHome.swift的自定义视图文件

section 1 step 1

步骤2 把应用的场景代理(scene delegate)的根视图从之前的地标列表视图更改为新创建的首页视图。现在应用启动后的每一个页面就是首页了,所以还需要添加从首页导航跳转到其它页面的方法。

section 1 step 2

步骤3 添加NavigationView,这个NavigationView将会容纳Landmarks应用中其它不同的视图。配合使用NavigationViewNavigationLink及相关的修改器,就可以构建出应用的页面间导航结构

section 1 step 3

步骤4 设置导航栏标题为Featured

section 1 step 4

第二节 创建地标类别列表

Landmarks应用为了便于用户浏览各种类别的地标,将地标按类别竖向排列形成列表视图,对于每一个类别内的具体地标,又把它们按照水平方向排列,形成横向列表。组合使用垂直栈(vertical statck)和水平栈(horizontal stack)并给列表添加滚动

section 2

步骤1 使用Dictionary结构体的初始化方法init(grouping:by:),把地标数据的类别属性category传入作为分组依据,可以把地标数据按类别分组。工程文件中已经为每一个地标样本数据预定义了类别。

section 2 step 1

步骤2 使用List显示地标数据的类别。Landmark.Category是枚举类型,它的值标识列表中每一种类别,可以保证类别不会有重复定义

section 2 step 1

第三节 添加针对单个类别的地标行列表

Landmarks应用对每个类别下的地标采用横向滑动的行进行展示。添加一个新的视图类型用来表示这样一个地标行,然后使用这个新创建的行类型具体展示某一具体类型上的所有地标。

section 3

步骤1 定义一个新的视图类型,用来展示地标类别行的内容。新建行视图需要存放地标具体类别的展示数据

section 3 step 1

步骤2 更新CategoryHome.swift的代码,把地标类别信息传给新建的行视图类型

section 3 step 2

步骤3 在CategoryRow.swift中使用一个HStack展示类别下的地标内容

section 3 step 3

步骤4 为行内容指定一个高度,并把行内容嵌入到ScrollView中,以支持横向滑动。预览视图时,可以多增加几个地标数据,用来查看列表的滑动是否正常。

section 3 step 4


收起阅读 »

复习Activity各种场景的生命周期

Activity是Android组件中最基本也是最为常见用的四大组件之一,也是我们在开发过程之中接触最多的组件,所以了解Activity的生命周期,并正确的理解与应用,是必不可少的。之前看到很多错误文章,今天特意自己亲自测试一遍,下面就来介绍一下Activit...
继续阅读 »

Activity是Android组件中最基本也是最为常见用的四大组件之一,也是我们在开发过程之中接触最多的组件,所以了解Activity的生命周期,并正确的理解与应用,是必不可少的。之前看到很多错误文章,今天特意自己亲自测试一遍,下面就来介绍一下Activity生命周期。

一.官网生命周期

在这里插入图片描述 上面图概括了android生命周期的各个环节,描述了activity从生成到销毁的过程。

  • onCreate:该方法时整个Activity生命周期的第一个方法,它表示Activity正在被创建,日常开发过程中,相信大家接触最多的就是这个方法,在这个方法中我们常常做一些初始化的工作(如加载布局资源、初始化数据等操作),此时Activity不可见。

  • onStart:顾名思义,该方法代表Activity正在被启动,此时Activity已经可见,但是无法响应用户的交互动作。

  • onResume:该方法表示Activity已经经过前面步骤创建完成,此时Activity已经可见并且已经来前台,用户能够看到界面并且能够进行交互操作并获得响应。

  • onPause:onPause方法表示Activity正在暂停,正常情况下,onStop紧接着就会被调用。在特殊情况下,如果这个时候用户快速地再回到当前的Activity,那么onResume会被调用(希望你手速够快,很难出现)。一般来说,在这个生命周期状态下,可以做一些存储数据、停止动画的工作,但是该方法不能执行耗时操作,这是由于启动新的Activity而唤醒的该状态,那会影响到新Activity的启动,原因是新的Activity的onResume方法是在老Activity的onPause执行完后才执行的(具体原因可以看下系统启动Activity的机制)。

  • onStop:表示Activity即将停止,可以做一些稍微重量级的资源回收工作等,同样也不能太耗时。

  • onDestroy:表示Activity即将被销毁,这是Activity生命周期的最后一个回调,我们可以做一些回收工作和最终的资源释放(如Service、BroadReceiver、Map等)。

  • onRestart:表示Activity正在重新启动,一般情况下,在当前Activity从不可见重新变为可见的状态时onRestart就会被调用。这种情形一般是由于用户的行为所导致的,比如用户按下Home键切换到桌面或者打开了一个新的Activity(这时当前Activity会暂停,也就是onPause和onStop被执行),接着用户有回到了这个Activity,就会出现这种情况。

二.常见一些情景

1.直接启动一个MainActivity

MainActivity: onCreate
MainActivity: onStart
MainActivity: onResume

2.在MainActivity中启动TwoActivity

此时MainActivity中先执行onPause方法,然后 TwoActivity执行onCreate → onStart → onResume,TwoActivity完全显示后才会执行MainActivity的 onStop方法

MainActivity: onPause
TwoActivity: onCreate
TwoActivity: onStart
TwoActivity: onResume
MainActivity: onStop

3.TwoActivity中点击back返回

此时TwoActivity 先执行 onPause方法,MainActivity执行onRestart→ onStart → onResume,最后TwoActivity 执行onStop→ onDestroy

TwoActivity: onPause
MainActivity: onRestart
MainActivity: onStart
MainActivity: onResume
TwoActivity: onStop
TwoActivity: onDestroy

3.MainActivity中点击home或者锁屏按键

MainActivity: onPause
MainActivity: onStop

4.重新进入MainActivity

MainActivity: onRestart
MainActivity: onStart
MainActivity: onResume

5.MainActivity 点击弹窗dialog

  • 注意:之前被很多人误导有生命周期变化,真实测试后发现没变化
**** 无生命周期变化 ****  
**** 无生命周期变化 ****
**** 无生命周期变化 ****

6.MainActivity 点击跳转透明Activity

设置透明activity的方法可以通过style

  <style name="MyTransparent" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowAnimationStyle">@android:style/Animation.Translucent</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
</style>

发现跳转到透明TransActivity后MainActivity并没有执行onstop方法

MainActivity: onPause
TransActivity: onCreate
TransActivity: onStart
TransActivity: onResume

7.透明Activity点击返回

MainActivity直接就执行了onResume方法

TransActivity: onPause
MainActivity: onResume
TransActivity: onStop
TransActivity: onDestroy

8.透明Activity中点击home或者锁屏按键

发现TransActivity和MainActivity都调用了onStop方法

TransActivity: onPause
TransActivity: onStop
MainActivity: onStop

9.重新进入透明Activity

发现MainActivity也执行了onRestart → onStart

MainActivity: onRestart
MainActivity: onStart
TransActivity: onRestart
TransActivity: onStart
TransActivity: onResume

三.Activity中onSaveInstanceState()和onRestoreInstanceState()

1.onSaveInstanceState(Bundle outState):

onSaveInstanceState函数在Activity生命周期中执行。 outState 参数作用 : 数据保存 : Activity 声明周期结束的时候, 需要保存 Activity 状态的时候, 会将要保存的数据使用键值对的形式 保存在 Bundle 对象中;

调用时机 : Activity 被销毁的时候调用, 也可能没有销毁就调用了; 按下Home键 : Activity 进入了后台, 此时会调用该方法; 按下电源键 : 屏幕关闭, Activity 进入后台; 启动其它 Activity : Activity 被压入了任务栈的栈底; 横竖屏切换 : 会销毁当前 Activity 并重新创建;

onSaveInstanceState方法调用注意事项 : 用户主动销毁不会调用 : 当用户点击回退键 或者 调用了 finish() 方法, 不会调用该方法; 调用时机不固定 : 该方法一定是在 onStop() 方法之前调用, 但是不确定是在 onPause() 方法之前 还是 之后调用; 布局中组件状态存储 : 每个组件都 实现了 onSaveInstance() 方法, 在调用函数的时候, 会自动保存组件的状态, 注意, 只有有 id 的组件才会保存; 关于默认的 super.onSaveInstanceState(outState) : 该默认的方法是实现 组件状态保存的;

MainActivity按电源键进入后台的生命周期如下:

MainActivity: onPause
MainActivity: onStop
MainActivity: onSaveInstanceState

2.onRestoreInstanceState(Bundle outState):

方法回调时机 : 在 Activity 被系统销毁之后 恢复 Activity 时被调用, 只有销毁了之后重建的时候才调用, 如果内存充足, 系统没有销毁这个 Activity, 就不需要调用; – Bundle 对象传递 : 该方法保存的 Bundle 对象在 Activity 恢复的时候也会通过参数传递到 onCreate() 方法中;

四.activity的进程优先级。

前台进程>可见进程>service进程>后台进程>空进程

前台进程:

 1.当前进程activity正在与用户进行交互。
2.当前进程service正在与activity进行交互或者当前service调用了startForground()属于前台进程或者当前service正在执行生命周期(onCreate(),onStart(),onDestory())
3.进程持有一个BroadcostReceiver,这个BroadcostReceiver正在执行onReceive()方法

可见进程:

 1. 进程持有一个activity,这个activity不再前台,处于onPause()状态下,当前覆盖的activity是以dialog形式存在的。
2. 进程有一个service,这个service和一个可见的Activity进行绑定。

service进程:

 1.当前开启startSerice()启动一个service服务就可以认为进程是一个服务进程。

后台进程:

  activity的onStop()被调用,但是onDestroy()没有调用的状态。该进程属于后台进程。

空进程

 改进程没有任何运行的数据了,且保留在内存空间,并没有被系统killed,属于空进程。该进程很容易被杀死。

收起阅读 »

这次,我想把内存泄漏讲明白

检测内存是否泄漏非常简单,只要在任意位置调用 Debug.dumpHprofData(file) 即可,通过拿到 hprof 文件进行分析就可以知道哪里产生了泄漏,但 dump 的过程会 suspend 所有的 java 线程,导致用户界面无响应,所以又不能随...
继续阅读 »


检测内存是否泄漏非常简单,只要在任意位置调用 Debug.dumpHprofData(file) 即可,通过拿到 hprof 文件进行分析就可以知道哪里产生了泄漏,但 dump 的过程会 suspend 所有的 java 线程,导致用户界面无响应,所以又不能随意 dump。为了能找到合理的 dump 时机,leakCanary 就采用预判的方式,在 onDestroy 中先检测一下当前 Activity 是否存在泄漏的风险,如果有这种情况,就开始 dump。需要注意的是,在 onDestroy 做检测仅仅只是预判,一种时机,并不能断定真的发生了泄漏,真正的泄漏需要通过分析 hprof 文件才能知晓。

hprof 是由 JVM TI Agent HPROF 生成的一种二进制文件,文件格式可以查看 Binary Dump Format

一、如何预判内存泄漏

  • 主动检测法
  • 阈值检测法

1、主动检测法

  • Activity 的检测预判
  • Service 的检测预判
  • Bitmap 大图的检测预判

1、Activity 的检测预判 LeakCanary 中对 Activity 的预判是在 onDestroy 生命周期中通过弱引用队列来持有当前 Activity 引用,如果在主动触发 gc 之后,泄漏对象集合中仍然能找到该引用实例,则说明发生了内存泄漏,就开始 dump

2、Service 的检测预判 LeakCanary 对 Service 的内存泄漏检测时机,是 hook 监听 ActivityThread 的 stopService,然后记录这个 binder 到弱引用集合中,然后代理 AMS 的 serviceDoneExecuting 方法,通过 binder 在弱引用集合中去移除,移除成功的话,说明发生了内存泄漏,就开始 dump

3、Bitmap 大图检测预判 Bitmap 不像 Activity、Service 这种,能够通过生命周期主动监测当前是否有内存泄漏的可能,他一般是在 Activity、Service 发生泄漏 dump 的时候,顺便检测一下 Bitmap 。在 Koom 中,Bitmap 大图检测是分析 hprof 中是否有超过 Bitmap 设置的阈值 size (width * height)

2、阈值检测法

阈值检测法的代表框架是 Koom,他抛弃了 LeakCanary 的实时检测性,采用定时轮训检测当前内存是否在不断累加,增长达到一定次数(可自己配置)时会进行 dump hprof,这种方式会牺牲一定的时效性,但对于应用到线上的 Koom 的框架,他完全不需要这么高的时效性

二、如何分析内存泄漏

分析工具代表:

  • MAT
  • Android Studio
  • HaHa
    • Matrix
    • LeakCanary 1.x
  • shark
    • Liko
    • Koom
    • LeakCanary 2.x

1、MAT

MAT 工具下载可点击链接 ,Android 生成的 dump 需要做一下转换才能被 MAT 识别,转换指令:

hprof-conv <hprof 文件> <新生成的文件>

eg:

hprof-conv android.hprof mat.hprof

hprof-conv 跟 adb 在同一个文件夹下,配置了 adb 命令的可以直接用这个命令执行。

MAT 查内存泄漏会有点费劲,毕竟是个 java 通用工具,并不会指明告诉你是哪个 Activity 发生了泄漏,但可以分析个大概。

一般泄漏的都是比较大的实例:

image.png

点击类名进入查看:

image.png

ActivityLeakMaker 占用了近 190944 byte 的内存空间,并且引用链里面有 Activity 相关的内容,切回代码来看问题,原来是静态变量持有了 Activity 实例导致:

image.png

2、Android Studio

Android Studio 的 Profiler 工具支持 hprof 的解析,并且很智能的提示当前 leak 了哪些对象,打开方式很简单,将 hprof 文件拖拽至 as,然后双击 hprof 文件即可:

image.png

我们可以很直观的看到,当前 LeakedActivity 和 ReportFragment 发生了泄漏。

如果我们的需求仅仅只是在开发阶段进行内存泄漏检测的话,并且又不想接入 LeakCanary(因为有时候想调试下自己模块的代码,其他模块经常报内存泄漏,冻结当前线程,很影响调试),那么我们可以在应用里面埋个彩蛋,比如单击 5 次版本号,然后调用 Debug.dumpHprofData ,然后将 hprof 文件导出到 as 进行分析,这就将原本可能会进行数次 dump 的过程,改成了自己需要去检测的时候再去 dump。

3、HaHa

在 LeakCanary 的第一版的时候,是采用的 Haha 库来分析泄漏引用链,但由于后面新出的 Shark,比 HaHa 快 8 倍,并且内存占用还要少 10 倍,但查找泄漏路径的大致步骤与 Shark 无异,故此文就不分析 HaHa 了。

4、Shark

Shark 是 square 团队开发的一款全新的分析 hprof 文件的工具,其官方宣布比 Android Studio 用于 memory profiler 的核心库 perflib 要快 8 倍并且内存占用少 10 倍,更加适合手机端的分析工具。其目的就是提供快速解析hprof文件和分析快照的能力,并找出真正的泄漏对象以及对象到GcRoot 的最短引用路径链,以便帮助开发者更加直观的找出泄漏的真正原因。 -- 引用自《LeakCanary2.0解析

看了下 Koom 分析引用链的过程,大致可以分为以下几个步骤:

  • 分析 hprof 文件,获取镜像所有的 instance 实例
  • 遍历所有的实例,判断这个实例与各个 Detectors 是否有存在泄漏,如果有,则记录 objectId 到集合
  • 根据 objectId 集合获取各个泄漏实例引用链,分析出 gcRoot,并遍历 gcRoot 下的引用路径

这个地方重点在于如何找到泄漏的 objectId,因为找到 objectId,即可找到泄漏引用链。在分析 hprof 的时候我们可以拿到 dump 时的内存实例,那么,我们可以根据这个实例来判断是否泄漏,例如:

  • Activity : 判断实例是否是 android.app.Activity 的子类,并且 mFinished 或 mDestroyed 是否为 true (Activity 关闭时该值会为 true),因为 Activity 不泄露的话肯定是会被释放,所以,不可能存在于 dump 的实例中,有就是发生了泄漏
  • Bitmap : 获取实例的类名称是否为 android.graphics.Bitmap,如果是的话,则获取实例的 mWidth 和 mHeight 实例变量,计算两者的乘积是否超过阈值,是的话,也判定为泄漏
  • .... (更多判断可以看 analysis 目录的各个 Detector)

Shark 根据 objectId 分析出的引用链路径:

   ┬───
  │ GC Root: Local variable in native code
  │
  ├─ android.os.HandlerThread instance
  │    Leaking: UNKNOWN
  │    ↓ HandlerThread.contextClassLoader
  │                    ~~~~~~~~~~~~~~~~~~
  ├─ dalvik.system.PathClassLoader instance
  │    Leaking: UNKNOWN
  │    ↓ PathClassLoader.runtimeInternalObjects
  │                      ~~~~~~~~~~~~~~~~~~~~~~
  ├─ java.lang.Object[] array
  │    Leaking: UNKNOWN
  │    ↓ Object[].[197]
  │               ~~~~~
  ├─ com.kwai.koom.demo.leaked.ActivityLeakMaker$LeakedActivity class
  │    Leaking: UNKNOWN
  │    ↓ static ActivityLeakMaker$LeakedActivity.uselessObjectList
  │                                              ~~~~~~~~~~~~~~~~~
  ├─ java.util.ArrayList instance
  │    Leaking: UNKNOWN
  │    ↓ ArrayList.elementData
  │                ~~~~~~~~~~~
  ├─ java.lang.Object[] array
  │    Leaking: UNKNOWN
  │    ↓ Object[].[0]
  │               ~~~
  ╰→ com.kwai.koom.demo.leaked.ActivityLeakMaker$LeakedActivity instance
 •     Leaking: YES (This is the leaking object), Signature: 39f4102649e5d3a5be12db591c2e5f68a1c0d2e9

三、如何应用于线上

1、解决 dump 冻结问题

由于 dump hprof 会暂停所有 java 线程问题,致使 LeakCanary 只能应用于线下检测。但 Koom 和 Liko 另辟蹊径,采用 linux 的 copy-on-write 机制,从当前的主线程 fork 出一个子进程,然后在子进程进行 dump 分析,对于用户所在的进程不会有任何感知。

这个地方会有个坑,就是在 fork 子进程的时候 dump hprof。由于 dump 前会先 suspend 所有的 java 线程,等所有线程都挂起来了,才会进行真正的 dump。由于 copy-on-write 机制,子进程也会将父进程中的 threadList 也拷贝过来,但由于 threadList 中的 java 线程活动在父进程,子进程是无法挂起父进程中的线程的,然后就会一直处于等待中。

为了解决这个问题,Koom 和 Liko 采用欺骗的方式,在 fork 子进程之前,先将父进程中的 threadList 全部设置为 suspend 状态,然后 fork 子进程,子进程在 dump 的时候发现 threadList 都为挂起状态了,就立马开始 dump hprof,然后父进程在 fork 操作之后,立马 resume 恢复回 threadList 的状态

2、解决混淆问题

Shark 支持混淆反解析,思路也很简单,解析 mapping.txt 文件,每次读取一行,只解析类和字段:

  • 类特征 :行尾为 : 冒号结尾,然后根据 -> 作为 index 分割,左边的为原类名,右边的为混淆类名
  • 字段特征:行尾不为 : 冒号结尾,并且不包含 (括号(带括号的为方法),即为字段特征,根据 -> 作为 index 分割,左边为原字段名,右边的为混淆字段名

将混淆类名、字段名作为 key,原类名、原字段名作为 value 存入 map 集合,在分析出内存泄漏的引用路径类时,将类名和字段名都通过这个 map 集合去拿到原始类名和字段名即可,即完成混淆后的反解析

leakCanary 内部是写死的 mapping 文件为 leakCanaryObfuscationMapping.txt,如果打开该文件失败,则不做引用链反解析:

image.png

也即意味着,如果想 LeakCanary 支持混淆反解析,只需要将自己的 mapping 文件重命名为 leakCanaryObfuscationMapping.txt,然后放入 asset 目录即可

对于 Koom 的混淆反解析,Koom 并没有做,但我们可以自己去加这块代码:

private boolean buildIndex() {
  ...
   try {
     // 新增 ---------- start
     InputStream is =  KGlobalConfig.getApplication().getResources().getAssets().open("mapping.txt");
     ProguardMapping mapping = new ProguardMappingReader(is).readProguardMapping();
    // 新增 ---------- end
       
     heapGraph = HprofHeapGraph.Companion.indexHprof(hprof, mapping,
             kotlin.collections.SetsKt.setOf(gcRoots));
  } catch (Exception e) {
     e.printStackTrace();
  }
   return true;
}        

将 mapping.txt 文件放到 asset 目录即可,如下是混淆与混淆反解析的引用链的对比:

image.png

3、泄漏兜底

在预判内存泄漏发生时,我们可以将 Activity 中引用到的 Bitmap、DrawingCache 等进行主动释放,以此来降低泄漏的影响面。做法是,在 Activity onDestory 时候从 view 的 rootview 开始,递归释放所有子 view 涉及的图片、背景、DrawingCache、监听器等等资源,让 Activity 成为一个不占资源的空壳,泄露了也不会导致图片资源被持有,eg:

...
   Drawable d = iv.getDrawable();
if (d != null) {
   d.setCallback(null);
}        
iv.setImageDrawable(null);
...
...

但这一点对于阈值检测法的 Koom 来说,没办法做到,因为他拿不到 onDestroy 时的 Activity 实例,但也不要紧,我们可以将兜底操作做成通用操作,不管他泄漏与不泄露,都做 view 相关引用的卸载。

四、总结:

整体下来,分析个内存泄漏其实并不难,难就难在我们平时并没有养成好的习惯,对于引用的传递考虑的不周全,但我们可以加强自身的编码习惯,尽量减少项目中的泄漏问题

注:本文分析的 Koom 源码为 1.0 版本,目前 Koom 已经出 2.0 版本


收起阅读 »

JAVA创建线程的三种方式

JAVA创建线程的三种方式一、JAVA创建线程的方式JAVA中为了有异步计算,所以需要开启线程帮助后来计算,后台运行,在java中开启线程的方式有三种:继承Thread类实现Runnable接口使用Callable和Future二、线程创建方式的异同继承Thr...
继续阅读 »

JAVA创建线程的三种方式

一、JAVA创建线程的方式

JAVA中为了有异步计算,所以需要开启线程帮助后来计算,后台运行,在java中开启线程的方式有三种:

  1. 继承Thread类
  2. 实现Runnable接口
  3. 使用Callable和Future

二、线程创建方式的异同

  1. 继承Thread类: (1)通过Thread的构造方法创建线程 (2)通过start启动线程线程,且一个线程只能执行一次 (3)调用this.即可获得当前线程 (4)若要两个线程之间共享变量时,需要在声明为static变量,不推荐使用static变量,因为异步问题不容易控制。
  2. 实现Runnable接口 (1)线程只是实现了Runnable接口,还可以继承其他类和实现其他接口; (2)可以多个线程之间共享同一个目标对象(Runnable),非常适合多个线程处理同一份资源的情况;这一点比较难理解,看下面的代码示例会好理解。 (3)使用Thread.currentThread()可获得当前线程
  3. FutureTask:使用Callable和Future (1)Callable接口是Runnable接口的增强版 (2)Callable接口中的call()方法可以有返回值,也可以声明抛出异常

实现Runnable接口和继承Thread的方式区别: 继承Thread创建的线程是创建的Thread子类即可代表线程对象;而实现Runnable接口的创建的Runnable对象只能作为线程对象的target。 这也就是Runnable可以共享数据的原因。

FutureTask、Callable、Future之间的关系: 它实现了了RunnableFuture接口,而RunnableFuture接口又继承自Runnable和Future接口,所以FutureTask既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。

public class FutureTask<V> implements RunnableFuture<V> {

}

三、实践

3.1 继承Thread类

class Test {

public static int number = 0;

public static void main(String[] args) {
TestThread testThread = new TestThread();
testThread.start();

TestThread2 testThread2 = new TestThread2();
testThread2.start();
}

public static class TestThread extends Thread {
@Override
public void run() {
super.run();
for (int i = 0; i < 10; i++) {
System.out.println(this.getName() + ":" + ++number);
}
}
}

public static class TestThread2 extends Thread {
@Override
public void run() {
super.run();
for (int i = 0; i < 10; i++) {
System.out.println(this.getName() + ":" + ++number);
}
}
}
}

3.2 实现Runnable接口

该代码示例中是两个线程共用一个Runnable,其中Runnable的数据是两个线程之间共享的。

class Test {
public static void main(String[] args) {
TestRunnable testRunnable = new TestRunnable();
// 共用一个Runnable,数据会按顺序输出
new Thread(testRunnable).start();
new Thread(testRunnable).start();

}

public static class TestRunnable implements Runnable {
int number = 0;

@Override
public void run() {
++number;
System.out.println(Thread.currentThread().getName() + " " + number);
}
}
}

3.3 FutureTask:使用Callable和Future

class Test {
public static void main(String[] args) {
FutureTask<Integer> task = new FutureTask<Integer>((Callable<Integer>) () -> {
int i = 0;
while (i < 10) {
++i;
}
//call()方法的返回值
return i;
});

new Thread(task, "有返回值的线程").start();

try {
System.out.println("task.get():" + task.get());
} catch (Exception e) {
e.printStackTrace();
}
}
}
收起阅读 »

冒泡排序的进化过程

基础版本 所有情况下时间复杂度都为O(n2n^2n2) public static void bob(int[] array) { // 总共比较n-1轮 for (int i = 0; i < array.length - 1; i++...
继续阅读 »

基础版本


所有情况下时间复杂度都为O(n2n^2)


public static void bob(int[] array) {
// 总共比较n-1轮
for (int i = 0; i < array.length - 1; i++) {
// 因为每次都能确定一个最大元素,所以每次都能少比较一次
for (int j = 0; j < array.length - i - 1; j++) {
if (array[j] > array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}

上述算法简单粗暴,对任意数组上来就是两层for套着猛干。


如果现在有个有序数组,比如{1,2,3,4,5,6,7,8},也会白费力气去浪费cpu,这是不必要的。怎么避免呢?


我们看到,如果元素整体有序,那么上述代码中的


if (arr[j] > arr[j + 1])

就永远不会满足,那么就不会发生元素的交换,所以我们可以添加个布尔值,来判断是否发生了元素交换,如果没发生,则认为已经整体有序了,直接跳出即可。如下:


1 进阶版本


这里添加了一个boolean来判断本次是否有元素交换,没有则提前结束。


private static void bob2(int[] arr) {
int length = arr.length;
for (int i = 0; i < length; i++) {
boolean swap = false;
for (int j = 0; j < length - 1; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;

swap = true;
}
}
if (!swap) break;
}
}

上述代码可以避免对整体有序的数组瞎排序。但是,如果一个数组不是整体有序,而是局部有序呢,比如{4,3,2,1,5,6,7,8},我们观察到后半部分是不需要参加排序的,也就是说,只需要将前半部分排序即可。


所以,我们就要确定从哪开始的元素是有序的,也就是确定有序区开始的下标


那么,怎么确定这个下标呢?


我们可以想一下,对于冒泡排序,如果后面的元素比前面的大,才交换,否则就不交换,也就是说,最后一次发生交换的位置,其后面一定是有序的。比如在位置i发生了交换,i后面没有发生过交换,那么i后面一定是有序的,否则i后面就还会发生交换。


所以,每次元素最后一次交换的位置,就是有序区下标的起点,也是无序区下标的终点。


定义一个下标,每次有元素交换就更新下标,下标后面的元素就是有序的,每次比较只比较下标前面的元素即可


代码如下:


2 高阶版本


private static void bob3(int[] arr) {
int length = arr.length;
int lastSwapIndex = 0;
// 定义有序区起始点,也就是无序区终点
int sortedBorder = length - 1;
for (int i = 0; i < length; i++) {
boolean swap = false;
// 只比较无序区
for (int j = 0; j < sortedBorder; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swap = true;
// 发生了交换,就更新下标
lastSwapIndex = j;
}
}
// 更新下标
sortedBorder = lastSwapIndex;
if (!swap) break;
}
}

上述代码就可以解决局部有序但整体无序的情况。


但是我们发现,上面的代码,都是从左向右比较的,如果数组是{2,3,4,5,6,7,8,1}这样呢,也是局部有序,但是,如果从左往右比,则很费时间,而从右往左比,则一轮就能结束。


但是!如果从右往左比的话,遇见{8,1,2,3,4,5,6,7}这样的又跪了,怎么办呢?我们可以采用双向比较法,也就是一次从左向右比,一次从右向左比,这就叫鸡尾酒排序


现在我们使用鸡尾酒排序(双向排序),每次排序后交换方向,代码如下:


3 最终版本(鸡尾酒排序)


private static void bob4(int[] arr) {
int length = arr.length;
for (int i = 0; i < (length >>> 1); i++) {
boolean swap = false;
// 从左到右
for (int j = 0; j < length - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
swap = true;
}
}

if (!swap) break;

// 从右到左
swap = false;
for (int j = length - 1; j > 0; j--) {
if (arr[j] < arr[j - 1]) {
swap(arr, j, j - 1);
swap = true;
}
}

if (!swap) break;
}
}

// 交换元素
private static void swap(int[] arr, int i, int j) {
arr[i] ^= arr[j];
arr[j] ^= arr[i];
arr[i] ^= arr[j];
}

总结


我们虽然针对冒泡排序进行了多次优化,但是它的时间复杂度还是O(n2),这是无法避免的,因为冒泡排序每次只是交换相邻元素,也就是只消除了一个逆序对,凡是通过交换相邻元素进行的排序,其时间复杂度都是O(n2)


为什么呢?因为交换相邻元素每次只消除了一个逆序对。我们来证明下。


学过<线性代数>的应该知道逆序这个定义。



在一个排列中,如果一对数的前后位置与大小顺序相反,即前面的数大于后面的数,那么它们就称为一个逆序。一个排列中逆序的总数就称为这个排列的逆序数。



证明: 凡是通过交换相邻元素进行的排序,其时间复杂度都是O(n2n^2)


假设现有任意序列L,其共有n个元素,则其共能组成Cn2C_n^2个数对(从n个元素中,挑出2个元素组成的数对),也就是(n(n1)2)(\frac{n(n-1)}{2})个, 其中逆序数为a;然后取其反序列Lr,其逆序数为b,而且b=(n(n1)2)(\frac{n(n-1)}{2})-a,因为原来L中的顺序对,在Lr中全变成了逆序对,而且对于任意的数对,要么是顺序,要么是逆序(相同的可以认为是顺序),所以a+b=(n(n1)2)(\frac{n(n-1)}{2})


所以,L和Lr的总逆序对就是(n(n1)2)(\frac{n(n-1)}{2}),那么单个L的逆序对就是(n(n1)4)(\frac{n(n-1)}{4}),当n趋近于+∞时,就是n2n^2,而通过交换相邻元素每次只能消除一个逆序对,所以总共需要交换n2n^2次,所以相应算法的时间复杂度就是O(n2n^2)。


为什么交换相邻元素只能消除一个逆序对呢,因为只改变了相邻俩元素的位置,它俩前后的该比它大还是比它大,该比它小还是比它小。比如{5,4,3,2,1},我们交换了3和2,变为{5,4,2,3,1},我们发现,只是消除了{1,2}这个逆序对,前面的5和4,还是比它俩大,后面的1,还是比它俩小,所以只消除了一个逆序对,这里不再废话。


证明完毕。


其实,我们可以扩展一下,凡是每次只能消除一个逆序对的算法,其时间复杂度都是O(n2n^2)。也不再废话。


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

FastKV:一个真的很快的KV存储组件

一、前言 KV存储无论对于客户端还是服务端都是重要的构件。 对于Android客户端而言,最常见的莫过于SDK提供的SharePreferences(以下简称SP),但其低效率和ANR问题饱受诟病。 后来官方又推出了基于Kotlin的DataStore, 其中...
继续阅读 »

一、前言


KV存储无论对于客户端还是服务端都是重要的构件。

对于Android客户端而言,最常见的莫过于SDK提供的SharePreferences(以下简称SP),但其低效率和ANR问题饱受诟病。

后来官方又推出了基于Kotlin的DataStore, 其中的Preferences DataStore,换汤不换药,底层的存储策略还是一样的,目测该有的问题还是有。

18年年末微信开源了MMKV, 有较高热度。

我之前写过一个叫LightKV的Android客户端的KV存储组件,开源时间比MMKV要早一点,但关注量不多……不过话说回来,由于当时认知不足,LightKV的设计也不够成熟。


1.1 SP的不足


关于SP的缺点网上有不少讨论,这里主要提两个点:



  • 保存速度较慢


SP用内存层用HashMap保存,磁盘层则是用的XML文件保存。

每次更改,都需要将整个HashMap序列化为XML格式的报文然后整个写入文件。

归结其较慢的原因:

1、不能增量写入;

2、序列化比较耗时。



  • 可以能会导致ANR


public void apply() {
// ...省略无关代码...
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
}

public void handleStopActivity(IBinder token, boolean show, int configChanges,
PendingTransactionActions pendingActions, boolean finalStateRequest, String reason) {
// ...省略无关代码...
// Make sure any pending writes are now committed.
if (!r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}
}

Activity stop时会等待SP的写入任务,如果SP的写入任务多且执行慢的话,可能会阻塞主线程较长时间,轻则卡顿,重则ANR。


1.2 MMKV的不足



  • 没有类型信息,不支持getAll

    MMKV的存储用类似于Protobuf的编码方式,只存储key和value本身,没有存类型信息(Protobuf用tag标记字段,信息更少)。

    由于没有记录类型信息,MMKV无法自动反序列化,也就无法实现getAll接口。

  • 读取相对较慢

    SP在加载的时候已经将value反序列化存在HashMap中了,读取的时候索引到之后就能直接引用了。

    而MMKV每次读取时都需要重新解码,除了时间上的消耗之外,还需要每次都创建新的对象。

    不过这不是大问题,相对SP没有差很多。

  • 需要引入so, 增加包体积

    引入MMKV需要增加的体积还是不少的,且不说jar包和aidl文件,光是一个arm64-v8a的so就有四百多K。



虽然说现在APP体积都不小,但毕竟增加体积对打包、分发和安装时间都多少有些影响。



  • 文件只增不减

    MMKV的扩容策略还是比较激进的,而且扩容之后不会主动trim size。

    比方说,假如有一个大value,让其扩容至1M,后面删除该value,哪怕有效内容只剩几K,文件大小还是保持在1M。

  • 可能会丢失数据

    前面的问题总的来说都不是什么“要紧”的问题,但是这个丢失数据确实是硬伤。

    MMKV官方有这么一段表述:

    通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。





这个表述对一半不对一半。

如果数据完成写入到内存块,如果系统不崩溃,即使进程崩溃,系统也会将buffer刷入磁盘;

但是如果在刷入磁盘之前发生系统崩溃或者断电等,数据就丢失了,不过这种情况发生的概率不大;

另一种情况是数据写一半的时候进程崩溃或者被杀死,然后系统会将已写入的部分刷入磁盘,再次打开时文件可能就不完整了。

例如,MMKV在剩余空间不足时会回收无效的空间,如果这期间进程中断,数据可能会不完整。
MMKV官方的说明可以佐证:



CRC校验失败之后,MMKV有两种应对策略:直接丢弃所有数据,或者尝试读取数据(用户可以在初始化时设定)。

尝试读取数据不一定能恢复数据,甚至可能会读到一些错误的数据,得看运气。


这个过程是比较容易复现的,下面是其中一种复现路径:



  1. 新增和删除若干key-value
    得到数据如下:





  1. 插入一个大字符串,触发扩容,扩容前会触发垃圾回收




  2. 断点打在执行memmove的循环中,执行一部分memmove, 然后在手机上杀死进程






  1. 再次打开APP,数据丢失



相比之下,SP虽然低效,但至少不会丢失数据。


二、FastKV


在总结了之前的经验和感悟之后,笔者实现了一个高效且可靠的版本,且将其命名为: FastKV


2.1 特性


FastKV有以下特性:



  1. 读写速度快

    • FastKV采用二进制编码,编码后的体积相对XML等文本编码要小很多。

    • 增量编码:FastKV记录了各个key-value相对文件的偏移量,更新数据时,可以直接在对应的位置写入数据。

    • 默认用mmap的方式记录数据,更新数据时直接写入到内存即可,没有IO阻塞。



  2. 支持多种写入模式

    • 除了mmap这种非阻塞的写入方式,FastKV也支持常规的阻塞式写入方式,
      并且支持同步阻塞和异步阻塞(分别类似于SharePreferences的commit和apply)。



  3. 支持多种类型

    • 支持常用的boolean/int/float/long/double/String等基础类型。

    • 支持ByteArray (byte[])。

    • 支持存储自定义对象。

    • 内置StringSet编码器 (为了兼容SharePreferences)。



  4. 方便易用

    • FastKV提供了了丰富的API接口,开箱即用。

    • 提供的接口其中包括getAll()和putAll()方法,
      所以迁移SharePreferences等框架的数据到FastKV很方便,当然,迁移FastKV的数据到其他框架也很方便。



  5. 稳定可靠

    • 通过double-write等方法确保数据的完整性。

    • 在API抛IO异常时提供降级处理。



  6. 代码精简

    • FastKV由纯Java实现,编译成jar包后体积仅30多K。




2.2 实现原理


2.2.1 编码


文件的布局:



[data_len | checksum | key-value | key-value|....]




  • data_len: 占4字节, 记录所有key-value所占字节数。

  • checksum: 占8字节,记录key-value部分的checksum。


key-value的数据布局:


+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| delete_flag | external_flag | type | key_len | key_content | value |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 1bit | 1bit | 6bits | 1 byte | | |


  • delete_flag :标记当前key-value是否删除。

  • external_flag: 标记value部分是否写到额外的文件。

    注:对于数据量比较大的value,放在主文件会影响其他key-value的访问性能,因此,单独用一个文件来保存该value, 并在主文件中记录其文件名。

  • type: value类型,目前支持boolean/int/float/long/double/String/ByteArray以及自定义对象。

  • key_len: 记录key的长度,key_len本身占1字节,所以支持key的最大长度为255。

  • key_content: key的内容本身,utf8编码。

  • value: 基础类型的value, 直接编码(little-end);

    其他类型,先记录长度(用varint编码),再记录内容。

    String采用UTF-8编码,ByteArray无需编码,自定义对象实现Encoder接口,分别在Encoder的encode/decode方法中序列化和反序列化。


2.2.2 存储




  • mmap

    为了提高写入性能,FastKV默认采用mmap的方式写入。




  • 降级

    当mmap API发生IO异常时,降级到常规的blocking I/O,同时为了不影响当前线程,会将写入放到异步线程中执行。




  • 数据完整性

    如果在写入一部分的过程中发生中断(进程或系统),则文件可能会不完整。

    故此,需要用一些方法确保数据的完整性。

    当用mmap的方式打开时,FastKV采用double-write的方式:数据依次写入A/B两个文件,确保任何时刻总有一个文件完整的; 加载数据时,通过checksum、标记、数据合法性检验等方法验证数据的正确性。

    double-write可以防止进程崩溃后数据不完整,但mmap是系统定时刷盘,若在刷盘系统崩溃或者断电,仍会丢失更新(之前的数据还在,仅丢失更新)。 可以通过调用force()强制刷盘,但这就不能发挥mmap的优点了。

    基于此,FastKV也支持用blocking I/O的方式写文件(比mmap慢,但是能确保数据真正落盘)。

    当用blocking I/O的写入时,先写临时文件,完整写入后再删除主文件,然后重命名临时文件为主文件。

    FastKV支持同步的和异步的blocking I/O,写入方式类似于SP的commit和apply,但是序列化key-value的部分是增量的,比SP的序列化整个HashMap的方式要快许多。




  • 更新策略(增/删/改)

    新增:写入到数据的尾部。

    删除:delete_flag设置为1。

    修改:如果value部分的长度和原来一样,则直接写入原来的位置;
    否则,先写入key-value到数据尾部,再标记原来位置的delete_flag为1(删除),最后再更新文件的data_len和checksum。




  • gc/truncate

    删除key-value时会收集信息(统计删除的个数,以及所在位置,占用空间等)。

    GC的触发点有两个:

    1、新增key-value时剩余空间不足,且已删除的空间达到阈值,且腾出删除空间后足够写入当前key-value, 则触发GC;

    2、删除key-value时,如果删除空间达到阈值,或者删除的key-value个数达到阈值,则触发GC。

    GC后如果不用的空间达到设定阈值,则触发truncate(缩小文件大小)。




2.3 使用方法


2.3.1 导入


dependencies {
implementation 'io.github.billywei01:fastkv:1.0.2'
}

2.3.2 初始化


    FastKVConfig.setLogger(FastKVLogger)
FastKVConfig.setExecutor(ChannelExecutorService(4))

初始化可以按需设置日志回调和Executor。

建议传入自己的线程池,以复用线程。


日志接口提供三个级别的回调,按需实现即可。


    public interface Logger {
void i(String name, String message);

void w(String name, Exception e);

void e(String name, Exception e);
}

2.3.3 数据读写



  • 基本用法


    FastKV kv = new FastKV.Builder(path, name).build();
if(!kv.getBoolean("flag")){
kv.putBoolean("flag" , true);
}


  • 保存自定义对象


    FastKV.Encoder<?>[] encoders = new FastKV.Encoder[]{LongListEncoder.INSTANCE};
FastKV kv = new FastKV.Builder(path, name).encoder(encoders).build();

String objectKey = "long_list";
List<Long> list = new ArrayList<>();
list.add(100L);
list.add(200L);
list.add(300L);
kv.putObject(objectKey, list, LongListEncoder.INSTANCE);

List<Long> list2 = kv.getObject("long_list");

FastKV支持保存自定义对象,为了加载文件时能自动反序列化,需在构建FastKV实例时传入对象的编码器。

编码器为实现FastKV.Encoder的对象。

比如上面的LongListEncoder的实现如下:


public class LongListEncoder implements FastKV.Encoder<List<Long>> {
public static final LongListEncoder INSTANCE = new LongListEncoder();

@Override
public String tag() {
return "LongList";
}

@Override
public byte[] encode(List<Long> obj) {
return new PackEncoder().putLongList(0, obj).getBytes();
}

@Override
public List<Long> decode(byte[] bytes, int offset, int length) {
PackDecoder decoder = PackDecoder.newInstance(bytes, offset, length);
List<Long> list = decoder.getLongList(0);
decoder.recycle();
return (list != null) ? list : new ArrayList<>();
}
}

编码对象涉及序列化/反序列化。

这里推荐笔者的另外一个框架:github.com/BillyWei01/…


2.3.4 For Android


Android平台上的用法和常规用法一致,不过Android平台多了SharePreferences API,以及支持Kotlin。

FastKV的API兼容SharePreferences, 可以很轻松地迁移SharePreferences的数据到FastKV。

相关用法可参考:github.com/BillyWei01/…


三、 性能测试



  • 测试数据:搜集APP中的SharePreferenses汇总的部份key-value数据(经过随机混淆)得到总共四百多个key-value。由于日常使用过程中部分key-value访问多,部分访问少,所以构造了一个正态分布的访问序列。

  • 比较对象: SharePreferences 和 MMKV

  • 测试机型:荣耀20S


测试结果:



























写入(ms)读取(ms)
SharePreferences14906
MMKV349
FastKV141


  • SharePreferences提交用的是apply, 耗时依然不少。

  • MMKV的读取比SharePreferences要慢一些,写入则比之快许多。

  • FastKV无论读取还是写入都比另外两种方式要快。


四、结语


本文探讨了当下Android平台的各类KV存储方式,提出并实现了一种新的存储组件,着重解决了KV存储的效率和数据可靠性问题。

目前代码已上传Github: github.com/BillyWei01/…


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

移动端网络监控实践

1. 背景介绍 在移动端应用开发场景下,不可避免的要与网络打交道。有时在网络请求失败时,我们想知道网络的质量;有时需要明确的告知用户当前网络质量(比如游戏场景实时显示延迟)。网络监控离不开最经典的TCP/IP模型,基于模型分层统计网络耗时有助于我们更清晰的了解...
继续阅读 »

1. 背景介绍


在移动端应用开发场景下,不可避免的要与网络打交道。有时在网络请求失败时,我们想知道网络的质量;有时需要明确的告知用户当前网络质量(比如游戏场景实时显示延迟)。网络监控离不开最经典的TCP/IP模型,基于模型分层统计网络耗时有助于我们更清晰的了解当前网络质量。


image-20210929181044521-2910245.png


TCP/IP参考模型中物理层难以在网络层面统计,网络层有Ping工具,传输层有系统提供的Socket接口,应用层最常用的有HTTP、RTMP协议。本文我们介绍ping工具、DNS解析耗时、TCP连接耗时、HTTP建立耗时。


2. ping


ping是基于网络层ICMP协议的,发送的是ICMP回显请求报文。下面我们先了解下ICMP。


2.1 ICMP(Internet控制报文协议)简介


ICMP是IP层的一个组成部分,主要传递差错报文以及其他需要注意的信息。ICMP报文通常被IP层或更高层协议(TCP或UDP)使用。ICMP的正式规范参见RFC 792[Posterl 1981b],ICMP封装在IP数据包内部,格式是20字节的IP首部+ICMP报文。ICMP报文格式如下:


image-20211005181747393-3429068.png


所有报文的前4个字节都是一样的,但是剩下的其他字节则互不相同。类型字段可以有15个不同的值,以描述特定类型的ICMP报文,某些CIMP报文还是用代码字段的值来进一步描述不同的条件。校验和字段覆盖整个ICMP报文。


不同类型由报文中的类型字段和代码字段来共同决定。报文可以分为查询报文和差错报文,ICMP差错报文有时需要做特殊处理(如在对ICMP差错报文进行响应时,永远不会生成另一份ICMP差错报文)。


对于我们今天用到的ping程序,使用了:



  • 类型为0,代码为0的回显应答的查询报文

  • 类型为8,代码为0的请求回显的查询报文


2.2 Ping程序协议简介


ping的工作原理很简单,一台网络设备发送请求等待另一网络设备的回复,并记录下发送时间。接收到回复之后,就可以计算报文传输时间了。只要接收到回复就表示连接是正常的。耗费的时间喻示了路径长度。重复请求响应的一致性也表明了连接质量的可靠性。因此,ping回答了两个基本的问题:是否有连接?连接的质量如何?


我们称发送回显请求的ping程序为客户,称被ping的主机为服务器。大多数的TCP/IP实现都在内核中直接支持ping服务器,这种服务器不是一个用户进程。ICMP回显请求和回显应答报文如下:


image-20211005183328906-3430011.png


Unix系统在实现ping程序时是把ICMP报文中的标识符字段置成发送进程的ID号。这样即使在同一台主机上同时运行了多个ping程序实例,ping程序也可以识别出返回的信息。序列号从0开始,每发送一次新的回显请求就加1。


2.3 ping程序命令介绍


ping程序的主要选项:



  1. -c:选项允许用户指定发送报文的数量,例如,ping –c10会发送10个报文然后停止;

  2. -f:选项表明报文发送速率与接收主机能够处理速率相同,这一参数可用于链路压力测试或接口性能比较;

  3. -l:选项用于计数,尽可能快的发送该数量报文,然后恢复正常,该命令用于测试处理泛洪的能力,需要root权限执行;

  4. -i:选项用于用户在两个连续报文之间指定等待秒数。该命令对于将报文间隔开或用在脚本中非常有用。正常情况下,偶然的ping包对数据流的影响是很小的。但重复报文或报文泛洪影响就很大了。因此,使用该选项时需谨慎;

  5. -n:选项将输出限制为数字形式,这在碰见DNS问题时很有用;

  6. -v:显示更详尽输出,较少输出为-q和-Q;

  7. -s:选项指定发送数据的大小。但如果设置的太小,小于8,则报文中就没有空间留给时间戳了。设置报文大小能诊断有路径MTU(Maximum Transmission Unit)设置或分段而导致的问题。如果不使用该选项,ping默认是64字节。


2.4 Android端执行ping程序


Android系统提供了ping命令行程序,在程序中可以通过popen执行系统自带ping程序,下面是执行ping程序的代码:


int RunPingQuery(int _querycount, int interval/*S*/, int timeout/*S*/, const char* dest, unsigned int packetSize) {
char cmd[256] = {0};


int index = snprintf(cmd, 256, "ping -c %d -i %d -w %d", _querycount, interval, timeout);

if (index < 0 || index >= 256) {
//sprintf return error
return -1;
}

int tempLen = 0;

if (packetSize > 0) {
tempLen = snprintf((char*)&cmd[index], 256 - index, " -s %u %s", packetSize, dest);
} else {
tempLen = snprintf((char*)&cmd[index], 256 - index, " %s", dest);
}

if (tempLen < 0 || tempLen >= 256 - index) {
//sprintf return error
return -1;
}
FILE* pp = popen(cmd, "r");

if (!pp) {
//popen error
return -1;
}

std::string pingresult_;
while (fgets(line, sizeof(line), pp) != NULL) {
pingresult_.append(line, strlen(line));
}

pclose(pp);

if (pingresult_.empty()) {
//m_strPingResult is empty
return -1;
}

struct PingStatus pingStatusTemp; //= {0};notice: cannot initial with = {0},crash
GetPingStatus(pingStatusTemp);
if (0 == pingStatusTemp.avgrtt && 0 == pingStatusTemp.maxrtt) {
//remote host is not available
return -1;
}
return 0;
}

int GetPingStatus(struct PingStatus& _ping_status, std::string pingresult_) {
if (pingresult_.empty()) return -1;

_ping_status.res = pingresult_;
std::vector<std::string> vecPingRes;
str_split('\n', pingresult_, vecPingRes);

std::vector<std::string>::iterator iter = vecPingRes.begin();

for (; iter != vecPingRes.end(); ++iter) {
if (vecPingRes.begin() == iter) { // extract ip from the result string and assign to _ping_status.ip
int index1 = iter->find_first_of("(", 0);

if (index1 > 0) {
int index2 = iter->find_first_of(")", 0);

if (index2 > index1) {
int size = index2 - index1 - 1;
std::string ipTemp(iter->substr(index1 + 1, size));
strncpy(_ping_status.ip, ipTemp.c_str(), (size < 16 ? size : 15));
}
}
} // end if(vecPingRes.begin()==iter)

int num = iter->find("packet loss", 0);

if (num >= 0) {
int loss_rate = 0;
int i = 3;

while (iter->at(num - i) != ' ') {
loss_rate += ((iter->at(num - i) - '0') * (int)pow(10.0, (double)(i - 3)));
i++;
}
_ping_status.loss_rate = (double)loss_rate / 100;
}

int num2 = iter->find("rtt min/avg/max", 0);

if (num2 >= 0) {
int find_begpos = 23;
int findpos = iter->find_first_of('/', find_begpos);
std::string sminRTT(*iter, find_begpos, findpos - find_begpos);
find_begpos = findpos + 1;
findpos = iter->find_first_of('/', find_begpos);
std::string savgRTT(*iter, find_begpos, findpos - find_begpos);
find_begpos = findpos + 1;
findpos = iter->find_first_of('/', find_begpos);
std::string smaxRTT(*iter, find_begpos, findpos - find_begpos);
_ping_status.minrtt = atof(sminRTT.c_str());
_ping_status.avgrtt = atof(savgRTT.c_str());
_ping_status.maxrtt = atof(smaxRTT.c_str());
}
}
return 0;
}

2.5 iOS端发送ping指令


iOS端主要通过创建socket发送ICMP执行,主要思路如下:



  1. 如果设置的是域名,需要将DNS转换为IP;

  2. 创建socketn = socket(family, type, protocol),family为AF_INET, type为SOCK_DGRAM, protocol为IPPROTO_ICMP;

  3. 构造ICMP包:


struct icmp {
u_char icmp_type; /* type of message, see below */
u_char icmp_code; /* type sub code */
u_short icmp_cksum; /* ones complement cksum of struct */
union {
u_char ih_pptr; /* ICMP_PARAMPROB */
struct in_addr ih_gwaddr; /* ICMP_REDIRECT */
struct ih_idseq {
n_short icd_id;
n_short icd_seq;
} ih_idseq;
int ih_void;

/* ICMP_UNREACH_NEEDFRAG -- Path MTU Discovery (RFC1191) */
struct ih_pmtu {
n_short ipm_void;
n_short ipm_nextmtu;
} ih_pmtu;

struct ih_rtradv {
u_char irt_num_addrs;
u_char irt_wpa;
u_int16_t irt_lifetime;
} ih_rtradv;
} icmp_hun;
#define icmp_pptr icmp_hun.ih_pptr
#define icmp_gwaddr icmp_hun.ih_gwaddr
#define icmp_id icmp_hun.ih_idseq.icd_id
#define icmp_seq icmp_hun.ih_idseq.icd_seq
#define icmp_void icmp_hun.ih_void
#define icmp_pmvoid icmp_hun.ih_pmtu.ipm_void
#define icmp_nextmtu icmp_hun.ih_pmtu.ipm_nextmtu
#define icmp_num_addrs icmp_hun.ih_rtradv.irt_num_addrs
#define icmp_wpa icmp_hun.ih_rtradv.irt_wpa
#define icmp_lifetime icmp_hun.ih_rtradv.irt_lifetime
union {
struct id_ts {
n_time its_otime;
n_time its_rtime;
n_time its_ttime;
} id_ts;
struct id_ip {
struct ip idi_ip;
/* options and then 64 bits of data */
} id_ip;
struct icmp_ra_addr id_radv;
u_int32_t id_mask;
char id_data[1];
} icmp_dun;
#define icmp_otime icmp_dun.id_ts.its_otime
#define icmp_rtime icmp_dun.id_ts.its_rtime
#define icmp_ttime icmp_dun.id_ts.its_ttime
#define icmp_ip icmp_dun.id_ip.idi_ip
#define icmp_radv icmp_dun.id_radv
#define icmp_mask icmp_dun.id_mask
#define icmp_data icmp_dun.id_data
};

void __preparePacket(char* _sendbuffer, int& _len) {
char sendbuf[MAXBUFSIZE];
memset(sendbuf, 0, MAXBUFSIZE);
struct icmp* icmp;
icmp = (struct icmp*) sendbuf;
icmp->icmp_type = ICMP_ECHO;
icmp->icmp_code = 0;
icmp->icmp_id = getpid() & 0xffff;/* ICMP ID field is 16 bits */
icmp->icmp_seq = htons(nsent_++);
memset(&sendbuf[ICMP_MINLEN], 0xa5, DATALEN); /* fill with pattern */

struct timeval now;
(void)gettimeofday(&now, NULL);
now.tv_usec = htonl(now.tv_usec);
now.tv_sec = htonl(now.tv_sec);
bcopy((void*)&now, (void*)&sendbuf[ICMP_MINLEN], sizeof(now));
_len = ICMP_MINLEN + DATALEN; /* checksum ICMP header and data */
icmp->icmp_cksum = 0;
icmp->icmp_cksum = in_cksum((u_short*) icmp, _len);
memcpy(_sendbuffer, sendbuf, _len);
}


  1. 接收ICMP包:


int PingQuery::__recv() {
char recvbuf[MAXBUFSIZE];
char controlbuf[MAXBUFSIZE];
memset(recvbuf, 0, MAXBUFSIZE);
memset(controlbuf, 0, MAXBUFSIZE);

struct msghdr msg = {0};
struct iovec iov = {0};
iov.iov_base = recvbuf;
iov.iov_len = sizeof(recvbuf);
msg.msg_name = &recvaddr_;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = controlbuf;

msg.msg_namelen = sizeof(recvaddr_);
msg.msg_controllen = sizeof(controlbuf);

int n = (int)recvmsg(sockfd_, &msg, 0);

if (n < 0) {
return -1;
}
//解析消息结构
return n;
}

2.6 ping网络延迟的相关参考


ping外网小于50ms,网络的延迟就算良好,是正常的。


一般来说,网络的延迟PING值越低,速度会越快;但是网络的速度与网络延迟这二者之间没有必然的联系,以下是ping网络延迟的相关参考数据:



  • ping网络:1到30ms:速度极快,几乎察觉不出有延迟,玩任何游戏速度都特别顺畅;~

  • ping网络:31到50ms:速度良好,可以正常游戏浏览网页,没有明显的延迟情况;~

  • ping网络:51到100ms:速度普通,对抗类游戏在一定水平以上能感觉出延迟,偶尔感觉到停顿;

  • ping网络:100ms到200ms:速度较差,无法正常游玩对抗类游戏,有明显的卡顿现象,偶尔出现丢包和掉线现象。


3. dns解析耗时


在我们创建socket前,会有一个域名转IP的解析过程。域名系统是一种用于TCP/IP应用程序的分布式数据库,提供了域名和IP地址之间的转换及有关电子邮件的选路信息。在Unix主机中,通过两个库函数gethostbyname和gethostbyaddr来访问的。前者接收主机名字返回IP地址,后者接收IP地址来寻找主机名字。


我们知道域名解析要访问域名服务,连接域名服务是基于UDP还是TCP呢?DNS名字服务器使用的熟知端口号是53,通过tcpdump观察到所有例子都是采用UDP,为什么采用的是UDP呢?


当名字解析器发出一个查询请求,并且返回响应中的TC(删减标志)比特被设置为1时,它就意味着响应的长度超过了512个字节,而仅返回前512个字节。在遇到这种情况时,名字解析器通过使用TCP重发原来的查询请求,它将允许返回的响应超过512个字节。TCP能将用户的数据流分为一些报文段,它就能用多个报文段来传送任意长度的用户数据。


我们要统计DNS解析延时就需要自己创建socket,发送DNS报文并获取响应计算耗时。创建socket需要知道DNS服务地址,怎么获取DNS地址呢?


一种常见的方法通过获取手机配置文件获取:


char buf1[PROP_VALUE_MAX];
char buf2[PROP_VALUE_MAX];
__system_property_get("net.dns1", buf1);
__system_property_get("net.dns2", buf2);

这种方式高版本获取不到DNS服务地址,部分高版本手机可通过下面方法获取:


    char buf3[1024];
__system_property_get("ro.config.dnscure_ipcfg", buf3);
std::string dnsCureIPCfgStr(buf3);
if (!dnsCureIPCfgStr.empty()) {
const std::vector<std::string> &kVector = splitstr(dnsCureIPCfgStr, '|');
if (kVector.size() > 2) {
const std::vector<std::string> &kVector2 = splitstr(dnsCureIPCfgStr, ';');
if (kVector2.size() > 2) {
_dns_servers.push_back(kVector2[0]); // 主DNS
_dns_servers.push_back(kVector2[1]); // 备DNS
return;
}
}
}

该方法获取到DNS列表,以逗号分隔地址列表,内网外网通过|区分。


通过ConnectivityManager获取:


private static String[] getDnsFromConnectionManager(Context context) {
LinkedList<String> dnsServers = new LinkedList<>();
if (Build.VERSION.SDK_INT >= 21 && context != null) {
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(context.CONNECTIVITY_SERVICE);
if (connectivityManager != null) {
NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
if (activeNetworkInfo != null) {
for (Network network : connectivityManager.getAllNetworks()) {
NetworkInfo networkInfo = connectivityManager.getNetworkInfo(network);
if (networkInfo != null && networkInfo.getType() == activeNetworkInfo.getType()) {
LinkProperties lp = connectivityManager.getLinkProperties(network);
for (InetAddress addr : lp.getDnsServers()) {
dnsServers.add(addr.getHostAddress());
}
}
}
}
}
}
return dnsServers.isEmpty() ? new String[0] : dnsServers.toArray(new String[dnsServers.size()]);
}

获取到的是内网DNS地址。在Android端获取DNS服务地址需要考虑到Android品牌及系统的兼容性。


4. tcp连接耗时统计


TCP耗时从socket创建到连接、收发消息耗时:



  1. 创建socketsocket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

  2. 建立连接:int connectRet = connect(fsocket, (sockaddr*)&_addr, sizeof(_addr));

  3. 发送测试指令:send

  4. 接收消息:recv


5. 总结


本文讨论了统计移动端网络耗时网络质量的主要方法:ping耗时、DNS耗时、TCP连接耗时等。在移动端要考虑到获取DNS服务地址的兼容性、tcp socket读写次数等策略,以及简要介绍了网络质量评估方法。



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

MVVM + RxAndroid + RxView + DataBinding + LiveData + LiveEventBus + Retrofit

前言 本来想记录一下最近相机相关的知识点的,但发现需要时间整理一下,那这里就介绍一下最近写的直播app中使用的整体架构吧。 由于之前项目大多是用MVC,MVP的整体架构,所以这次一个人写直播项目时就干脆用MVVM进行开发(sunflower的架构让我很馋) 简...
继续阅读 »

前言


本来想记录一下最近相机相关的知识点的,但发现需要时间整理一下,那这里就介绍一下最近写的直播app中使用的整体架构吧。


由于之前项目大多是用MVC,MVP的整体架构,所以这次一个人写直播项目时就干脆用MVVM进行开发(sunflower的架构让我很馋)


简介

最后现阶段是 基于 MVVM



  • UI: AndroidX + DataBinding + RxView + Bravh

  • 数据传递: LiveData + LiveEventBus

  • 网络请求: Retrofit + RxAndroid + OkHttp3


 // 分包工具
implementation deps.support.multidex
// androidX
implementation deps.androidX.appcompat
implementation deps.androidX.recyclerview
implementation deps.androidX.constraintLayout
implementation deps.androidX.lifecycle
implementation deps.androidX.palette
// material
implementation deps.material.runtime
// implementation deps.support.design
// implementation deps.support.recyclerview
// 腾讯直播SDK
implementation deps.liteavSdk.liteavsdk_smart
// 自定义采集控件
implementation deps.liveKit.runtime
// OkHttp3 + OkHttp3拦截器 腾讯云需要
implementation deps.okHttp3.runtime
implementation deps.okHttp3.interceptor
// gson
implementation deps.gson.runtime
// 腾讯IM
implementation deps.imsdk.runtime
// Glide
implementation deps.glide.runtime
// 腾讯存储服务
implementation deps.cosxml.runtime
// B站弹幕
implementation deps.DanmakuFlameMaster.runtime
// rxAndroid + rxJava
implementation deps.rxAndroid.runtime
implementation deps.rxAndroid.rxjava
// rxBinding
implementation deps.rxBinding.runtime
// autoDispose
implementation deps.autoDispose.android
implementation deps.autoDispose.lifecycle
// retrofit
implementation deps.retrofit.runtime
implementation deps.retrofit.adapter
implementation deps.retrofit.converter
// xxpermissions
implementation deps.xxpermissions.runtime
// liveEventBus
implementation deps.liveEventBus.runtime
// banner
implementation deps.banner.runtime
// bravh
implementation deps.bravh.runtime
// hilt
// implementation deps.hilt.runtime
// implementation deps.hilt.lifecycle
// kapt deps.hilt.kapt
// kapt deps.hilt.compiler
// leakCanary
debugImplementation deps.leakCanary.runtime

以上就是大致引入的包,然后接下来就是针对业务场景的一整套流程演示了:


登录场景

View

/**
* 登录页面
*/
public class LoginActivity extends MVVMActivity {
private static final String TAG = "LoginActivity";

private LoadingDialog.Builder mLoading; // 加载页面
private ActivityLoginBinding mDataBinding;// DataBinding
private LoginViewModel mViewModel;

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}

@Override
public void initViewModel() {
mDataBinding = DataBindingUtil.setContentView(this, R.layout.activity_login);
ViewModelProvider.Factory factory = new LoginViewModelFactory(getApplication(), this);
mViewModel = ViewModelProviders.of(this, factory).get(LoginViewModel.class);
}

@Override
public void init(){
mLoading = new LoadingDialog.Builder(LoginActivity.this);
mLoading.setMessage(getString(R.string.login_loading_text));
mLoading.create();
}

@Override
public void bindUi(){
// 登录请求
RxView.clicks(mDataBinding.loginBtn)
.subscribeOn(AndroidSchedulers.mainThread())
.to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(this)))
.subscribe(unit ->
PermissionTools.requestPermission(this, () -> // 校验读写权限
mViewModel.Login(mDataBinding.userNameEdt.getText().toString().trim() // 登录请求
, mDataBinding.passwordEdt.getText().toString().trim())
, Permission.READ_PHONE_STATE));
// 注册按钮
RxView.clicks(mDataBinding.registerImg)
.to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(this)))
.subscribe(unit -> startActivity(new Intent(LoginActivity.this, RegisterActivity.class))); // 跳转注册页面
}

/**
* 不带粘性消息
*/
@Override
public void subscribeUi() {
// 页面状态变化通知 带粘性消息
mViewModel.getLoginState().observe(this, state -> {
switch (state) {
case ERROR_CUSTOMER_SUCCESS_PASS: // 通过校验
mLoading.getObj().show();
break;
case ERROR_CUSTOMER_PASSWORD_ERROR: // 账号错误
case ERROR_CUSTOMER_USERNAME_ERROR: // 密码错误
mDataBinding.passwordEdt.setText(""); // 清空密码输入框
ToastUtil.showToast(this, TCErrorConstants.getErrorInfo(state));
break;
}
});

// 登录信息返回通知
LiveEventBus.get(RequestTags.LOGIN_REQ, BaseResponBean.class)
.observe(this, bean -> {
Optional.ofNullable(mLoading).ifPresent(builder -> mLoading.getObj().dismiss()); // 取消 Loading
if (bean.getCode() == 200) { // 登录成功
ToastUtil.showToast(LoginActivity.this, "登录成功!");
startActivity(new Intent(LoginActivity.this, MainActivity.class));
finish();
} else { // 登录失败
ToastUtil.showToast(LoginActivity.this, "登录失败:" + TCErrorConstants.getErrorInfo(bean.getCode()));
mDataBinding.passwordEdt.setText(""); // 清空密码输入框
}
});
}

@Override
public void initRequest() {

}

@Override
protected void onDestroy() {
super.onDestroy();
Optional.ofNullable(mLoading).ifPresent(builder -> mLoading.getObj().dismiss()); // 取消 Loading
}

}

以上的登录View中包含几个模块



  1. initViewModel() 是为了保证MVVM的完整性,进行的VIewModel初始化

  2. init() 用于处理一些View中控件的初始化

  3. bindUi() 是通过RxView,将页面的事件转换成Observable,然后在于ViewModel中具体的功能进行绑定

  4. subscribeUi() 是例如ViewModel中LiveData的变化,或是通过LiveEventBus返回的通知引起的View变化

  5. initRequest() 用于处理刚进入View时就要请求的方法


public abstract class MVVMActivity extends AppCompatActivity {

public abstract void initViewModel();

public abstract void init();

public abstract void bindUi();

public abstract void subscribeUi();

/**
* 请求网络数据
*/
public abstract void initRequest();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
initViewModel();
init();
subscribeUi();
initRequest();
}

@Override
protected void onResume() {
super.onResume();
bindUi();
}
}

以上就是每个方法的调用顺序


ViewModel

public class LoginViewModel extends ViewModel {

private final LoginRepository repository;
private final LifecycleOwner lifecycleOwner;
private final MutableLiveData<Integer> loginState = new MutableLiveData<>(); // 登录失败

public LoginViewModel(LoginRepository repository,LifecycleOwner lifecycleOwner) {
this.repository = repository;
this.lifecycleOwner = lifecycleOwner;
}

/**
* 登录行为
*
* @param userName 账号
* @param passWord 密码
*/
public void Login(String userName, String passWord) {
if (checkInfo(userName, passWord)) {
loginState.postValue(ERROR_CUSTOMER_SUCCESS_PASS);
repository.loginReq(lifecycleOwner, userName, passWord);
}
}

/**
* 检测用户输入的账号密码是否合法
*
* @param userName 账号
* @param passWord 密码
* @return true:通过检测 false:未通过
*/
private boolean checkInfo(String userName, String passWord) {
if (!TCUtils.isUsernameVaild(userName)) {
loginState.postValue(ERROR_CUSTOMER_USERNAME_ERROR);
return false;
}
if (!TCUtils.isPasswordValid(passWord)) {
loginState.postValue(ERROR_CUSTOMER_PASSWORD_ERROR);
return false;
}
return true;
}

public LiveData<Integer> getLoginState() {
return loginState;
}
}

ViewModel作为连通View以及Model之间的通道,负责管理LiveData,以及一些业务上的逻辑,而View尽量通过LiveData的双向绑定实现UI的更新。


Model

这里时Model的代表 Repository


public class LoginRepository extends BaseRepository {

private final static String TAG = "LoginRepository";

private final static String PREFERENCE_USERID = "userid";
private final static String PREFERENCE_USERPWD = "userpwd";

/**
* 单例模式
*/
@SuppressLint("StaticFieldLeak")
private static volatile LoginRepository singleton = null;

/********************************** 本地数据缓存 **************************************/
private LoginResponBean mUserInfo = new LoginResponBean(); // 登录返回后 用户信息存在这
private final LoginSaveBean loginSaveBean = new LoginSaveBean(); // 用于保存用户登录信息
private TCUserMgr.CosInfo mCosInfo = new TCUserMgr.CosInfo(); // COS 存储的 sdkappid

private Context mContext; // 初始化一些组件需要使用

/**
* 初始化缓存数据
*/
private void initData() {
loadUserInfo(); // 是否有缓存账号数据
}

private void loadUserInfo() {
if (mContext == null) return;
TXLog.d(TAG, "xzb_process: load local user info");
SharedPreferences settings = mContext.getSharedPreferences("TCUserInfo", Context.MODE_PRIVATE);
loginSaveBean.setmUserId(settings.getString(PREFERENCE_USERID, ""));
loginSaveBean.setmUserPwd(settings.getString(PREFERENCE_USERPWD, ""));
}

private void saveUserInfo() {
if (mContext == null) return;
TXLog.d(TAG, "xzb_process: save local user info");
SharedPreferences settings = mContext.getSharedPreferences("TCUserInfo", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = settings.edit();
editor.putString(PREFERENCE_USERID, loginSaveBean.getmUserId());
editor.putString(PREFERENCE_USERPWD, loginSaveBean.getmUserPwd());
editor.apply();
}

/**
* 登录请求
*
* @param userName 账号
* @param passWord 密码
*/
public void loginReq(LifecycleOwner lifecycleOwner, String userName, String passWord) {
LoginRequestBuilder.loginFlowable(userName, passWord)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.flatMap((Function<BaseResponBean<LoginResponBean>, Flowable<BaseResponBean<AccountInfoBean>>>) loginBean -> {
if (loginBean != null) { // 登录成功
Optional.ofNullable(loginBean.getData()).ifPresent(userInfo -> mUserInfo = userInfo); // 保存返回的数据
if (loginBean.getMessage() != null) {
LiveEventBus.get(RequestTags.LOGIN_REQ, BaseResponBean.class)
.post(new BaseResponBean<>(loginBean.getCode(), loginBean.getMessage())); // 页面要处理的逻辑(注册返回)
}
if (loginBean.getCode() == 200
&& loginBean.getData() != null
&& loginBean.getData().getToken() != null
&& loginBean.getData().getRoomservice_sign() != null
&& loginBean.getData().getRoomservice_sign().getUserID() != null) {
setToken(loginBean.getData().getToken()); // Token 保存到本地 用于后期请求鉴权
setUserId(loginBean.getData().getRoomservice_sign().getUserID());// UserId 保存到本地 当前登录的账号
initMLVB();// 初始化直播SDK
return LoginRequestBuilder.accountFlowable(getUserId(), getToken()); // 请求账户信息
} else {
return Flowable.error(new ApiException(loginBean.getCode(), loginBean.getMessage())); // 抛出登录异常 不会继续链式调用
}
}
return Flowable.error(new ApiException(-1, "网络异常")); // 抛出登录异常 不会继续链式调用
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(lifecycleOwner)))
.subscribe(new DisposableSubscriber<BaseResponBean<AccountInfoBean>>() {
@Override
public void onNext(BaseResponBean<AccountInfoBean> accountBean) {
if (accountBean != null && accountBean.getCode() == 200) { // 查询账户信息返回
if (accountBean.getData() != null) {
if (accountBean.getData().getAvatar() != null)
loginSaveBean.setmUserAvatar(accountBean.getData().getAvatar()); // 保存用户头像信息
if (accountBean.getData().getNickname() != null)
loginSaveBean.setmUserName(accountBean.getData().getNickname()); // 用户称呼
if (accountBean.getData().getFrontcover() != null)
loginSaveBean.setmCoverPic(accountBean.getData().getFrontcover());// 直播封面?
if (accountBean.getData().getSex() >= 0) {
loginSaveBean.setmSex(accountBean.getData().getSex());// 用户性别
}
}
}
}

@Override
public void onError(Throwable t) {
if (t instanceof ApiException) {
Log.e("TAG", "request error" + ((ApiException) t).getStatusDesc());
} else {
Log.e("TAG", "request error" + t.getMessage());
}
}

@Override
public void onComplete() {

}
});
}



/**
* 注册账号请求
*
* @param username 账户名
* @param password 密码
*/
public void registerReq(LifecycleOwner lifecycleOwner,String username, String password) {
LoginRequestBuilder.registerFlowable(username, password)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.to(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(lifecycleOwner)))
.subscribe(new DisposableSubscriber<BaseResponBean>() {
@Override
public void onNext(BaseResponBean registerBean) {
if (registerBean != null) {
LiveEventBus.get(RequestTags.REGISTER_REQ, BaseResponBean.class)
.post(new BaseResponBean<>(registerBean.getCode(), registerBean.getMessage())); // 页面要处理的逻辑(登录返回)
}
}

@Override
public void onError(Throwable t) {

}

@Override
public void onComplete() {

}
});
}


/**
* 初始化直播SDK
*/
public void initMLVB() {
// 校验数据完整性
if (mUserInfo == null || mContext == null
|| mUserInfo.getRoomservice_sign() == null
|| mUserInfo.getRoomservice_sign().getSdkAppID() == 0
|| mUserInfo.getRoomservice_sign().getUserID() == null
|| mUserInfo.getRoomservice_sign().getUserSig() == null) return;

LoginInfo loginInfo = new LoginInfo();
loginInfo.sdkAppID = mUserInfo.getRoomservice_sign().getSdkAppID();
loginInfo.userID = getUserId();
loginInfo.userSig = mUserInfo.getRoomservice_sign().getUserSig();

String userName = loginSaveBean.getmUserName();
loginInfo.userName = !TextUtils.isEmpty(userName) ? userName : getUserId();
loginInfo.userAvatar = loginSaveBean.getmUserAvatar();
MLVBLiveRoom liveRoom = MLVBLiveRoom.sharedInstance(mContext);
liveRoom.login(loginInfo, new IMLVBLiveRoomListener.LoginCallback() {
@Override
public void onError(int errCode, String errInfo) {
Log.i(TAG, "MLVB init onError: errorCode = " + errInfo + " info = " + errInfo);
}

@Override
public void onSuccess() {
Log.i(TAG, "MLVB init onSuccess: ");
}
});
}

/**
* 自动登录
*/
public void autoLogin() {

}

public void setmContext(Context context) {
this.mContext = context;
initData();
}

public LoginSaveBean getLoginInfo(){
return loginSaveBean;
}

public static LoginRepository getInstance() {
if (singleton == null) {
synchronized (LoginRepository.class) {
if (singleton == null) {
singleton = new LoginRepository();
}
}
}
return singleton;
}
}

除去里面复杂的业务逻辑,可以看到Repository的主要作用是数据仓库,如用单例形式保存一些业务上的数据(用户账户信息),负责处理请求中的业务逻辑,通过RxAndroid和Retrofit的组合,来完成一系列的请求,并通过LiveEventBus或是LiveData来通知页面


HttpRequest

网络请求模块


// LoginRequestBuilder.java
public static Flowable<BaseResponBean<LoginResponBean>> loginFlowable(String userName, String passWord) {
HashMap<String, String> requestParam = new HashMap<>();
requestParam.put("userid", userName);
requestParam.put("password", TCUtils.md5(TCUtils.md5(passWord) + userName));
return RetrofitTools.getInstance(LoginService.class) // 这里是很标准的Retrofit写法
.login(RequestBodyMaker.getRequestBodyForParams(requestParam));
}

// LoginService.java
@POST("/login")
Flowable<BaseResponBean<LoginResponBean>> login(@Body RequestBody requestBody);

// RetrofitTools.java
public static <T> T getInstance(final Class<T> service) {
if (okHttpClient == null) {
synchronized (RetrofitTools.class) {
HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor(new HttpInteraptorLog());
interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
okHttpClient = new OkHttpClient.Builder()
.addInterceptor(interceptor)
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.build();
}
}

if (retrofit == null) {
synchronized (RetrofitTools.class) {
if(retrofit == null) {
retrofit = new Retrofit.Builder()
.baseUrl(TCGlobalConfig.APP_SVR_URL) //BaseUrl
.client(okHttpClient) //请求的网络框架
.addConverterFactory(GsonConverterFactory.create()) //解析数据格式
.addCallAdapterFactory(RxJava3CallAdapterFactory.create()) // 使用RxJava作为回调适配器
.build();
}
}
}
return retrofit.create(service);
}

网络请求返回的Flowable(背压)可以直接通过组合,链式的方式,组合成符合业务逻辑的结构


以上看上去十分简单的一个例子就是糅合了MVVM + RxAndroid + RxView + DataBinding + LiveData + LiveEventBus + Retrofit


一些复杂的列表页面,则加入了Bravh,来优Adapter代码量


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

Android Runtime (ART) 和 Dalvik 小知识,大挑战!

1. Dalvik Dalvik是Google公司自己设计用于Android平台的虚拟机。它可以支持已转换为 .dex(即Dalvik Executable)格式的Java应用程序的运行,.dex 格式是专为Dalvik设计的一种压缩格式,适合内存和处理器速度...
继续阅读 »

1. Dalvik


Dalvik是Google公司自己设计用于Android平台的虚拟机。它可以支持已转换为 .dex(即Dalvik Executable)格式的Java应用程序的运行,.dex 格式是专为Dalvik设计的一种压缩格式,适合内存和处理器速度有限的系统。Dalvik 经过优化,允许在有限的内存中同时运行多个虚拟机的实例,并且每一个Dalvik 应用作为一个独立的Linux 进程执行。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。


在 Android L (Android 5.0) 之前叫作 DVM,5.0 之后直接删除DVM,代替它的是传闻已久的ART(Android Runtime)


在整个 Android 操作系统体系中,ART 位于下图黄色小方块位置:



不是说被删除就无用了,咱们毕竟做这一行的还是要简单的了解一下。


1.1 Dalvik 和 JVM 区别




  • 1、Dalvik 基于寄存器,而 JVM 基于栈。




  • 2、基于寄存器的虚拟机对于更大的程序来讲,在它们编译的时候,花费的时间更短。




  • 3、JVM字节码中,局部变量会被放入局部变量表中,继而被压入堆栈供操做码进行运算,固然JVM也能够只使用堆栈而不显式地将局部变量存入变量表中。




  • 4、Dalvik字节码中,局部变量会被赋给65536个可用的寄存器中的任何一个,Dalvik指令直接操做这些寄存器,而不是访问堆栈中的元素。




1.2 Dalvik 如何运行 java




  • VM字节码由.class文件组成,每一个文件一个class。




  • JVM在运行的时候为每个类装载字节码。相反的,Dalvik程序只包含一个.dex文件,这个文件包含了程序中全部的类。




  • Java编译器建立了JVM字节码以后,Dalvik的dx(d8)编译器删除.class文件,从新把它们编译成Dalvik字节码,而后把它们写进一个.dex文件中。这个过程包括翻译、重构、解释程序的基本元素(常量池、类定义、数据段)。




  • 常量池描述了全部的常量,包括引用、方法名、数值常量等。类定义包括了访问标志、类名等基本信息。数据段中包含各类被VM执行的函数代码以及类和函数的相关信息(例如DVM所须要的寄存器数量、局部变量表、操做数堆栈大小),还有实例变量。




1.3 dex文件


class 文件是由一个 java 源码文件生成的 class 文件,而 Android 是把所有 class 文件进行合并优化,然后生成一个最终的 class.dex 文件。dex 文件去除了 class 文件中的冗余信息(比如重复字符常量),并且结构更加紧凑,因此在 dex 解析阶段,可以减少 I/O 操作,提高了类的查找速度。



实际上,dex 文件在 App 安装过程中还会被进一步优化为 odex(optimized dex),此过程还会在后续介绍安装过程时再次提到。




注意:这一优化过程也会伴随着一些副作用,最经典的就是 Android 65535 问题。



65535


65535 代表 dex 文件中的方法个数、属性个数、以及类的个数。也就是说理论上不止方法数,我们在 java 文件中声明的变量,或者创建的类个数如果也超过 65535 个,同样会编译失败,Android 提供了 MultiDex 来解决这个问题。很多网上的文章说 65535 问题是因为解析 dex 文件到数据结构 DexFile 时,使用了 short 来存储方法的个数,其实这种说法是错误的!


Android 65535问题解决


2. Android Runtime (ART)


Android Runtime (ART) 是运行 Android 5.0(API 级别 21)及更高版本的设备的默认运行时。此运行时提供大量功能,可提升 Android 平台和应用的性能和流畅度。


ART 是 Android 上的应用和部分系统服务使用的托管式运行时。ART 及其前身 Dalvik 最初是专为 Android 项目打造的。作为运行时的 ART 可执行 Dalvik 可执行文件并遵循 Dex 字节码规范。


ART 和 Dalvik 是运行 Dex 字节码的兼容运行时,因此针对 Dalvik 开发的应用也能在 ART 环境中运作。不过,Dalvik 采用的一些技术并不适用于 ART。


2.1 ART 功能


2.1.1 预先 (AOT) 编译


ART 引入了预先编译机制,可提高应用的性能。ART 还具有比 Dalvik 更严格的安装时验证。


在安装时,ART 使用设备自带的 dex2oat 工具来编译应用。此实用工具接受 DEX 文件作为输入,并为目标设备生成经过编译的应用可执行文件。该工具应能够顺利编译所有有效的 DEX 文件。但是,一些后处理工具会生成无效文件,Dalvik 可以接受这些文件,但 ART 无法编译这些文件。


2.1.2 垃圾回收方面的优化


垃圾回收 (GC) 会耗费大量资源,这可能有损于应用性能,导致显示不稳定、界面响应速度缓慢以及其他问题。ART 通过以下几种方式对垃圾回收做了优化:



  • 大多采用并发设计,具有一次 GC 暂停;

  • 并发复制,可减少后台内存使用和碎片;

  • GC 暂停的时间不受堆大小影响;

  • 在清理最近分配的短时对象这种特殊情况中,回收器的总 GC 时间更短;

  • 优化了垃圾回收的工效,能够更加及时地进行并行垃圾回收,这使得 GC_FOR_ALLOC 事件在典型用例中极为罕见。


2.1.3 开发和调试方面的优化


ART 提供了大量功能来优化应用开发和调试。


2.1.3.1 支持采样分析器


一直以来,开发者都使用 Traceview 工具(用于跟踪应用执行情况)作为分析器。虽然 Traceview 可提供有用的信息,但每次方法调用产生的开销会导致 Dalvik 分析结果出现偏差,而且使用该工具明显会影响运行时性能。


ART 添加了对没有这些限制的专用采样分析器的支持,因而可更准确地了解应用执行情况,而不会明显减慢速度。KitKat 版本为 Dalvik 的 Traceview 添加了采样支持。



Traceview:Traceview 已弃用。如果您使用的是 Android Studio 3.2 或更高版本,应改为使用 CPU 性能剖析器 来执行以下操作:检查通过使用 Debug 类检测应用而捕获的 .trace 文件,记录新方法跟踪记录,保存 .trace 文件,以及检查应用进程的实时 CPU 使用情况。



2.1.3.2 支持更多调试功能


ART 支持许多新的调试选项,特别是与监控和垃圾回收相关的功能。例如,您可以:



  • 查看堆栈跟踪中保留了哪些锁,然后跳转到持有锁的线程。

  • 询问指定类的当前活动的实例数、请求查看实例,以及查看使对象保持有效状态的参考。

  • 过滤特定实例的事件(如断点)。

  • 查看方法退出(使用“method-exit”事件)时返回的值。

  • 设置字段观察点,以在访问和/或修改特定字段时暂停程序执行。


2.1.3.3 优化了异常和崩溃报告中的诊断详细信息


当发生运行时异常时,ART 会为您提供尽可能多的上下文和详细信息。ART 会提供 java.lang.ClassCastExceptionjava.lang.ClassNotFoundExceptionjava.lang.NullPointerException 的更多异常详细信息。(较高版本的 Dalvik 会提供 java.lang.ArrayIndexOutOfBoundsExceptionjava.lang.ArrayStoreException 的更多异常详细信息,这些信息现在包括数组大小和越界偏移量;ART 也提供这类信息。)


ART 还通过纳入 Java 和原生堆栈信息,在应用原生代码崩溃报告中提供更实用的上下文信息。


2.2 Android 8.0 中的 ART 功能改进


在 Android 8.0 版本中,Android Runtime (ART) 有了极大改进。下面的列表总结了设备制造商可以在 ART 中获得的增强功能。


2.2.1 并发压缩式垃圾回收器


正如 Google 在 Google I/O 大会上所宣布的那样,ART 在 Android 8.0 中提供了新的并发压缩式垃圾回收器 (GC)。该回收器会在每次执行 GC 时以及应用正在运行时对堆进行压缩,且仅在处理线程根时短暂停顿一次。该回收器具有以下优势:



  • GC 始终会对堆进行压缩:堆的大小平均比 Android 7.0 中的小 32%。

  • 得益于压缩,系统现可实现线程局部碰撞指针对象分配:分配速度比 Android 7.0 中的快 70%。

  • H2 基准的停顿次数比 Android 7.0 GC 的少 85%。

  • 停顿次数不再随堆的大小而变化,应用在使用较大的堆时也无需担心造成卡顿。

  • GC 实现细节 - 读取屏障:

    • 读取屏障是在读取每个对象字段时所做的少量工作。

    • 它们在编译器中经过了优化,但可能会减慢某些用例的速度。




2.2.2 循环优化


在 Android 8.0 版本中,ART 采取了多种循环优化措施,具体如下:



  • 消除边界检查

    • 静态:在编译时证明范围位于边界内

    • 动态:运行时测试确保循环始终位于边界内(否则不进行优化)



  • 消除归纳变量

    • 移除无用归纳

    • 用封闭式表达式替换仅在循环后使用的归纳



  • 消除循环主体内的无用代码,移除整个死循环

  • 强度降低

  • 循环转换:逆转、交换、拆分、展开、单模等

  • SIMDization(也称为矢量化)


循环优化器位于 ART 编译器中一个独立的优化环节中。大多数循环优化与其他方面的优化和简化类似。采用比平时更复杂的方式进行一些重写 CFG 的优化时会面临挑战,因为大多数 CFG 实用工具(请参阅 nodes.h)都侧重于构建而不是重写 CFG。


2.2.3 类层次结构分析


在 Android 8.0 中,ART 会使用类层次结构分析 (CHA),这是一种编译器优化,可根据对类层次结构的分析结果,将虚拟调用去虚拟化为直接调用。虚拟调用代价高昂,因为它们围绕 vtable 查找来实现,且会占用几个依赖负载。另外,虚拟调用也不能内嵌。


以下是对相关增强功能的总结:



  • 动态单一实现方法状态更新 - 在类关联时间结束时,如果 vtable 已被填充,ART 会按条目对超类的 vtable 进行比较。

  • 编译器优化 - 编译器会利用某种方法的单一实现信息。如果方法 A.foo 设置了单一实现标记,则编译器会将虚拟调用去虚拟化为直接调用,并借此进一步尝试内嵌直接调用。

  • 已编译代码无效 - 另外,在类关联时间结束时,如果单一实现信息已更新,且方法 A.foo 之前拥有单一实现,但该状态现已变为无效,则依赖方法 A.foo 拥有单一实现这一假设的所有已编译代码都需要变为无效代码。

  • 去优化 - 对于堆栈上已编译的有效代码,系统会启动去优化功能,以强制使已编译无效代码进入解释器模式,从而确保正确性。系统会采用结合了同步和异步去优化的全新去优化机制。


2.2.4 .oat 文件中的内嵌缓存


ART 现在采用内嵌缓存,并对有足够数据可用的调用站点进行优化。内嵌缓存功能会将额外的运行时信息记录到配置文件中,并利用这类信息将动态优化添加到预先编译中。


2.2.5 Dexlayout


Dexlayout 是在 Android 8.0 中引入的一个库,用于分析 dex 文件,并根据配置文件对其进行重新排序。Dexlayout 旨在使用运行时配置信息,在设备的空闲维护编译期间对 dex 文件的各个部分进行重新排序。通过将经常一起访问的部分 dex 文件集中在一起,程序可以因改进文件位置而拥有更好的内存访问模式,从而节省 RAM 并缩短启动时间。


由于配置文件信息目前仅在运行应用后可用,因此系统会在空闲维护期间将 dexlayout 集成到 dex2oat 的设备编译中。


2.2.6 Dex 缓存移除


在 Android 7.0 及更低版本中,DexCache 对象拥有四个大型数组,与 DexFile 中特定元素的数量成正比,即:



  • 字符串(每个 DexFile::StringId 一个引用),

  • 类型(每个 DexFile::TypeId 一个引用),

  • 方法(每个 DexFile::MethodId 一个原生指针),

  • 字段(每个 DexFile::FieldId 一个原生指针)。


这些数组用于快速检索我们以前解析的对象。在 Android 8.0 中,除方法数组外,所有数组都已移除。


2.2.7 解释器性能


在 Android 7.0 版本中,通过引入 mterp(一种解释器,具有以汇编语言编写的核心提取/解码/解释机制),解释器性能得以显著提升。Mterp 模仿了快速 Dalvik 解释器,并支持 arm、arm64、x86、x86_64、mips 和 mips64。对于计算代码而言,ART 的 Mterp 大致相当于 Dalvik 的快速解释器。不过,有时候,它的速度可能会显著变慢,甚至急剧变慢:



  • 调用性能。

  • 字符串操作和 Dalvik 中其他被视为内嵌函数的高频用户方法。

  • 堆栈内存使用量较高。


Android 8.0 解决了这些问题。


2.2.8 详细了解内嵌


从 Android 6.0 开始,ART 可以内嵌同一个 dex 文件中的任何调用,但只能内嵌来自其他 dex 文件的叶方法。此项限制具有以下两个原因:



  • 从其他 dex 文件进行内嵌要求使用该 dex 文件的 dex 缓存,这与同一个 dex 文件内嵌(只需重复使用调用方的 dex 缓存)有所不同。已编译代码中需要具有 dex 缓存,以便执行一系列指令,例如静态调用、字符串加载或类加载。

  • 堆栈映射只对当前 dex 文件中的方法索引进行编码。


为了应对这些限制,Android 8.0 做出了以下改进:



  • 从已编译代码中移除 dex 缓存访问(另请参阅“Dex 缓存移除”部分)

  • 扩展堆栈映射编码。


2.2.9 同步方面的改进


ART 团队调整了 MonitorEnter/MonitorExit 代码路径,并减少了我们对 ARMv8 上传统内存屏障的依赖,尽可能将其替换为较新的(获取/释放)指令。


2.2.10 更快速的原生方法


使用 @FastNative@CriticalNative 注解可以更快速地对 Java 原生接口 (JNI) 进行原生调用。这些内置的 ART 运行时优化可以加快 JNI 转换速度,并取代了现已弃用的 !bang JNI 标记。这些注解对非原生方法没有任何影响,并且仅适用于 bootclasspath 上的平台 Java 语言代码(无 Play 商店更新)。


@FastNative 注解支持非静态方法。如果某种方法将 jobject 作为参数或返回值进行访问,请使用此注解。


利用 @CriticalNative 注解,可更快速地运行原生方法,但存在以下限制:



  • 方法必须是静态方法 - 没有参数、返回值或隐式 this 的对象。

  • 仅将基元类型传递给原生方法。

  • 原生方法在其函数定义中不使用 JNIEnv 和 jclass 参数。

  • 方法必须使用 RegisterNatives 进行注册,而不是依靠动态 JNI 链接。



@FastNative 和 @CriticalNative 注解在执行原生方法时会停用垃圾回收。不要与长时间运行的方法一起使用,包括通常很快但一般不受限制的方法。




停顿垃圾回收可能会导致死锁。如果锁尚未得到本地释放(即尚未返回受管理代码),请勿在原生快速调用期间获取锁。此要求不适用于常规的 JNI 调用,因为 ART 将正执行的原生代码视为已暂停的状态。




@FastNative 可以使原生方法的性能提升高达 3 倍,而 @CriticalNative 可以使原生方法的性能提升高达 5 倍。



更多详情:官方:Android Runtime (ART) 和 Dalvik


3. 内存管理


Android Runtime (ART) 和 Dalvik 虚拟机使用分页和内存映射来管理内存。这意味着应用修改的任何内存,无论修改的方式是分配新对象还是轻触内存映射的页面,都会一直驻留在 RAM 中,并且无法换出。要从应用中释放内存,只能释放应用保留的对象引用,使内存可供垃圾回收器回收。这种情况有一个例外:对于任何未经修改的内存映射文件(如代码),如果系统想要在其他位置使用其内存,可将其从 RAM 中换出。


3.1 ART GC 概览


ART 有多个不同的 GC 方案,涉及运行不同的垃圾回收器。从 Android 8 (Oreo) 开始,默认方案是并发复制 (CC)。另一个 GC 方案是并发标记清除 (CMS)


并发复制 GC 的一些主要特性包括:



  • CC 支持使用名为“RegionTLAB”的触碰指针分配器。此分配器可以向每个应用线程分配一个线程本地分配缓冲区 (TLAB),这样,应用线程只需触碰“栈顶”指针,而无需任何同步操作,即可从其 TLAB 中将对象分配出去。

  • CC 通过在不暂停应用线程的情况下并发复制对象来执行堆碎片整理。这是在读取屏障的帮助下实现的,读取屏障会拦截来自堆的引用读取,无需应用开发者进行任何干预。

  • GC 只有一次很短的暂停,对于堆大小而言,该次暂停在时间上是一个常量。

  • 在 Android 10 及更高版本中,CC 会扩展为分代 GC。它支持轻松回收存留期较短的对象,这类对象通常很快便会无法访问。这有助于提高 GC 吞吐量,并显著延迟执行全堆 GC 的需要。


ART 仍然支持的另一个 GC 方案是 CMS。此 GC 方案还支持压缩,但不是以并发方式。在应用进入后台之前,它会避免执行压缩,应用进入后台后,它会暂停应用线程以执行压缩。如果对象分配因碎片而失败,也必须执行压缩操作。在这种情况下,应用可能会在一段时间内没有响应。


由于 CMS 很少进行压缩,因此空闲对象可能会不连续。CMS 使用一个名为 RosAlloc 的基于空闲列表的分配器。与 RegionTLAB 相比,该分配器的分配成本较高。最后,由于内部碎片,Java 堆的 CMS 内存用量可能会高于 CC 内存用量。


CMS具体内容可参考:Java 垃圾回收(GC)


3.2 垃圾回收


ART 或 Dalvik 虚拟机之类的受管内存环境会跟踪每次内存分配。一旦确定程序不再使用某块内存,它就会将该内存重新释放到堆中,无需程序员进行任何干预。这种回收受管内存环境中的未使用内存的机制称为"垃圾回收"。垃圾回收有两个目标:



  • 在程序中查找将来无法访问的数对象

  • 回收这些对象使用的资源。


Android 的内存堆是分代的,这意味着它会根据分配对象的预期寿命和大小跟踪不同的分配存储分区。


可参考:Java 垃圾回收(GC)


3.3 共享内存


为了在 RAM 中容纳所需的一切,Android 会尝试跨进程共享 RAM 页面。它可以通过以下方式实现这一点:



  • 每个应用进程都从一个名为 Zygote 的现有进程 fork。可参考:源码解读-应用是如何启动的

  • 大多数静态数据会内存映射到一个进程中。这种方法使得数据不仅可以在进程之间共享,还可以在需要时换出。静态数据示例包括:Dalvik 代码、应用资源和 lib 中的文件。

  • Android 使用明确分配的共享内存区域(通过 ashmem 或 gralloc)在进程间共享同一动态 RAM。例如,窗口 surface 使用在应用和屏幕合成器之间共享的内存,而光标缓冲区则使用在内容提供器和客户端之间共享的内存。


由于共享内存的广泛使用,在确定应用使用的内存量时需要小心谨慎。


3.4 分配与回收应用内存


Dalvik 堆局限于每个应用进程的单个虚拟内存范围。这定义了逻辑堆大小,该大小可以根据需要增长,但不能超过系统为每个应用定义的上限


堆的逻辑大小与堆使用的物理内存量不同。在检查应用堆时,Android 会计算按比例分摊的内存大小 (PSS) 值,该值同时考虑与其他进程共享的脏页和干净页,但其数量与共享该 RAM 的应用数量成正比。此 (PSS) 总量是系统认为的物理内存占用量。


Dalvik 堆不压缩堆的逻辑大小,这意味着 Android 不会对堆进行碎片整理来缩减空间。只有当堆末尾存在未使用的空间时,Android 才能缩减逻辑堆大小。但是,系统仍然可以减少堆使用的物理内存。垃圾回收之后,Dalvik 遍历堆并查找未使用的页面,然后使用 madvise 将这些页面返回给内核。因此,大数据块的配对分配和解除分配应该使所有(或几乎所有)使用的物理内存被回收。但是,从较小分配量中回收内存的效率要低得多,因为用于较小分配量的页面可能仍在与其他尚未释放的数据块共享。


3.5 限制应用内存


为了维持多任务环境的正常运行,Android 会为每个应用的堆大小设置硬性上限。不同设备的确切堆大小上限取决于设备的总体可用 RAM 大小。如果您的应用在达到堆容量上限后尝试分配更多内存,则可能会收到 OutOfMemoryError


在某些情况下,你可以通过调用 getMemoryClass() 向系统查询此确切可用的堆空间大小。


3.6 切换应用


当用户在应用之间切换时,Android 会将非前台应用保留在缓存中。非前台应用就是指用户看不到或未运行前台服务(如音乐播放)的应用。


例如,当用户首次启动某个应用时,系统会为其创建一个进程;但是当用户离开此应用时,该进程不会退出。系统会将该进程保留在缓存中。如果用户稍后返回该应用,系统就会重复使用该进程,从而加快应用切换速度。


如果你的应用具有缓存的进程且保留了目前不需要的资源,那么即使用户未使用您的应用,它也会影响系统的整体性能。当系统资源(如内存)不足时,它将会终止缓存中的进程。系统还会考虑终止占用最多内存的进程以释放 RAM。


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

优雅的使用注释

代码千万行,注释第一行。 代码不规范,同事泪两行。 前言 注释相信小伙伴们都不陌生,但是就是这个小小的注释就像项目文档一样让许多小伙伴又爱又恨。不喜欢写注释,又讨厌别人不写注释。在此我们将讨论 JavaScript 和 CSS 的注释,希望通过这篇文章,让你...
继续阅读 »

代码千万行,注释第一行。

代码不规范,同事泪两行。



前言


注释相信小伙伴们都不陌生,但是就是这个小小的注释就像项目文档一样让许多小伙伴又爱又恨。不喜欢写注释,又讨厌别人不写注释。在此我们将讨论 JavaScriptCSS 的注释,希望通过这篇文章,让你重拾对注释的喜爱,让编码的乐趣如星辰大海。


一、语法


1.1 CSS 注释


/* css 注释 */

1.2 JavaScript 注释


// 单行注释

/**
* 多行注释,注意第一行最好用两个 *
* ...
*/

/*
当然,除了两端的 * 必须加以外,其他的 * 不加也行
...
*/


二、基本使用


2.1 单行注释


一般情况下,单行注释会出现在代码的正上方,起到提示的作用:


/* 用注释备注 CSS 类名的功能 */

/* 顶部组件 */
.hd {
position: fixed;
width: 100vw;
}

/* 版心 */
.container {
margin: 16px auto;
width: 1200px;
}

// 用单行注释备注简单的信息

const userName = ""; // 用户名
const userAvatar = ""; // 用户头像

// xxx函数
const myFunction = () => {};

2.2 多行注释


多行注释一般用于需要备注的信息过多的情况,常常出没于 JavaScript 函数的附近。首先提出一个问题:为什么要用到多行注释,用单行注释不香吗?下面就来看看下面的代码:


// xxx函数
const myFunction = ({ id, name, avatar, list, type }) => {
// 此处省略 30 行代码
};

小伙伴们可能看到了,一个传入五个参数,内部数行代码的函数竟然只有短短的一行注释,也许你开发的时候能记住这个函数的用途以及参数的类型以及是否必传等,但是如果你隔了一段时间再回头看之前的代码,那么简短的注释就可能变成你的困扰。 更不用说没有注释,不写注释一时爽,回看代码火葬场。 写注释的目的在于提高代码的可读性。相比之下,下面的注释就清晰的多:


/**
* 调整滚动距离
* 用于显示给定 id 元素
* @param id string 必传 元素 id
* @param distance number 非必传 距离视口最顶部距离(避免被顶部固定定位元素遮挡)
* @returns null
*/
export const scrollToShowElement = (id = "", distance = 0) => {
return () => {
if (!id) {
return;
};

const element = document.getElementById(id);
if (!element) {
return;
};

const top = element?.offsetTop || 0;
window.scroll(0, top - distance);
};
};

对于复杂的函数,函数声明上面要加上统一格式的多行注释,同时内部的复杂逻辑和重要变量也需要加上单行注释,两者相互配合,相辅相成。函数声明的多行注释格式一般为:


/**
* 函数名称
* 函数简介
* @param 参数1 参数1数据类型 是否必传 参数1描述
* @param 参数2 参数2数据类型 是否必传 参数2描述
* @param ...
* @returns 返回值
*/

多行注释的优点是清晰明了,缺点是较为繁琐(可以借助编辑器生成 JavaScript 函数注释模板)。建议逻辑简单的函数使用单行注释,逻辑复杂的函数和公共/工具函数使用多行注释。


当然,一个好的变量/函数名也能降低阅读者的思考成本,可以移步到我的文章:《优雅的命名 🧊🧊》


三、进阶使用


无论是 css 还是 JavaScript 中,当代码越来越多的时候,也使得寻找要改动的代码时变得越来越麻烦。所以我们有必要对代码按模块进行整理,并在每个模块的顶部用注释,结束时使用空行进行分割。


 /* 以下代码仅为示例 */

/* 模块1 */
/* 类名1 */
.class-a {}

/* 类名2 */
.class-b {}

/* 类名3 */
.class-c {}

/* 模块2 */
/* 类名4 */
.class-d {}

/* 类名5 */
.class-e {}

/* ... */

// 以下代码仅为示例

// 模块1
// 变量1
const value1 = "";
// 变量2
const value2 = "";
// 变量3
const value3 = "";
// 函数1
const myFunction1 = () => {};

// 模块2
// 变量4
const value4 = "";
// 变量5
const value5 = "";
// 变量6
const value6 = "";
// 函数2
const myFunction2 = () => {};

// ...

效果有了,但是似乎不太明显,因此我们在注释中增加 - 或者 = 来进行分割试试:


 /* ------------------------ 模块1 ------------------------ */
/* 类名1 */
.class-a {}

/* 类名2 */
.class-b {}

/* 类名3 */
.class-c {}

/* ------------------------ 模块2 ------------------------ */
/* 类名4 */
.class-d {}

/* 类名5 */
.class-e {}

/* ... */

// 以下代码仅为示例

/* ======================== 模块1 ======================== */
// 变量1
const value1 = "";
// 变量2
const value2 = "";
// 变量3
const value3 = "";
// 函数1
const myFunction1 = () => {};

/* ======================== 模块2 ======================== */
// 变量4
const value4 = "";
// 变量5
const value5 = "";
// 变量6
const value6 = "";
// 函数2
const myFunction2 = () => {};

// ...

能直观的看出,加长版的注释分割效果更好,区分度更高。高质量的代码往往需要最朴实无华的注释进行分割。其中 JavaScript 的注释“分割线”建议使用多行注释。




“华丽的”分割线:


 /* ------------------------ 华丽的分割线 ------------------------ */

/* ======================== 华丽的分割线 ======================== */

四、扩展


工欲善其事,必先利其器。下面我要推荐几款 VSCode 编辑器关于注释的插件。


4.1 Better Comments


Better Comments.png


插件介绍:可以改变注释的颜色,有四种高亮的颜色(默认为红色、橙色、绿色、蓝色)和一种带删除线的黑色。颜色可以在插件配置里面修改。下图为实例颜色和本人在项目中的用法,一个注释对应一种情况。


注释的默认颜色.png


喜欢花里胡哨的coder们必备插件,有效提高注释的辨析度和美感,从此爱上注释。其改变注释颜色只需要加上一个或多个字符即可,开箱即用。


// ! 红色的高亮注释,双斜线后加英文叹号     !     配置
// todo 橙色的高亮注释,双斜线后加 todo 函数
// * 绿色的高亮注释,双斜线后加 * 变量
// ? 蓝色的高亮注释,双斜线后加英文问号 ? 组件
// // 黑色带删除线的注释,双斜线后加双斜线 // 说明

4.2 koroFileHeader


koroFileHeader.png


插件介绍:文件头部添加注释,在光标处添加函数注释,一键添加佛祖保佑永无BUG、神兽护体等注释图案。


koroFileHeader 说明.png


4.3 JavaScript Comment Snippet


JavaScript Comment Snippet.png


插件介绍:可以快速生成 JavaScript 注释,冷门但是好用。


JavaScript Comment Snippet 使用.gif
JavaScript Comment Snippet 使用.png
JavaScript Comment Snippet 使用.png


结语


不得不说注释在编码过程中真的相当重要,为了写出更优雅,更易于维护的代码,我们也应当把最重要的信息写到注释里。一个项目的 README.markdown 和项目中的注释就喜像是项目的 说明书 一样,能让非项目开发者更快的读懂代码的含义以及编码的思想。让代码成就我们,让代码改变世界,让注释,伴我同行!



收起阅读 »

技术总结 | 前端萌新现在上车Docker,还来得及么?

序言 作为一名爱学习的前端攻城狮,在当下疯狂内卷的大环境🐱, 不卷一卷Docker是不是有点说不过去,再加上现在我司前端部署项目大部分都是Docker,所以现在赶紧上车, 跟着Up主来look look,欢迎有big old指正 Q:你能说一下你怎么看待Do...
继续阅读 »

序言


作为一名爱学习的前端攻城狮,在当下疯狂内卷的大环境🐱, 不卷一卷Docker是不是有点说不过去,再加上现在我司前端部署项目大部分都是Docker,所以现在赶紧上车, 跟着Up主来look look,欢迎有big old指正



  • Q:你能说一下你怎么看待DockerDocker能干什么么

  • A:Docker是一个便携的应用容器, 用来自动化测试和持续集成、发布


大家在面试的时候是不是这么回答的😂,恭喜你答对了,但是不够完整,现在来结合文档和Demo具体看看,Docker到底能干啥


概念


什么是Docker


Docker就好比是一个集装箱,里面装着各式各类的货物。在一艘大船上,可以把货物规整的摆放起来。并且各种各样的货物被集装箱标准化了,集装箱和集装箱之间不会互相影响。


有人觉得Docker是一台虚拟机,但是这种想法是错误的,直接上图


8931c4f83956f72f924f9c30aee3c40.png



上图差异,左图虚拟机的Guest OS层和Hypervisor层在Docker中被Docker Engine层所替代。虚拟机的Guest OS即为虚拟机安装的操作系统,它是一个完整操作系统内核;虚拟机的Hypervisor层可以简单理解为一个硬件虚拟化平台,它在Host OS是以内核态的驱动存在的。



三大核心概念


镜像(image)


镜像是创建docker容器的基础,docker镜像类似于虚拟机镜像,可以将它理解为一个面向docker引擎的只读模块,包含文件系统


创建镜像的方式



  1. 使用Dockerfile Build镜像

  2. 拉取Docker官方镜像


容器(container)


容器是从镜像创建的应用运行实例,容器之间是相互隔离、互不可见的。可以把容器看做一个简易版的linux系统环境(包括root权限、进程空间、用户空间和网络空间等),以及运行在这个环境上的应用打包而成的应用盒子。


可以利用docker create命令创建一个容器,创建后的的容器处于停止状态,可以使用docker start命令来启动它。也可以运行docker run命令来直接从镜像启动运行一个容器。docker run = docker creat + docker start


当利用docker run创建并启动一个容器时,docker在后台的标准操作包括:


(1)检查本地是否存在指定的镜像,不存在就从公有仓库下载。

(2)利用镜像创建并启动一个容器。

(3)分配一个文件系统,并在只读的镜像层外面挂载一层可读写层。

(4)从宿主机配置的网桥接口中桥接一个虚拟的接口到容器中。

(5)从地址池中配置一个IP地址给容器。

(6)执行用户指定的应用程序。

(7)执行完毕后容器终止。


仓库(Repository)


安装Docker后,可用通过官方提供的registry镜像来搭建一套本地私有仓库环境。


下载registry镜像:


6246e8b8ff24b58a0bdf74ab6de1e30.png


基础操作


安装Docker


linux安装Docker


8d75417f1599a5e7716278780346a8e.png


windows安装docker


推荐安装Docker Desktop 飞机票


image.png


拉取镜像


# 拉取镜像
>>> docker pull ubuntu
Using default tag: latest
latest: Pulling from library/ubuntu
f3ef4ff62e0d: Pull complete
Digest: sha256:a0d9e826ab87bd665cfc640598a871b748b4b70a01a4f3d174d4fb02adad07a9
Status: Downloaded newer image for ubuntu:latest
docker.io/library/ubuntu:latest

# 查看本地所有镜像
>>> docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu latest 597ce1600cf4 13 days ago 72.8MB
hello latest 8b9d88b05a48 2 weeks ago 231MB
centos latest 5d0da3dc9764 4 weeks ago 231MB
docker/getting-started latest 083d7564d904 4 months ago 28MB

# 删除镜像
>>> docker rmi ubuntu
Untagged: ubuntu:latest
Untagged: ubuntu@sha256:a0d9e826ab87bd665cfc640598a871b748b4b70a01a4f3d174d4fb02adad07a9
Deleted: sha256:597ce1600cf4ac5f449b66e75e840657bb53864434d6bd82f00b172544c32ee2
Deleted: sha256:da55b45d310bb8096103c29ff01038a6d6af74e14e3b67d1cd488c3ab03f5f0d


创建容器


#创建容器
>>> docker create --name my-ubuntu ubuntu
2da5d12e9cbaed77d90d23f5f5436215ec511e20607833a5a674109c13b58f48

#启动容器
>>> docker start 2da5d

#查看所有容器
>>> docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
2da5d12e9cba ubuntu "bash" About a minute ago Exited (0) 31 seconds ago my-ubuntu

#删除容器
>>> docker rm 2da5d

#创建并进入容器
>>> docker run --name my-ubuntu2 -it ubuntu
root@552c7c73dcf6:/#
#进入容器后就可以在容器内部执行脚本了

# 进入正在运行的容器
>>> docker exec -it 2703b1 sh
/ #


编排Dockerfile


Dockerfile是一个创建镜像所有命令的文本文件, 包含了一条条指令和说明, 每条指令构建一层, 通过docker build命令,根据Dockerfile的内容构建镜像,因此每一条指令的内容, 就是描述该层如何构建.有了Dockefile, 就可以制定自己的docker镜像规则,只需要在Dockerfile上添加或者修改指令, 就可生成docker镜像.


FROM ubuntu          #构造的新镜像是基于哪个镜像
MAINTAINER Up_zhu #维护者信息
RUN yum install nodejs #构建镜像时运行的shell命令
WORKDIR /app/my-app #设置工作路径
EXPOSE 8080 #指定于外界交互的端口,即容器在运行时监听的端口
ENV MYSQL_ROOT_PASSWORD 123456 #设置容器内环境变量
ADD ./config /app/config #拷贝文件或者目录到镜像,如果是URL或者压缩包会自动下载或者自动解压
COPY ./dist /app/my-app
VOLUME /etc/mysql #定义匿名卷

实战



基于vite项目打镜像,发布



新建Dockerfile


FROM nginx
COPY ./dist/ /usr/share/nginx/html/
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf

新建nginx配置文件


# nginx/default.conf
server {
listen 80;
server_name localhost;

#charset koi8-r;
access_log /var/log/nginx/host.access.log main;
error_log /var/log/nginx/error.log error;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
}

#error_page 404 /404.html;

# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}


打镜像


image.png


查看本地镜像


>>> docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
my-vite latest cc015756264b About a minute ago 133MB

启动容器


image.png


现在可以访问地址来验证是否成功


image.png


查看本地正在运行的容器


image.png


文末


是不是很Easy呢?我们从上面可以看出,Docker 的功能是十分强大的,除此之外,我们还可以拉取一些 UbuntuApache 等镜像, 也可以自己定制一下镜像,发布到Docker Hub.


image.png


当然!本文介绍的只是Docker的基础功能,小编能力到此,还需继续学习~



收起阅读 »

实现无感刷新token,我是这样做的

前言 最近在做需求的时候,涉及到登录token,产品提出一个问题:能不能让token过期时间长一点,我频繁的要去登录。 前端:后端,你能不能把token 过期时间设置的长一点。 后端:可以,但是那样做不安全,你可以用更好的方法。 前端:什么方法? 后端:给你...
继续阅读 »

前言



最近在做需求的时候,涉及到登录token,产品提出一个问题:能不能让token过期时间长一点,我频繁的要去登录。


前端:后端,你能不能把token 过期时间设置的长一点。


后端:可以,但是那样做不安全,你可以用更好的方法。


前端:什么方法?


后端:给你刷新token的接口,定时去刷新token


前端:好,让我思考一下



需求



当token过期的时候,刷新token,前端需要做到无感刷新token,即刷token时要做到用户无感知,避免频繁登录。实现思路


方法一


后端返回过期时间,前端判断token过期时间,去调用刷新token接口


缺点:需要后端额外提供一个token过期时间的字段;使用了本地时间判断,若本地时间被篡改,特别是本地时间比服务器时间慢时,拦截会失败。


方法二


写个定时器,定时刷新token接口


缺点:浪费资源,消耗性能,不建议采用。


方法三


在响应拦截器中拦截,判断token 返回过期后,调用刷新token接口



实现



axios的基本骨架,利用service.interceptors.response进行拦截


import axios from 'axios'

service.interceptors.response.use(
  response => {
    if (response.data.code === 409) {
        return refreshToken({ refreshToken: localStorage.getItem('refreshToken'), token: getToken() }).then(res => {
          const { token } = res.data
          setToken(token)
          response.headers.Authorization = `${token}`
        }).catch(err => {
          removeToken()
          router.push('/login')
          return Promise.reject(err)
        })
    }
    return response && response.data
  },
  (error) => {
    Message.error(error.response.data.msg)
    return Promise.reject(error)
  })



问题解决



问题一:如何防止多次刷新token


我们通过一个变量isRefreshing 去控制是否在刷新token的状态。


import axios from 'axios'

service.interceptors.response.use(
  response => {
    if (response.data.code === 409) {
      if (!isRefreshing) {
        isRefreshing = true
        return refreshToken({ refreshToken: localStorage.getItem('refreshToken'), token: getToken() }).then(res => {
          const { token } = res.data
          setToken(token)
          response.headers.Authorization = `${token}`
        }).catch(err => {
          removeToken()
          router.push('/login')
          return Promise.reject(err)
        }).finally(() => {
          isRefreshing = false
        })
      }
    }
    return response && response.data
  },
  (error) => {
    Message.error(error.response.data.msg)
    return Promise.reject(error)
  })

问题二:同时发起两个或者两个以上的请求时,其他接口怎么解决


当第二个过期的请求进来,token正在刷新,我们先将这个请求存到一个数组队列中,想办法让这个请求处于等待中,一直等到刷新token后再逐个重试清空请求队列。那么如何做到让这个请求处于等待中呢?为了解决这个问题,我们得借助Promise。将请求存进队列中后,同时返回一个Promise,让这个Promise一直处于Pending状态(即不调用resolve),此时这个请求就会一直等啊等,只要我们不执行resolve,这个请求就会一直在等待。当刷新请求的接口返回来后,我们再调用resolve,逐个重试。最终代码:


import axios from 'axios'

// 是否正在刷新的标记
let isRefreshing = false
//重试队列
let requests = []
service.interceptors.response.use(
response => {
//约定code 409 token 过期
if (response.data.code === 409) {
if (!isRefreshing) {
isRefreshing = true
//调用刷新token的接口
return refreshToken({ refreshToken: localStorage.getItem('refreshToken'), token: getToken() }).then(res => {
const { token } = res.data
// 替换token
setToken(token)
response.headers.Authorization = `${token}`
// token 刷新后将数组的方法重新执行
requests.forEach((cb) => cb(token))
requests = [] // 重新请求完清空
return service(response.config)
}).catch(err => {
//跳到登录页
removeToken()
router.push('/login')
return Promise.reject(err)
}).finally(() => {
isRefreshing = false
})
} else {
// 返回未执行 resolve 的 Promise
return new Promise(resolve => {
// 用函数形式将 resolve 存入,等待刷新后再执行
requests.push(token => {
response.headers.Authorization = `${token}`
resolve(service(response.config))
})
})
}
}
return response && response.data
},
(error) => {
Message.error(error.response.data.msg)
return Promise.reject(error)
}
)

作者:远航_
链接:https://juejin.cn/post/7018439775476514823

收起阅读 »

Android 热修复核心原理,ClassLoader类加载

Android 热修复核心原理,ClassLoader类加载[TOC]Android前沿技术探讨:ClassLoader在热修复中的应用又在写bug?这句话虽然是句玩笑话,但是也正因为我们是人不是神,但也不能面面俱到,什么都考虑完美,出现bug是不可避免的。那...
继续阅读 »

Android 热修复核心原理,ClassLoader类加载

[TOC]Android前沿技术探讨:ClassLoader在热修复中的应用

又在写bug?这句话虽然是句玩笑话,但是也正因为我们是人不是神,但也不能面面俱到,什么都考虑完美,出现bug是不可避免的。那么对于android我们出现了Bug怎么办?

早期遇到Bug我们一般会紧急发布了一个版本。然而这个Bug可能就是简简单单的一行代码,为了这一行代码,进行全量或者增量更新迭代一个版本,未免有点大材小用了。而且新版本的普及需要时间,而且如果这次的新版本又有个小问题,怎么办?

那么为了解决这一个问题,热修复出现了。

热修复,现在大家应该都不陌生。从16年开始开始,热修复技术在 Android 技术社区热了一阵子,这种不用发布新版本就可以修复线上 bug 的技术看起来非常黑科技。

本章节的目的并不在于热修复本身,主要是通过热修复这个案例熟悉其核心:类加载机制。(

ART 和 Dalvik

DVM也是实现了JVM规范的一个虚拟器,默认使用CMS垃圾回收器,但是与JVM运行 Class 字节码不同,DVM 执行 Dex(Dalvik Executable Format) ——专为 Dalvik 设计的一种压缩格式。Dex 文件是很多 .class 文件处理压缩后的产物,最终可以在 Android 运行时环境执行。

ART(Android Runtime) 是在 Android 4.4 中引入的一个开发者选项,也是 Android 5.0 及更高版本的默认 Android 运行时。ART 和 Dalvik 都是运行 Dex 字节码的兼容运行时,因此针对 Dalvik 开发的应用也能在 ART 环境中运作。

source.android.google.cn/devices/tec…

dexopt与dexaot

  • dexopt

    Dalvik中虚拟机在加载一个dex文件时,对 dex 文件 进行 验证 和 优化的操作,其对 dex 文件的优化结果变成了 odex(Optimized dex) 文件,这个文件和 dex 文件很像,只是使用了一些优化操作码。

  • dex2oat

    ART 预先编译机制,在安装时对 dex 文件执行dexopt优化之后再将odex进行 AOT 提前编译操作,编译为OAT(实际上是ELF文件)可执行文件(机器码)。(相比做过ODEX优化,未做过优化的DEX转换成OAT要花费更长的时间)

image.png

ClassLoader介绍

任何一个 Java 程序都是由一个或多个 class 文件组成,在程序运行时,需要将 class 文件加载到 JVM 中才可以使用,负责加载这些 class 文件的就是 Java 的类加载机制。ClassLoader 的作用简单来说就是加载 class 文件,提供给程序运行时使用。每个 Class 对象的内部都有一个 classLoader 字段来标识自己是由哪个 ClassLoader 加载的。

class Class<T> {
...
 private transient ClassLoader classLoader;
...
}

ClassLoader是一个抽象类,而它的具体实现类主要有:

  • BootClassLoader

    用于加载Android Framework层class文件。

  • PathClassLoader

    用于Android应用程序类加载器。可以加载指定的dex,以及jar、zip、apk中的classes.dex

  • DexClassLoader

    用于加载指定的dex,以及jar、zip、apk中的classes.dex

    很多博客里说PathClassLoader只能加载已安装的apk的dex,其实这说的应该是在dalvik虚拟机上。

    但现在一般不用关心dalvik了。

    Log.e(TAG, "Activity.class 由:" + Activity.class.getClassLoader() +" 加载");
    Log.e(TAG, "MainActivity.class 由:" + getClassLoader() +" 加载");


    //输出:
    Activity.class 由:java.lang.BootClassLoader@d3052a9 加载

    MainActivity.class 由:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.enjoy.enjoyfix-1/base.apk"],nativeLibraryDirectories=[/data/app/com.enjoy.enjoyfix-1/lib/x86, /system/lib, /vendor/lib]]] 加载

    它们之间的关系如下:

image.png

PathClassLoaderDexClassLoader的共同父类是BaseDexClassLoader

public class DexClassLoader extends BaseDexClassLoader {

   public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
}
}

public class PathClassLoader extends BaseDexClassLoader {

   public PathClassLoader(String dexPath, ClassLoader parent) {
       super(dexPath, null, null, parent);
  }

public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent){
super(dexPath, null, librarySearchPath, parent);
}
}

可以看到两者唯一的区别在于:创建DexClassLoader需要传递一个optimizedDirectory参数,并且会将其创建为File对象传给super,而PathClassLoader则直接给到null。因此两者都可以加载指定的dex,以及jar、zip、apk中的classes.dex

PathClassLoader pathClassLoader = new PathClassLoader("/sdcard/xx.dex", getClassLoader());

File dexOutputDir = context.getCodeCacheDir();
DexClassLoader dexClassLoader = new DexClassLoader("/sdcard/xx.dex",dexOutputDir.getAbsolutePath(), null,getClassLoader());

其实,optimizedDirectory参数就是dexopt的产出目录(odex)。那PathClassLoader创建时,这个目录为null,就意味着不进行dexopt?并不是,optimizedDirectory为null时的默认路径为: /data/dalvik-cache

在API 26源码中,将DexClassLoader的optimizedDirectory标记为了 deprecated 弃用,实现也变为了:

public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}

......和PathClassLoader一摸一样了!

双亲委托机制

可以看到创建ClassLoader需要接收一个ClassLoader parent参数。这个parent的目的就在于实现类加载的双亲委托。即:

某个类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{

   // 检查class是否有被加载  
Class c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
               //如果parent不为null,则调用parent的loadClass进行加载  
c = parent.loadClass(name, false);
          } else {
               //parent为null,则调用BootClassLoader进行加载  
               c = findBootstrapClassOrNull(name);
          }
      } catch (ClassNotFoundException e) {

      }

       if (c == null) {
           // 如果都找不到就自己查找
long t1 = System.nanoTime();
           c = findClass(name);
      }
}
return c;
}

因此我们自己创建的ClassLoader: new PathClassLoader("/sdcard/xx.dex", getClassLoader());并不仅仅只能加载 xx.dex中的class。

值得注意的是:c = findBootstrapClassOrNull(name);

按照方法名理解,应该是当parent为null时候,也能够加载BootClassLoader加载的类。

new PathClassLoader("/sdcard/xx.dex", null),能否加载Activity.class?

但是实际上,Android当中的实现为:(Java不同)

private Class findBootstrapClassOrNull(String name)
{
 return null;
}

findClass

可以看到在所有父ClassLoader无法加载Class时,则会调用自己的findClass方法。findClass在ClassLoader中的定义为:

protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}

其实任何ClassLoader子类,都可以重写loadClassfindClass。一般如果你不想使用双亲委托,则重写loadClass修改其实现。而重写findClass则表示在双亲委托下,父ClassLoader都找不到Class的情况下,定义自己如何去查找一个Class。而我们的PathClassLoader会自己负责加载MainActivity这样的程序中自己编写的类,利用双亲委托父ClassLoader加载Framework中的Activity。说明PathClassLoader并没有重写loadClass,因此我们可以来看看PathClassLoader中的 findClass 是如何实现的。

public BaseDexClassLoader(String dexPath, File optimizedDirectory,String    
librarySearchPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, librarySearchPath,
                                   optimizedDirectory);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
   //查找指定的class
   Class c = pathList.findClass(name, suppressedExceptions);
   if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class "" + name + "" on path: " + pathList);
       for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
      }
           throw cnfe;
}
return c;
}

实现非常简单,从pathList中查找class。继续查看DexPathList

public DexPathList(ClassLoader definingContext, String dexPath,
           String librarySearchPath, File optimizedDirectory) {
//.........
   // splitDexPath 实现为返回 List<File>.add(dexPath)
   // makeDexElements 会去 List<File>.add(dexPath) 中使用DexFile加载dex文件返回 Element数组
   this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                          suppressedExceptions, definingContext);
//.........
   
}

public Class findClass(String name, List<Throwable> suppressed) {
    //从element中获得代表Dex的 DexFile
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
           //查找class
      Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
           if (clazz != null) {
          return clazz;
      }
  }
  }
   if (dexElementsSuppressedExceptions != null) {
  suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
  }
return null;
}

热修复

PathClassLoader中存在一个Element数组,Element类中存在一个dexFile成员表示dex文件,即:APK中有X个dex,则Element数组就有X个元素。

image.png

PathClassLoader中的Element数组为:[patch.dex , classes.dex , classes2.dex]。如果存在Key.class位于patch.dex与classes2.dex中都存在一份,当进行类查找时,循环获得dexElements中的DexFile,查找到了Key.class则立即返回,不会再管后续的element中的DexFile是否能加载到Key.class了。

因此实际上,一种热修复实现可以将出现Bug的class单独的制作一份fix.dex文件(补丁包),然后在程序启动时,从服务器下载fix.dex保存到某个路径,再通过fix.dex的文件路径,用其创建Element对象,然后将这个Element对象插入到我们程序的类加载器PathClassLoaderpathList中的dexElements数组头部。这样在加载出现Bug的class时会优先加载fix.dex中的修复类,从而解决Bug。

热修复的方式不止这一种,并且如果要完整实现此种热修复可能还需要注意一些其他的问题(如:反射兼容)。


收起阅读 »

分析应用程序启动

一旦我们建立了触发应用程序缓慢启动的指标和场景,下一步就是提高性能。要了解是什么导致应用程序启动缓慢,我们需要对其进行分析。 Android Studio 提供了几种类型的分析器录制配置:Trace System Calls(又名 systrace、perfe...
继续阅读 »

一旦我们建立了触发应用程序缓慢启动的指标和场景,下一步就是提高性能。

要了解是什么导致应用程序启动缓慢,我们需要对其进行分析。 Android Studio 提供了几种类型的分析器录制配置:

Trace System Calls(又名 systrace、perfetto):对运行时的影响很小,非常有助于了解应用程序如何与系统和 CPU 交互,但不了解应用程序 VM 内部发生的 Java 方法调用。

Sample C/C++ Functions(又名 Simpleperf):我不感兴趣,我处理的应用程序运行的字节码比本机代码多得多。 在 Q+ 上,这现在也应该以低开销的方式对 Java 堆栈进行采样,但我还没有设法让它工作。

Trace Java Methods:这会捕获所有 VM 方法调用,这些调用引入了如此多的开销,结果没有多大意义。

Sample Java Methods:开销比跟踪少,但显示了 VM 内部发生的 Java 方法调用。 这是我在分析应用程序启动时的首选选项。

在应用程序启动时开始录制

Android Studio profiler 有通过连接到已经运行的进程来启动跟踪的 UI,但没有明显的方式在应用程序启动时开始记录。

该选项存在但隐藏在应用程序的 run configuration:在 profiling 选项卡中选中启动时启动此记录。

然后通过 Run > Profile app 部署应用程序。

分析 release builds

Android 开发人员通常在日常工作中使用调试构建类型,调试构建通常包括 LeakCanary 等额外库等。

开发人员应该分析发布版本而不是调试版本,以确保他们正在解决客户面临的实际问题。

不幸的是,发布版本是不可调试的,因此 Android 分析器无法记录发布版本的跟踪。

以下是解决该问题的几个选项。

1. 创建可调试的发布版本

我们可以暂时使我们的发布构建可调试,或者创建一个新的发布构建类型来进行分析。

android {
buildTypes {
release {
debuggable true
// ...
}
}
}

如果 APK 是可调试的,库和 Android 框架代码通常会有不同的行为。 ART 禁用了许多优化以启用连接调试器,这会显着且不可预测地影响性能。因此该解决方案并不理想。

2. 在有 root 设备上调试设备

Root 设备允许 Android Studio 分析器记录不可调试构建的跟踪。

通常不建议在模拟器上进行分析 - 每个系统组件的性能都会不同(cpu 速度、缓存大小、磁盘性能),因此“优化”实际上可以通过将工作转移到手机上较慢的东西来使事情变慢 . 如果您没有可用的 root 物理设备,您可以创建一个没有 Play 服务的模拟器,然后运行 adb root。

3. 在 Android Q 上使用 simpleperf

有一个名为 simpleperf 的工具,如果它们有一个特殊的清单标志,据说可以在非根 Q+ 设备上启用分析版本构建。 该文档将其称为 profileableFromShell,XML 示例有一个带有 android:shell 属性的 profileable 标记,官方清单文档没有显示任何内容。

<manifest ...>
<application ...>
<profileable android:shell="true" />
</application>
</manifest>

我查看了 cs.android.com 上的清单解析代码:

if (tagName.equals("profileable")) {
sa = res.obtainAttributes(
parser,
R.styleable.AndroidManifestProfileable
);
if (sa.getBoolean(
R.styleable.AndroidManifestProfileable_shell,
false
)) {
ai.privateFlags |= ApplicationInfo.PRIVATE_FLAG_PROFILEABLE_BY_SHELL;
}
}

如果清单具有 <profileable android:shell="true" /> (我没有尝试过),您似乎可以从命令行触发分析。 据我了解,Android Studio 团队仍在努力与此新功能集成。

分析下载的 APK

在 Square,我们的版本是用 CI 构建的。 正如我们之前看到的,从 Android Studio 分析应用程序启动需要检查运行配置中的一个选项。 我们如何使用下载的 APK 来做到这一点?

事实证明,这是可能的,但隐藏在 File > Profile or Debug APK 下。 这将打开一个包含解压缩 APK 的新窗口,您可以从中设置运行配置并开始分析。

Android Studio 分析器会减慢一切

不幸的是,当我在一个生产应用程序上测试这些方法时,即使在最近的 Android 版本上,Android Studio 的分析也会大大减慢应用程序的启动速度(大约慢 10 倍)。 我不知道为什么,也许是“高级分析”,它似乎不能被禁用。 我们需要另辟蹊径!

从代码分析

我们可以直接从代码开始跟踪,而不是从 Android Studio 进行分析:

val tracesDirPath = TODO("path for trace directory")
val fileNameFormat = SimpleDateFormat(
"yyyy-MM-dd_HH-mm-ss_SSS'.trace'",
Locale.US
)
val fileName = fileNameFormat.format(Date())
val traceFilePath = tracesDirPath + fileName
// Save up to 50Mb data.
val maxBufferSize = 50 * 1000 * 1000
// Sample every 1000 microsecond (1ms)
val samplingIntervalUs = 1000
Debug.startMethodTracingSampling(
traceFilePath,
maxBufferSize,
samplingIntervalUs
)

// ...

Debug.stopMethodTracing()

然后我们可以从设备中提取跟踪文件并将其加载到 Android Studio 中。

什么时候开始取样

我们应该在应用程序生命周期中尽早开始记录跟踪。 最早可以在 Android P 之前在应用程序启动时运行的代码是 ContentProvider,而在 Android P+ 上它是 AppComponentFactory。

Android P / API < 28

class AppStartListener : ContentProvider() {
override fun onCreate(): Boolean {
Debug.startMethodTracingSampling(...)
return false
}
// ...
}
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<provider
android:name=".AppStartListener"
android:authorities="com.example.appstartlistener"
android:exported="false" />
</application>

</manifest>

在定义提供者时,我们可以设置一个 initOrder 标记,并且最高的数字首先被初始化。

Android P+ / API 28+

@RequiresApi(28)
class MyAppComponentFactory() :
androidx.core.app.AppComponentFactory() {

@RequiresApi(29)
override fun instantiateClassLoader(
cl: ClassLoader,
aInfo: ApplicationInfo
): ClassLoader {
if (Build.VERSION.SDK_INT >= 29) {
Debug.startMethodTracingSampling(...)
}
return super.instantiateClassLoader(cl, aInfo)
}

override fun instantiateApplicationCompat(
cl: ClassLoader,
className: String
): Application {
if (Build.VERSION.SDK_INT < 29) {
Debug.startMethodTracingSampling(...)
}
return super.instantiateApplicationCompat(cl, className)
}
}
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
android:appComponentFactory=".MyAppComponentFactory"
tools:replace="android:appComponentFactory"
tools:targetApi="p">
</application>

</manifest>

在哪里存储采样

val tracesDirPath = TODO("path for trace directory")
  • API < 28:广播接收器可以访问上下文,我们可以在该上下文上调用 Context.getDataDir() 将跟踪存储在应用程序目录中。

  • API 28: AppComponentFactory.instantiateApplication() 负责创建一个新的应用程序实例,所以目前还没有可用的上下文。 我们可以直接硬编码到 /sdcard/ 的路径,但这需要 WRITE_EXTERNAL_STORAGE 权限。

  • API 29+:当面向 API 29 时,硬编码 /sdcard/ 停止工作。 我们可以添加 requestLegacyExternalStorage 标志,但无论如何 API 30 都不支持它。 建议在 API 30+ 上尝试 MANAGE_EXTERNAL_STORAGE。 无论哪种方式,AppComponentFactory.instantiateClassLoader() 都会传递一个 ApplicationInfo,因此我们可以使用 ApplicationInfo.dataDir 将跟踪存储在应用程序目录中。

何时停止采样

当应用程序的第一帧完全加载时,冷启动结束。 我们可以根据这个条件停止方法跟踪:

class MyApp : Application() {

override fun onCreate() {
super.onCreate()

var firstDraw = false
val handler = Handler()

registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (firstDraw) return
val window = activity.window
window.onDecorViewReady {
window.decorView.onNextDraw {
if (firstDraw) return
firstDraw = true
handler.postAtFrontOfQueue {
Debug.stopMethodTracing()
}
}
}
}
})
}
}

我们还可以记录比应用程序启动时间更长的固定时间,例如 5秒:

Handler(Looper.getMainLooper()).postDelayed({
Debug.stopMethodTracing()
}, 5000)

使用 Nanoscope 进行分析

另一个用于分析应用程序启动的选项是 uber/nanoscope。 这是一个带有内置低开销跟踪的 Android 图像。 它很棒,但有一些限制:

它只跟踪主线程。

大型应用程序将溢出内存跟踪缓冲区。

应用启动步骤

一旦我们有了启动跟踪,我们就可以开始调查什么操作耗费时间。 应该期待 3 个主要部分:

ActivityThread.handlingBindApplication() 包含 Activity 创建之前的启动工作。 如果这很慢,那么我们可能需要优化 Application.onCreate()。

TransactionExecutor.execute() 负责创建和恢复 Activity ,包括填充视图层次结构。

ViewRootImpl.performTraversals() 是框架执行第一次测量、布局和绘制的地方。 如果这很慢,则可能是视图层次结构过于复杂,或者具有需要优化的自定义绘图的视图。

如果注意到服务是在第一次视图遍历之前启动的,那么延迟该服务的启动可能是值得的,以便它在视图遍历之后发生。

结论

一些要点:

分析发布版本专注于实际问题。

Android 上的分析应用程序启动状态远非理想。 基本上没有好的开箱即用解决方案,但 Jetpack Benchmark 团队正在努力解决这个问题。

从代码开始采样,以防止 Android Studio 拖慢一切。

收起阅读 »

adb 如何衡量应用启动

可以利用 ActivityTaskManager 的输出来获取应用程序启动持续时间。每当 Activity 启动时,都会在 logcat 输出中看到类似的内容:ActivityTaskManager: Displayed com.android.samples...
继续阅读 »

可以利用 ActivityTaskManager 的输出来获取应用程序启动持续时间。

每当 Activity 启动时,都会在 logcat 输出中看到类似的内容:

ActivityTaskManager: Displayed
com.android.samples.mytest/.MainActivity: +1s380ms

此持续时间(在此示例中为 1,380 毫秒)表示从启动应用程序到系统认为它“已启动”的时间,其中包括绘制第一帧(因此为“已显示”)。

这篇文章深入探讨了这个问题:

ActivityTaskManager 究竟测量什么?

ActivityTaskManager 测量从 system_process 接收到启动活动的意图到该活动的窗口完成绘制的时间(API < 30 上的正常运行时间,API 30+ 上的实时时间)。

关键要点:

此度量包括应用程序代码和资源加载之前的几百毫秒,即应用程序开发人员无法影响的时间。

可以在应用程序内进行测量而无需额外的时间,我将在最后分享如何进行。

ActivityTaskManager log

ActivityTaskManager: Displayed
com.android.samples.mytest/.MainActivity: +1s380ms

我们知道日志的样子,所以我们可以在 cs.android.com 上搜索它:"Displayed"

这导致我们到 ActivityTaskManager.logAppDisplayed():

private void logAppDisplayed(TransitionInfoSnapshot info) {
StringBuilder sb = mStringBuilder;
sb.setLength(0);
sb.append("Displayed ");
sb.append(info.launchedActivityShortComponentName);
sb.append(": ");
TimeUtils.formatDuration(info.windowsDrawnDelayMs, sb);
Log.i(TAG, sb.toString());
}

启动持续时间为 TransitionInfoSnapshot.windowsDrawnDelayMs。 它在 TransitionInfoSnapshot.notifyWindowsDrawn() 中计算:

TransitionInfoSnapshot notifyWindowsDrawn(
ActivityRecord r,
long timestampNs
) {
TransitionInfo info = getActiveTransitionInfo(r);
info.mWindowsDrawnDelayMs = info.calculateDelay(timestampNs);
return new TransitionInfoSnapshot(info);
}

private static final class TransitionInfo {
int calculateDelay(long timestampNs) {
long delayNanos = timestampNs - mTransitionStartTimeNs;
return (int) TimeUnit.NANOSECONDS.toMillis(delayNanos);
}
}

让我们找出 timestampNs 和 mTransitionStartTimeNs 在哪里被捕获。

ActivityMetricsLogger.notifyActivityLaunching() 捕获活动转换的开始:

private LaunchingState notifyActivityLaunching(
Intent intent,
ActivityRecord caller,
int callingUid
) {
long transitionStartNs = SystemClock.elapsedRealtimeNanos();
LaunchingState launchingState = new LaunchingState();
launchingState.mCurrentTransitionStartTimeNs = transitionStartNs;
return launchingState;
}

TransitionInfoSnapshot.notifyWindowsDrawn() 由 ActivityRecord.onWindowsDrawn() 调用,后者由 ActivityRecord.updateReportedVisibilityLocked() 调用:

void updateReportedVisibilityLocked() {
// ...
if (nowDrawn != reportedDrawn) {
onWindowsDrawn(nowDrawn, SystemClock.elapsedRealtimeNanos());
reportedDrawn = nowDrawn;
}
// ...
}

我们现在知道在哪里捕获开始和结束时间戳,但不幸的是 ActivityMetricsLogger.notifyActivityLaunching() 和 ActivityRecord.updateReportedVisibilityLocked() 有很多调用点,因此很难在 AOSP 源中进一步挖掘。

调试system_process

我告诉一个朋友,我在查看 Android 资源时遇到了死胡同,他问我:

为什么不设置断点?

我从未尝试过调试 system_process,但我们没有理由不能。 谢谢文森特的主意! 幸运的是,Android Studio 设置为查找应用编译所针对的 Android 版本的源代码。

使用 Root 设备,我可以将调试器连接到 system_process。

当我启动我的应用程序时,我遇到了 ActivityMetricsLogger.notifyActivityLaunching() 的断点。

和 TransitionInfoSnapshot.notifyWindowsDrawn() 的另一个断点。

读取堆栈跟踪

第一个堆栈跟踪显示当 system_process 收到启动活动的 Intent 时捕获开始时间戳。

第二个堆栈跟踪显示当该活动的窗口完成绘制时捕获结束时间戳。 相应的帧应在 16 毫秒内在显示屏上可见。

应用启动时间

启动 Activity 的用户体验在用户触摸屏幕时开始,但是应用程序开发人员对 ActivityThread.handleBindApplication() 之前花费的时间几乎没有影响,因此应用程序冷启动监控应该从这里开始。

ActivityThread.handleBindApplication() 加载 APK 和应用程序组件(AppComponentFactory、ContentProvider、Application)。 不幸的是,ActivityTaskManager 使用 ActivityTaskManagerService.startActivity() 作为开始时间,它发生在 ActivityThread.handleBindApplication() 之前的一段时间。

ActivityTaskManager 增加了多少时间?

我展示了我们可以使用 Process.getStartUptimeMillis() 来获取调用 ActivityThread.handleBindApplication() 的时间戳。 我还分享了一个代码片段来读取进程 fork 时间(参见 Processes.readProcessForkRealtimeMillis())。 我们可以将这 2 个值记录到 logcat:

val forkRealtime = Processes.readProcessForkRealtimeMillis()
val nowRealtimeMs = SystemClock.elapsedRealtime()
val nowUptimeMs = SystemClock.uptimeMillis()
val elapsedRealtimeMs = nowRealtimeMs - forkRealtime
val forkUptimeMs = nowUptimeMs - elapsedRealtimeMs
Log.d("AppStart", "$forkUptimeMs fork timestamp")

val processStart = Process.getStartUptimeMillis()
Log.d("AppStart", "$processStart bindApplication() timestamp")

我们还需要记录 ActivityMetricsLogger.mCurrentTransitionStartTime。 我们可以让我们之前的 system_process 断点非挂起并让它记录值。 Evaluate 和 log 的输出进入调试器控制台。 我们希望所有日志都在 logcat 中,因此我们从那里调用 Log.d()。

结果

D/AppStart: 27464211 Intent received
D/AppStart: 27464340 fork timestamp
D/AppStart: 27464533 bindApplication() timestamp
...
I/ActivityTaskManager: Displayed
com.example.logstartup/.MainActivity: +1s185ms

从接收到分叉 zygote 进程的意图需要 129 毫秒,从分叉到 ActivityThread.handleBindApplication() 需要 193 毫秒,即应用程序开始加载其代码和资源之前的 322 毫秒。 在此示例中,这是 ActivityTaskManager 报告的应用启动时间的约 30%。

实际数字低于此值,因为 system_process 正在运行并连接调试器。

从应用程序内部测量应用程序启动时间

我将该时间戳与传递给 TransitionInfoSnapshot.notifyWindowsDrawn() 的时间戳进行了比较,这两个值仅相隔几毫秒。

我们可以把我们学到的东西放在一起来衡量应用程序内的应用程序启动持续时间:

class MyApp : Application() {

override fun onCreate() {
super.onCreate()

var firstDraw = false
val handler = Handler()

registerActivityLifecycleCallbacks(
object : ActivityLifecycleCallbacks {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (firstDraw) return
val name = activity::class.java.simpleName
val window = activity.window
window.onDecorViewReady {
window.decorView.onNextDraw {
if (firstDraw) return@onNextDraw
firstDraw = true
handler.postAtFrontOfQueue {
val start = Process.getStartUptimeMillis()
val now = SystemClock.uptimeMillis()
val startDurationMs = now - start
Log.d(
"AppStart",
"Displayed $name in $startDurationMs ms"
)
}
}
}
}
})
}
}
D/AppStart: Displayed MainActivity in 863 ms

结论

ActivityTaskManager 的输出很方便,如果您尝试比较应用程序不同版本的启动时间,则完全值得使用。 请注意,应用程序开发人员无法影响那段时间的重要部分。

可以从应用程序内测量应用程序启动时间。


收起阅读 »

Android入门教程 | Fragment 基础概念

什么是Fragment?Fragment,直译为“碎片”,“片段”。 Fragment 表示 FragmentActivity 中的行为或界面的一部分。可以在一个 Activity 中组合多个片段,从而构建多窗格界面,并在多个 Activity 中重复使用某个...
继续阅读 »

什么是Fragment?

Fragment,直译为“碎片”,“片段”。 Fragment 表示 FragmentActivity 中的行为或界面的一部分。可以在一个 Activity 中组合多个片段,从而构建多窗格界面,并在多个 Activity 中重复使用某个片段。可以将片段视为 Activity 的模块化组成部分,它具有自己的生命周期,能接收自己的输入事件,并且可以在 Activity 运行时添加或移除片段(这有点像可以在不同 Activity 中重复使用的“子 Activity”)。

片段必须始终托管在 Activity 中,其生命周期直接受宿主 Activity 生命周期的影响。例如,当 Activity 暂停时,Activity 的所有片段也会暂停;当 Activity 被销毁时,所有片段也会被销毁。

不过,当 Activity 正在运行(处于已恢复生命周期状态)时,可以独立操纵每个片段,如添加或移除片段。当执行此类片段事务时,也可将其添加到由 Activity 管理的返回栈 — Activity 中的每个返回栈条目都是一条已发生片段事务的记录。借助返回栈,用户可以通过按返回按钮撤消片段事务(后退)。

Fragment的优点

  • Fragment加载灵活,替换方便。定制你的UI,在不同尺寸的屏幕上创建合适的UI,提高用户体验。
  • 可复用,页面布局可以使用多个Fragment,不同的控件和内容可以分布在不同的Fragment上。
  • 使用Fragment,可以少用一些Activity。一个Activity可以管辖多个Fragment。

Fragment生命周期

image.png

Fragment 类的代码与 Activity 非常相似。它包含与 Activity 类似的回调方法,如 onCreate()、onStart()、onPause() 和 onStop()。实际上,如果要将现有 Android 应用转换为使用片段,可能只需将代码从 Activity 的回调方法移入片段相应的回调方法中。

通常,至少应实现以下生命周期方法

  • onCreate() 系统会在创建片段时调用此方法。当片段经历暂停或停止状态继而恢复后,如果希望保留此片段的基本组件,则应在实现中将其初始化。
  • onCreateView() 系统会在片段首次绘制其界面时调用此方法。如要为片段绘制界面,从此方法中返回的 View 必须是片段布局的根视图。如果片段未提供界面,可以返回 null。
  • onPause() 系统会将此方法作为用户离开片段的第一个信号(但并不总是意味着此片段会被销毁)进行调用。通常,应在此方法内确认在当前用户会话结束后仍然有效的任何更改(因为用户可能不会返回)。

可能还想扩展几个子类,而非 Fragment 基类

  • DialogFragment 显示浮动对话框。使用此类创建对话框可有效代替使用 Activity 类中的对话框辅助方法,因为您可以将片段对话框纳入由 Activity 管理的片段返回栈,从而使用户能够返回清除的片段。
  • ListFragment 显示由适配器(如 SimpleCursorAdapter)管理的一系列项目,类似于 ListActivity。该类提供几种管理列表视图的方法,如用于处理点击事件的 onListItemClick() 回调。(请注意,显示列表的首选方法是使用 RecyclerView,而非 ListView。在此情况下,需在列表布局中创建包含 RecyclerView 的片段。如需了解具体操作方法,请参阅使用 RecyclerView 创建列表)
  • PreferenceFragmentCompat 以列表形式显示 Preference 对象的层次结构。此类用于为应用创建设置屏幕。
创建Fragment,使用自定义界面

片段通常用作 Activity 界面的一部分,并且会将其自己的布局融入 Activity。

如要为片段提供布局,必须实现 onCreateView() 回调方法,Android 系统会在片段需要绘制其布局时调用该方法。此方法的实现所返回的 View 必须是片段布局的根视图。

如要从 onCreateView() 返回布局,可以通过 XML 中定义的布局资源来扩展布局。为帮助您执行此操作,onCreateView() 提供了一个 LayoutInflater 对象。

例如,以下这个 Fragment 子类从 example_fragment.xml 文件加载布局:

public static class ExampleFragment extends Fragment {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.example_fragment, container, false);
}
}

传递至 onCreateView() 的 container 参数是片段布局将插入到的父级 ViewGroup(来自 Activity 的布局)。savedInstanceState 参数是在恢复片段时,提供上一片段实例相关数据的 Bundle(处理片段生命周期部分对恢复状态做了详细阐述)。

inflate() 方法带有三个参数

  • 想要扩展的布局的资源 ID。
  • 将作为扩展布局父项的 ViewGroup。传递 container 对系统向扩展布局的根视图(由其所属的父视图指定)应用布局参数具有重要意义。
  • 指示是否应在扩展期间将扩展布局附加至 ViewGroup(第二个参数)的布尔值。(在本例中,此值为 false,因为系统已将扩展布局插入 container,而传递 true 值会在最终布局中创建一个多余的视图组。)

接下来,需将该片段添加到您的 Activity 中。

向Activity添加Fragment

通常,片段会向宿主 Activity 贡献一部分界面,作为 Activity 整体视图层次结构的一部分嵌入到 Activity 中。可以通过两种方式向 Activity 布局添加片段(以下为代码片段,并非完整代码)。

静态方式

在 Activity 的布局文件内声明片段。 在本例中,您可以将片段当作视图来为其指定布局属性。例如,以下是拥有两个片段的 Activity 的布局文件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment android:name="com.example.news.ArticleListFragment"
android:id="@+id/list"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<fragment android:name="com.example.news.ArticleReaderFragment"
android:id="@+id/viewer"
android:layout_weight="2"
android:layout_width="0dp"
android:layout_height="match_parent" />
</LinearLayout>

<fragment> 中的 android:name 属性指定要在布局中进行实例化的 Fragment 类。

创建此 Activity 布局时,系统会将布局中指定的每个片段实例化,并为每个片段调用 onCreateView() 方法,以检索每个片段的布局。系统会直接插入片段返回的 View,从而代替 <fragment> 元素。

注意:每个片段都需要唯一标识符,重启 Activity 时,系统可使用该标识符来恢复片段(也可以使用该标识符来捕获片段,从而执行某些事务,如将其移除)。可以通过两种方式为片段提供 ID: 为 android:id 属性提供唯一 ID。 为 android:tag 属性提供唯一字符串。

Java代码加载Fragment

或者,通过编程方式将片段添加到某个现有 ViewGroup。 在 Activity 运行期间,您可以随时将片段添加到 Activity 布局中。您只需指定要将片段放入哪个 ViewGroup。

如要在 Activity 中执行片段事务(如添加、移除或替换片段),则必须使用 FragmentTransaction 中的 API。如下所示,可以从 FragmentActivity 获取一个 FragmentTransaction 实例:

FragmentManager fragmentManager = getSupportFragmentManager();
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();

然后,可以使用 add() 方法添加一个片段,指定要添加的片段以及将其插入哪个视图。例如:

ExampleFragment fragment = new ExampleFragment();
fragmentTransaction.add(R.id.fragment_container, fragment);
fragmentTransaction.commit();

传递到 add() 的第一个参数是 ViewGroup,即应放置片段的位置,由资源 ID 指定,第二个参数是要添加的片段。 一旦通过 FragmentTransaction 做出了更改,就必须调用 commit() 以使更改生效。

管理Fragment

如要管理 Activity 中的片段,需使用 FragmentManager。如要获取它,请从 Activity 调用 getSupportFragmentManager()

可使用 FragmentManager 执行的操作包括

  • 通过 findFragmentById()(针对在 Activity 布局中提供界面的片段)或 findFragmentByTag()(针对提供或不提供界面的片段)获取 Activity 中存在的片段。
  • 通过 popBackStack()(模拟用户发出的返回命令)使片段从返回栈中弹出。
  • 通过 addOnBackStackChangedListener() 注册侦听返回栈变化的侦听器。

也可使用 FragmentManager 打开一个 FragmentTransaction,通过它来执行某些事务,如添加和移除片段。

执行Fragment事务

在 Activity 中使用片段的一大优点是,可以通过片段执行添加、移除、替换以及其他操作,从而响应用户交互。提交给 Activity 的每组更改均称为事务,并且可使用 FragmentTransaction 中的 API 来执行一项事务。也可将每个事务保存到由 Activity 管理的返回栈内,从而让用户能够回退片段更改(类似于回退 Activity)。

如下所示,可以从 FragmentManager 获取一个 FragmentTransaction 实例:

FragmentManager fragmentManager = getSupportFragmentManager();
FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();

每个事务都是想要同时执行的一组更改。可以使用 add()、remove() 和 replace() 等方法,为给定事务设置您想要执行的所有更改。然后,如要将事务应用到 Activity,必须调用 commit()。

不过,在调用 commit() 之前,可能希望调用 addToBackStack(),以将事务添加到片段事务返回栈。该返回栈由 Activity 管理,允许用户通过按返回按钮返回上一片段状态。

例如,以下示例说明如何将一个片段替换为另一个片段,以及如何在返回栈中保留先前的状态:

// Create new fragment and transaction
Fragment newFragment = new ExampleFragment();
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();

// Replace whatever is in the fragment_container view with this fragment,
// and add the transaction to the back stack
transaction.replace(R.id.fragment_container, newFragment);
transaction.addToBackStack(null);

// Commit the transaction
transaction.commit();

在本例中,newFragment 会替换目前在 R.id.fragment_container ID 所标识的布局容器中的任何片段(如有)。通过调用 addToBackStack(),可以将替换事务保存到返回栈,以便用户能够通过按返回按钮撤消事务并回退到上一片段。

然后,FragmentActivity 会自动通过 onBackPressed() 从返回栈检索片段。

如果向事务添加多个更改(如又一个 add() 或 remove()),并调用 addToBackStack(),则调用 commit() 前应用的所有更改都将作为单一事务添加到返回栈,并且返回按钮会将它们一并撤消。

向 FragmentTransaction 添加更改的顺序无关紧要,不过:

必须最后调用 commit()。 如果要向同一容器添加多个片段,则添加片段的顺序将决定它们在视图层次结构中出现的顺序。 如果没有在执行删除片段的事务时调用 addToBackStack(),则事务提交时该片段会被销毁,用户将无法回退到该片段。不过,如果在删除片段时调用 addToBackStack(),则系统会停止该片段,并随后在用户回退时将其恢复。

调用 commit() 不会立即执行事务,而是在 Activity 的界面线程(“主”线程)可执行该操作时,再安排该事务在线程上运行。不过,如有必要,也可以从界面线程调用 executePendingTransactions(),以立即执行 commit() 提交的事务。通常不必这样做,除非其他线程中的作业依赖该事务。

注意:只能在 Activity 保存其状态(当用户离开 Activity)之前使用 commit() 提交事务。如果试图在该时间点后提交,则会引发异常。这是因为如需恢复 Activity,则提交后的状态可能会丢失。对于丢失提交无关紧要的情况,请使用 commitAllowingStateLoss()

生命周期变化

Fragment被创建的时候

它会经历以下状态

onAttach()
onCreate()
onCreateView()
onActivityCreated()

Fragment 对用户可见的时候

它会经历以下状态

onStart()
onResume()

Fragment进入“后台模式”的时候

它会经历以下状态

onPause()
onStop()

Fragment被销毁了(或者持有它的activity被销毁了)

它会经历以下状态

onPause()
onStop()
onDestroyView()
onDestroy()
onDetach()

Fragment与Activity不同的生命周期

Fragment 的大部分状态都和 Activity 很相似,但 fragment 有一些新的状态。

Fragment不同于Activity的生命周期 - onAttached() —— 当fragment被加入到activity时调用(在这个方法中可以获得所在的activity)。 - onCreateView() —— 当activity要得到fragment的layout时,调用此方法,fragment在其中创建自己的layout(界面)。 - onActivityCreated() —— 当activity的onCreated()方法返回后调用此方法 - onDestroyView() —— 当fragment中的视图被移除的时候,调用这个方法。 - onDetach() —— 当fragment和activity分离的时候,调用这个方法。

一旦activity进入resumed状态(也就是running状态),你就可以自由地添加和删除fragment了。因此,只有当activity在resumed状态时,fragment的生命周期才能独立的运转,其它时候是依赖于activity的生命周期变化的。

处理Fragment生命周期

管理片段生命周期与管理 Activity 生命周期很相似。和 Activity 一样,片段也以三种状态存在:

  • 已恢复:片段在运行中的 Activity 中可见。
  • 已暂停:另一个 Activity 位于前台并具有焦点,但此片段所在的 Activity 仍然可见(前台 Activity 部分透明,或未覆盖整个屏幕)。
  • 已停止:片段不可见。宿主 Activity 已停止,或片段已从 Activity 中移除,但已添加到返回栈。已停止的片段仍处于活动状态(系统会保留所有状态和成员信息)。不过,它对用户不再可见,并随 Activity 的终止而终止。 与 Activity 一样,您也可使用 onSaveInstanceState(Bundle)、ViewModel 和持久化本地存储的组合,在配置变更和进程终止后保留片段的界面状态。如要了解保留界面状态的更多信息,请参阅保存界面状态。

对于 Activity 生命周期与片段生命周期而言,二者最显著的差异是在其各自返回栈中的存储方式。默认情况下,Activity 停止时会被放入由系统管理的 Activity 返回栈中。不过,只有在移除片段的事务执行期间通过调用 addToBackStack() 显式请求保存实例时,系统才会将片段放入由宿主 Activity 管理的返回栈。

在其他方面,管理片段生命周期与管理 Activity 生命周期非常相似;对此,可采取相同的做法。

image.png

注意:如果 Fragment 中需要 Context 对象,则可以调用 getContext()。但请注意,只有在该片段附加到 Activity 时才需调用 getContext()。如果尚未附加该片段,或者其在生命周期结束期间已分离,则 getContext() 返回 null。

Fragment相关面试题:

1. 如何切换 fragement(不重新实例化)

翻看了 Android 官方 Doc,和一些组件的源代码,发现 replace()这个方法只是在上一个 Fragment不再需要时采用的简便方法.

正确的切换方式是 add(),切换时 hide(),add()另一个 Fragment;再次切换时,只需 hide()当前,show()另一个。这样就能做到多个 Fragment 切换不重新实例化:

2. Fragment 的的优点

  • Fragment 可以使你能够将 activity 分离成多个可重用的组件,每个都有它自己的生命周期和UI。
  • Fragment 可以轻松得创建动态灵活的 UI 设计,可以适应于不同的屏幕尺寸。从手机到平板电脑。
  • Fragment 是一个独立的模块,紧紧地与 activity 绑定在一起。可以运行中动态地移除、加入、交换等。
  • Fragment 提供一个新的方式让你在不同的安卓设备上统一你的 UI。
  • Fragment 解决 Activity 间的切换不流畅,轻量切换。
  • Fragment 替代 TabActivity 做导航,性能更好。
  • Fragment 在 4.2.版本中新增嵌套 fragment 使用方法,能够生成更好的界面效果。

3. Fragment 如何实现类似 Activity 栈的压栈和出栈效果

Fragment 的事物管理器内部维持了一个双向链表结构,该结构可以记录我们每次 add 的Fragment 和 replace 的 Fragment,然后当我们点击 back 按钮的时候会自动帮我们实现退栈操作。

4. Fragment 的 replace 和 add 方法的区别

Fragment 本身并没有 replace 和 add 方法,这里的理解应该为使用 FragmentManager 的 replace和 add 两种方法切换 Fragment 时有什么不同。

我们经常使用的一个架构就是通过 RadioGroup 切换 Fragment,每个 Fragment 就是一个功能模块。

Fragment 的容器一个 FrameLayout,add 的时候是把所有的 Fragment 一层一层的叠加到了FrameLayout 上了,而 replace 的话首先将该容器中的其他 Fragment 去除掉然后将当前 Fragment添加到容器中。

一个 Fragment 容器中只能添加一个 Fragment 种类,如果多次添加则会报异常,导致程序终止,而 replace 则无所谓,随便切换。

因为通过 add 的方法添加的 Fragment,每个 Fragment 只能添加一次,因此如果要想达到切换效果需要通过 Fragment 的的 hide 和 show 方法结合者使用。将要显示的 show 出来,将其他 hide起来。这个过程 Fragment 的生命周期没有变化。通过 replace 切换 Fragment,每次都会执行上一个 Fragment 的 onDestroyView,新 Fragment的 onCreateView、onStart、onResume 方法。

基于以上不同的特点我们在使用的使用一定要结合着生命周期操作我们的视图和数据。

5. Fragment与Activity之间是如何传值的

  • Activity向Fragment传值:

将要传的值,放到bundle对象里; 在Activity中创建该Fragment的对象fragment,

通过调用 fragment.setArguments()传递到fragment中; 在该Fragment中通过调用getArguments()得到bundle对象,就能得到里面的值。

  • Fragment向Activity传值:

在Activity中调用getFragmentManager()得到fragmentManager,,调用findFragmentByTag(tag)或者通过findFragmentById(id) FragmentManager fragmentManager = getFragmentManager(); Fragment fragment = fragmentManager.findFragmentByTag(tag);

通过回调的方式,定义一个接口(可以在 Fragment 类中定义),接口中有一个空的方法,在 fragment 中需要的时候调用接口的方法,值可以作为参数放在这个方法中,然后让 Activity 实现这个接口,必然会重写这个方法,这样值就传到了 Activity 中。

6. Fragment生命周期

  • onAttach(Contextcontext):在 Fragment 和 Activity 关联上的时候调用,且仅调用一次。在该回调中我们可以将 context 转化为 Activity 保存下来,从而避免后期频繁调用getAtivity() 获取 Activity 的局面,避免了在某些情况下 getAtivity() 为空的异常(Activity和 Fragment 分离的情况下)。同时也可以在该回调中将传入的Arguments提取并解析,在这里强烈推荐通过setArguments给Fragment传参数,因为在应用被系统回收时Fragment不会保存相关属性。

  • onCreate:在最初创建Fragment的时候会调用,和Activity的onCreate类似。

  • View onCreateView(LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState):在准备绘制Fragment界面时调用,返回值为Fragment要绘制布局的根视图,当然也可以返回null。注意使用inflater构建View时一定要将attachToRoot指明false,因为Fragment会自动将视图添加到container中,attachToRoot为true会重复添加报错。onCreateView并不是一定会被调用,当添加的是没有界面的Fragment就不会调用,比如调用FragmentTransaction的add(Fragment fragment, String tag)方法。

  • onActivityCreated :在 Activity 的 onCreated 执行完时会调用。

  • onStart() :Fragment对用户可见的时候调用,前提是 Activity 已经 started。

  • onResume():Fragment和用户之前可交互时会调用,前提是Activity已经resumed。

  • onPause():Fragment和用户之前不可交互时会调用。

  • onStop():Fragment不可见时会调用。

  • onDestroyView():在移除Fragment相关视图层级时调用。

  • onDestroy():最终清楚Fragment状态时会调用。

  • onDetach():Fragment和Activity解除关联时调用。

7. ViewPager对Fragment生命周期的影响

ViewPager+Fragment 是比较常见的组合了,一般搭配ViewPager的FragmentPagerAdapter 或 FragmentStatePagerAdapter 使用。不过 ViewPager 为了防止滑动出现卡顿,有一个缓存机制,默认情况下 ViewPager 会创建并缓存当前页面左右两边的页面(如Fragment)。此时左右两个 Fragment 都会执行从 onAttach->….->onResume 的生命周期,明明 Fragment 没有显示却已经到onResume 了,在某些情况下会出现问题。比如数据的加载时机、判断 Fragment 是否可见等。


收起阅读 »

Android 点击响应时间

Android 用户希望应用能够在短时间内响应他们的操作。UX 研究告诉我们,响应时间短于 100 毫秒会让人感觉立竿见影,而超过 1 秒的响应时间会让用户失去注意力。 当响应时间接近 10 秒时,用户只需放弃他们的任务。测量用户操作响应时间对于确保良好的用户...
继续阅读 »

Android 用户希望应用能够在短时间内响应他们的操作。

UX 研究告诉我们,响应时间短于 100 毫秒会让人感觉立竿见影,而超过 1 秒的响应时间会让用户失去注意力。 当响应时间接近 10 秒时,用户只需放弃他们的任务。

测量用户操作响应时间对于确保良好的用户体验至关重要。 点击是应用程序必须响应的最常见的操作。 我们可以测量 Tap 响应时间吗?

Tap Response Time 是从用户按下按钮到应用程序对点击做出明显反应的时间。

更准确地说,它是从手指离开触摸屏到显示器呈现出对该点击具有可见反应的帧(例如导航动画的开始)的时间。 Tap Response Time 不包括任何动画时间。

Naive Tap 响应时间

我打开了 Navigation Advanced Sample 项目并添加了一个对 measureTimeMillis() 的调用来测量点击 about 按钮时的 Tap Response Time。

aboutButton.setOnClickListener {
val tapResponseTimeMs = measureTimeMillis {
findNavController().navigate(R.id.action_title_to_about)
}
PerfAnalytics.logTapResponseTime(tapResponseTimeMs)
}

这种方法存在几个缺点:

它可以返回负时间。

它不会随着代码库的大小而扩展。

没有考虑从手指离开触摸屏到点击监听器被调用的时间。

它没有考虑从我们完成调用 NavController.navigate() 到显示渲染一个新屏幕可见的帧的时间。

负时间

measureTimeMillis() 调用 System.currentTimeMillis() 可以由用户或电话网络设置,因此时间可能会不可预测地向后或向前跳跃。 经过的时间测量不应使用 System.currentTimeMillis()

大型代码库

为每一个有意义的点击监听器添加测量代码是一项艰巨的任务。 我们需要一个可随代码库大小扩展的解决方案,这意味着我们需要中央钩子来检测何时触发了有意义的操作。

触摸流水线

当手指离开触摸屏时,会发生以下情况:

  • system_server 进程接收来自触摸屏的信息并确定哪个窗口应该接收 MotionEvent.UP 触摸事件。(每个窗口都与一个输入事件套接字对相关联:第一个套接字由 system_server 拥有以发送输入事件。 第一个套接字与创建窗口的应用程序拥有的第二个套接字配对,以接收输入事件。)

  • system_server 进程将触摸事件发送到目标窗口的输入事件套接字。

  • 该应用程序在其侦听套接字上接收触摸事件,将其存储在一个队列 (ViewRootImpl.QueuedInputEvent) 中,并安排一个 Choreographer 框架来使用输入事件。(system_server 进程检测输入事件何时在队列中停留超过 5 秒,此时它知道它应该显示应用程序无响应 (ANR) 对话框。)

  • 当 Choreographer 框架触发时,触摸事件被分派到窗口的根视图,然后通过其视图层次结构分派它。

  • 被点击的视图接收 MotionEvent.UP 触摸事件并发布一个单击侦听器回调。 这允许在单击操作开始之前更新视图的其他视觉状态。

  • 最后,当主线程运行发布回调时,将调用视图单击侦听器。

从手指离开触摸屏到调用单击侦听器时发生了很多事情。 每个运动事件都包括事件发生的时间 (MotionEvent.getEventTime())。 如果我们可以访问导致点击的 MotionEvent.UP 事件,我们就可以测量 Tap Response Time 的真正开始时间。

遍历和渲染

findNavController().navigate(R.id.action_title_to_about)
  • 在大多数应用程序中,上述代码启动片段事务。 该事务可能是立即的(commitNow())或发布的(commit())。

  • 当事务执行时,视图层次结构会更新并安排布局遍历。

  • 当布局遍历执行时,一个新的帧被绘制到一个表面上。

  • 然后它与来自其他窗口的帧合成并发送到显示器。

理想情况下,我们希望确切知道视图层次结构的更改何时在显示器上真正可见。 不幸的是,据我所知,没有 Java API,所以我们必须要有创意。

从点击到渲染

Main thread tracing

为了弄清楚这一点,我们在单击按钮时启用 Java 方法跟踪。

  1. MotionEvent.ACTION_UP 事件被调度,一个点击被发送到主线程。

  2. 发布的点击运行,点击侦听器调用 NavController.navigate() 并将片段事务发布到主线程。

  3. 片段事务运行,视图层次结构更新,并在主线程上为下一帧安排视图遍历。

  4. 视图遍历运行,视图层次结构被测量、布局和绘制。

Systrace

在步骤 4 中,视图遍历绘制通道生成绘制命令列表(称为显示列表)并将该绘制命令列表发送到渲染线程。

第 5 步:渲染线程优化显示列表,添加波纹等效果,然后利用 GPU 运行绘图命令并绘制到缓冲区(OpenGL 表面)。 完成后,渲染线程告诉表面抛掷器(位于单独的进程中)交换缓冲区并将其放在显示器上。

第6步(在systrace截图中不可见):所有可见窗口的表面由surface flinger和hardware composer合成,并将结果发送到显示器。

点击响应时间

我们之前将 Tap Response Time 定义为从用户按下按钮到应用对点击做出明显反应的时间。 换句话说,我们需要测量经过步骤 1 到 6 的总持续时间。

第 1 步:向上调度

我们定义了 TapTracker,一个触摸事件拦截器。 TapTracker 存储上次 MotionEvent.ACTION_UP 触摸事件的时间。 当发布的点击监听器触发时,我们通过调用 TapTracker.currentTap 来检索触发它的 up 事件的时间:

object TapTracker : TouchEventInterceptor {

var currentTap: TapResponseTime.Builder? = null
private set

private val handler = Handler(Looper.getMainLooper())

override fun intercept(
motionEvent: MotionEvent,
dispatch: (MotionEvent) -> DispatchState
): DispatchState {
val isActionUp = motionEvent.action == MotionEvent.ACTION_UP
if (isActionUp) {
val tapUptimeMillis = motionEvent.eventTime
// Set currentTap right before the click listener fires
handler.post {
TapTracker.currentTap = TapResponseTime.Builder(
tapUptimeMillis = tapUptimeMillis
)
}
}
// Dispatching posts the click listener.
val dispatchState = dispatch(motionEvent)

if (isActionUp) {
// Clear currentTap right after the click listener fires
handler.post {
currentTap = null
}
}
return dispatchState
}
}

然后我们将 TapTracker 拦截器添加到每个新窗口:

class ExampleApplication : Application() {

override fun onCreate() {
super.onCreate()

Curtains.onRootViewsChangedListeners +=
OnRootViewAddedListener { view ->
view.phoneWindow?.let { window ->
if (view.windowAttachCount == 0) {
window.touchEventInterceptors += TapTracker
}
}
}
}
}

第 2 步:单击侦听器和导航

让我们定义一个 ActionTracker,当发布的点击监听器触发时,它会被调用:

object ActionTracker {
fun reportTapAction(actionName: String) {
val currentTap = TapTracker.currentTap
if (currentTap != null) {
// to be continued...
}
}
}

以下是我们如何利用它:

aboutButton.setOnClickListener {
findNavController().navigate(R.id.action_title_to_about)
ActionTracker.reportTapAction("About")
}

但是,我们不想将该代码添加到每个点击侦听器中。 相反,我们可以向 NavController 添加目标侦听器:

navController.addOnDestinationChangedListener { _, dest, _ ->
ActionTracker.reportTapAction(dest.label.toString())
}

我们可以为每个选项卡添加一个目标侦听器。 或者我们可以利用生命周期回调向每个新的 NavHostFragment 实例添加目标侦听器:

class GlobalNavHostDestinationChangedListener
: ActivityLifecycleCallbacks {

override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (activity is FragmentActivity) {
registerFragmentCreation(activity)
}
}

private fun registerFragmentCreation(activity: FragmentActivity) {
val fm = activity.supportFragmentManager
fm.registerFragmentLifecycleCallbacks(
object : FragmentLifecycleCallbacks() {
override fun onFragmentCreated(
fm: FragmentManager,
fragment: Fragment,
savedInstanceState: Bundle?
) {
if (fragment is NavHostFragment) {
registerDestinationChange(fragment)
}
}
}, true
)
}

private fun registerDestinationChange(fragment: NavHostFragment) {
val navController = fragment.navController
navController.addOnDestinationChangedListener { _, dest, _ ->
val actionName = dest.label.toString()
ActionTracker.reportTapAction(actionName)
}
}

第三步:片段执行

调用 NavController.navigate() 不会立即更新视图层次结构。 相反,一个片段事务被发布到主线程。 当片段事务执行时,将创建并附加目标片段的视图。 由于所有挂起的片段事务都是一次性执行的,因此我们添加了自己的自定义事务以利用 runOnCommit() 回调。 让我们首先构建一个实用程序 OnTxCommitFragmentViewUpdateRunner.runOnViewsUpdated():

class OnTxCommitFragmentViewUpdateRunner(
private val fragment: Fragment
) {
fun runOnViewsUpdated(block: (View) -> Unit) {
val fm = fragment.parentFragmentManager
val transaction = fm.beginTransaction()
transaction.runOnCommit {
block(fragment.view!!)
}.commit()
}
}

然后我们将一个实例传递给 ActionTracker.reportTapAction():

class GlobalNavHostDestinationChangedListener
...
val navController = fragment.navController
navController.addOnDestinationChangedListener { _, dest, _ ->
val actionName = dest.label.toString()
- ActionTracker.reportTapAction(actionName)
+ ActionTracker.reportTapAction(
+ actionName,
+ OnTxCommitFragmentViewUpdateRunner(fragment)
+ )
}
}
}
 object ActionTracker {
- fun reportTapAction(actionName: String) {
+ fun reportTapAction(
+ actionName: String,
+ viewUpdateRunner: OnTxCommitFragmentViewUpdateRunner
+ ) {
val currentTap = TapTracker.currentTap
if (currentTap != null) {
- // to be continued...
+ viewUpdateRunner.runOnViewsUpdated { view ->
+ // to be continued...
+ }
}
}
}

第 4 步:帧和视图层次遍历

当片段事务执行时,会为下一帧安排一次视图遍历,我们使用 Choreographer.postFrameCallback() 将其挂钩:

object ActionTracker {
+
+ // Debounce multiple calls until the next frame
+ private var actionInFlight: Boolean = false
+
fun reportTapAction(
actionName: String,
viewUpdateRunner: OnTxCommitFragmentViewUpdateRunner
) {
val currentTap = TapTracker.currentTap
- if (currentTap != null) {
+ if (!actionInFlight & currentTap != null) {
+ actionInFlight = true
viewUpdateRunner.runOnViewsUpdated { view ->
- // to be continued...
+ val choreographer = Choreographer.getInstance()
+ choreographer.postFrameCallback { frameTimeNanos ->
+ actionInFlight = false
+ // to be continued...
+ }
}
}
}
}

第 5 步:渲染线程

视图遍历完成后,主线程将显示列表发送到渲染线程。 渲染线程执行额外的工作,然后告诉表面flinger交换缓冲区并将其放在显示器上。 我们注册一个 OnFrameMetricsAvailableListener 来获取总帧持续时间(包括在渲染线程上花费的时间):

 object ActionTracker {
...
val choreographer = Choreographer.getInstance()
choreographer.postFrameCallback { frameTimeNanos ->
actionInFlight = false
- // to be continued...
+ val callback: (FrameMetrics) -> Unit = { frameMetrics ->
+ logTapResponseTime(currentTap, frameMetrics)
+ }
+ view.phoneWindow!!.addOnFrameMetricsAvailableListener(
+ CurrentFrameMetricsListener(frameTimeNanos, callback),
+ frameMetricsHandler
+ )
}
}
}
}
+
+ private fun logTapResponseTime(
+ currentTap: TapResponseTime.Builder,
+ fM: FrameMetrics
+ ) {
+ // to be continued...
+ }

一旦我们有了帧指标,我们就可以确定帧缓冲区何时被交换,因此是 Tap 响应时间,即从 MotionEvent.ACTION_UP 到缓冲区交换的时间:

object ActionTracker {
...
currentTap: TapResponseTime.Builder,
fM: FrameMetrics
) {
- // to be continued...
+ val tap = currentTap.tapUptimeMillis
+ val intendedVsync = fM.getMetric(INTENDED_VSYNC_TIMESTAMP)
+ // TOTAL_DURATION is the duration from the intended vsync
+ // time, not the actual vsync time.
+ val frameDuration = fM.getMetric(TOTAL_DURATION)
+ val bufferSwap = (intendedVsync + frameDuration) / 1_000_000
+ Log.d("TapResponseTime", "${bufferSwap-tap} ms")
}
}

SurfaceFlinger

没有 Java API 来确定合成帧何时最终由 SurfaceFlinger 发送到显示器,因此我没有包含该部分。


收起阅读 »

iOS swiftUI 视图动画和转场 1.1

第二节 把视图的状态改态转化成动画效果已经学会了给单个视图添加动画的方法,现在可以学习怎么在视图的状态发生改变时添加动画效果。当用户点击按钮时会切换showDetail状态的值,在视图变化过程中添加动画效果。步骤1 把showDetail.toggl...
继续阅读 »

第二节 把视图的状态改态转化成动画效果

已经学会了给单个视图添加动画的方法,现在可以学习怎么在视图的状态发生改变时添加动画效果。当用户点击按钮时会切换showDetail状态的值,在视图变化过程中添加动画效果。

state change

步骤1 把showDetail.toggle()包裹在withAnimation函数调用块中。showDetail的改变影响了视图HikeDetail和详情切换按钮,在显示/隐藏详情的过程中都有了过滤动画效果。

with_animation block

放慢动画速度,可以观察SwiftUI动画在被中断下是怎么运作的

步骤2 给withAnimation传入一个时长4秒的基本动画参数.easeInOut(duration:4),可以指定动画过程时长,给withAnimation传入的动画参数与.animation(_:)修改器可用参数一致。

with animation duration block

步骤3 在动画过程进行中点击按钮切换视图状态,查看对应的动画被中断时的效果

with animation interrupt

步骤4 读下一节之前,把动画时长参数(.easeInOut(duration: 4))去掉,让动画不再缓慢进行。

第三节 定制视图转场动画

默值情况下,视图离屏和入屏时的动画效果是渐隐/渐现, 这个默认的转场效果可以使用transition(_:)修改器进行定制。

transitions

步骤1 给HikeView视图添加transition(_:)修改器,并定制转场参数为.slide,转场动画为滑入/滑出

transition slide

步骤2 可以把滑入/滑出这种转场动画封装起来,方便其它视图复用同样的转场效果

custom transition effect

步骤3 在moveAndFade转场效果的定义中使用move(edge:),让滑入/滑出从屏幕的同一边进行

move and fade custom

步骤4 使用asymmetric(insertion:removal:)修改器来定制视图显示/消失时的转场动画效果

custom move and fade slide scale

第四节 组合复杂的动画效果

点击图表下面的三个按钮,会在三个不同的数据集间进行切换并展示。本节中会使用组合动画,让图表在不同数据集间切换时的转换动画流畅自然。

combine animation

步骤1 把showDetail的默认值改为true,并把HikeView的预览模式视图固定在画布上。这样可以在编辑其它文件时,依然看到动画效果的变化。

pin canvas

步骤2 在HikeGraph.swift中定义了一个新的波动动画,并把它与滑入/滑出动画一起应用到图表视图上。

hike graph ripple

步骤3 把动画切换为弹簧动画(spring),并设置弹簧阻尼系数为0.5,动画过程中产生了逐渐回弹效果

spring animation

步骤4 加速弹簧动画的执行速度,缩短切换图表的时间

spring animation speed

步骤5 以当条形在图表中的位置为参数,添加延迟效果,图表中的每个条形会顺序动起来

spring animation index based delay

步骤6 观察一下自定义波动(rippling)效果是怎么作用在视图转场中的

检查是否理解

问题1 怎样从一串动画效果调用中,去掉其中的一种动画效果。以下面的代码为例,怎样去掉旋转动画

problem 1

  • a1
  • a2
  • a3

问题2 当你开发动画的过程上,为什么要把预览视图固定在画布上?

  •  为了固定动画过程中的当前帧
  •  为了在多个设备配置开发中预览动画效果
  •  为了在切换到其它不同文件时,固定显示当前视图的预览

问题3 在视图状态改变时,如何快速测试一个动画在被中断时的表现

  •  在包含animation(_:)修改器的代码行上打一个断点,然后单步按动画帧进行测试
  •  调整动画的持续时长,让动画在足够长的时间内完成,这样就可以调整动画的细节
  •  重复的调用sleep(100)来减慢动画的执行
收起阅读 »

iOS SwiftUI 视图动画和转场

视图动画和转场使用SwiftUI可以把视图状态的改变转成动画过程,SwiftUI会处理所有复杂的动画细节在这篇中,会给跟踪用户徒步的图表视图添加动画。使用animation(_:)修改器给一个视图添加动画效果非常容易下载起步项目并跟着本篇教程一步步实践,或者查...
继续阅读 »

视图动画和转场

使用SwiftUI可以把视图状态的改变转成动画过程,SwiftUI会处理所有复杂的动画细节

在这篇中,会给跟踪用户徒步的图表视图添加动画。使用animation(_:)修改器给一个视图添加动画效果非常容易

下载起步项目并跟着本篇教程一步步实践,或者查看本篇完成状态时的工程代码去学习


第一节 给每个视图单独添加动画

在视图上使用animation(_:)修改器时,SwiftUI会在视图的任何可进行动画的属性发生改变时产生对应的动画效果。视图的颜色、不透明度、旋转角度、大小及一些其它属性都是可进行动画的

animate button

步骤1 在HikeView.swift中,打开实时预览,体验一下图表的打开和隐藏,此时的状态改变时是没有添加动画效果的。在本篇的实践中,保持实时预览一直打开,每一步修改的效果就可以实时的看到

live preview animation

步骤2 给显示/隐藏切换的箭头按钮添加旋转动画,会发现现在按钮点击时的旋转有一个动画过渡的效果了

rotate button animation

rotate button animation video

步骤3 当视图从隐藏到展示时,让切换按钮变大1.5倍

rotate button scale

rotate button scale video

步骤4 把动画的类型从easeInOut改为spring()。SwiftUI包含一些预设或可自定义的动画类型,像弹簧(spring)动画和类型液体(fluid)动画类型。可以调整动画开始前的等待时长、动画的速度也可以指定让动画循环重复的进行

rotate button spring

步骤5 如果只想让按钮具有缩放动画而不进行旋转动画,可以在scaleEffect添加animation(nil)来实现。可以在这里做一些实验,如果把其它的一些动画效果结合在一起,会怎么样

rotate button no rotate

步骤6 学下一节之前,把本节中添加的animation(_:)修改器都去掉

rotate button resume

收起阅读 »

iOS SwiftUI 创建和组合视图 4.2

第三节 绘制徽章符号地标徽章中心有一个以地标App图标中的山峰图形改造形成的标志。山峰这个符号由两个形状组成,一个是表示山顶被雪覆盖的部分,另一个是山体。这里会使用有一定间距的两个局部三角形形状绘制这个徽章符号步骤1 把之前的徽章视图形状抽出来单独形...
继续阅读 »

第三节 绘制徽章符号

地标徽章中心有一个以地标App图标中的山峰图形改造形成的标志。山峰这个符号由两个形状组成,一个是表示山顶被雪覆盖的部分,另一个是山体。这里会使用有一定间距的两个局部三角形形状绘制这个徽章符号

badge symbol

步骤1 把之前的徽章视图形状抽出来单独形成一个BadgeBackground视图,并生成一个新的视图文件BadgeBackground.swift

badge background

步骤2 把BadgeBackground放在Badgebody属性中。

refactor badge

步骤3 创建名为BadgeSymbol的自定义视图,这个视图是一个山峰的形状,把这个形状复制多次并按一定角度旋转多次拼成一个徽章的图案

badge symbol

步骤4 使用pathAPI来绘制徽章符号的上半部分,试着调节spacingtopWidthtopHeight的系数,观察这些系数是怎么影响图形绘制的结果的

badge symbol top

步骤5 绘制徽章图案的下半部分,使用move(to:)把绘图光标移到另一个图形绘制的起点,绘制新的形状

badge symbol bottom

步骤6 用紫色填充徽章符号

badge symbol fill

第四节 组合徽章的前景符号和背景形状

徽章设计思路是在背景形状上面再绘制多个有固定旋转角度的山峰符号。定义一个新的类型用于展示旋转一定角度的徽章符号,使用ForEach生成不同旋转角度的山峰符号,绘制在徽章背景上,从而形成最终的徽章。

badge combine

步骤1 创建RotatedBadgeSymbol视图封装旋转徽章符号,调整旋转的角度,并在预览视图中查看效果

badge symbol rotate 4

步骤2 在Badge.swift中,使用ZStack把徽章图标放在徽章背景层上面。此时会发现,徽章符号的尺寸相比徽章背景大了许多,这不符合最初设计的预期

badge symbols

步骤3 缩放符号尺寸到合适的大小

badge geometry scale

步骤4 使用ForEach复制多个徽章图标,按360度周解均分,每一个徽章符号都比前一个多旋转45度,这种就会形成一个类似太阳和徽章图标

badge symbol completed

检查是否理解

问题1 GeometryReader的作用是什么?

  •  GeometryReader可以把父视图分割成网格,便于在屏幕上布局视图
  •  GeometryReader可以动态的绘制、定位、缩放视图,不需要写死它们的尺寸。这样可以在不同尺寸的屏幕上复用已经写好的视图
  •  使用GeometryReader可以自动识别应用视图层级上形状的类型和位置,例如: (圆)Circle

问题2 下面代码段布局后是哪一个图?

problem 2

  • answer 1
  • answer 2
  • answer 3

问题3 下面代码绘制出哪个图?

problem 3

  • answer 1
  • answer 2
  • answer 3
收起阅读 »

iOS SwiftUI 创建和组合视图 4.1

绘制和动画学习绘制形状和路径,并创建徽章和添加动画包含章节绘制路径和形状视图动画和转场绘制路径和形状用户在浏览完一个地标后会得到一个徽章。但用户要得到徽章首先要先要创建一个徽章。本篇教程就是使用路径和形状创建徽章的过程,创建的徽章可以和其它图形组合形成位置标志...
继续阅读 »

绘制和动画

学习绘制形状和路径,并创建徽章和添加动画

drawing and animation

包含章节

  • 绘制路径和形状
  • 视图动画和转场
  • 绘制路径和形状

    用户在浏览完一个地标后会得到一个徽章。但用户要得到徽章首先要先要创建一个徽章。本篇教程就是使用路径和形状创建徽章的过程,创建的徽章可以和其它图形组合形成位置标志。

    如果想要针对不同种类的地标创建不同的徽章,可以尝试改变徽章基本组成符号的重复次数、角度或大小。

    跟着教程一步步走,可以下载工程文件进行实践。


    第一节 创建徽章视图

    创建徽章前需要使用SwiftUI的矢量绘画API创建一个徽章视图

    badge

    步骤1 选择文件->新建->文件,然后从iOS文件模板列表中选择SwiftUI View。点击下一步(Next),输入文件名Badge后点击创建(Create)

    create file

    name file

    步骤2 调整Badge视图,暂时先让它显示"Badge"文本,一会儿再绘制徽章的形状

    badge text

    第二节 绘制徽章背景

    使用SwiftUI的图形API绘制一个徽章形状

    badge background

    步骤1 查看在文件HexagonParameters.swift中的代码。HexagonParameters结构体定义了绘制徽章六边形形状的控制点参数。不需要修改这些绘制相关的数据,仅仅使用这些数据指定绘制徽章形状时,线段和曲线的控制点位置。

    hexagonal data

    步骤2 在Badge.swift文件中,绘制徽章的形状并使用fill修改器给六边形填充颜色,形成一个视图。使用路径可以把多条直线、曲线或其它绘制形状的基本笔划连成一个复杂的图形,就像形成徽章六边形背景这样.

    Path

    步骤3 给路径添加起点,move(to:)方法可以把绘图光标移动到绘图中的一点,准备绘制的起点

    path start point

    步骤4 使用六边形的绘制参数数据HexagonParameters,依次绘制六边形的边,形成大致轮廓.addLine(to:)方法会使用当前绘图光标所在点为起点,方法参数中指定的点为终点绘制直线。目前六边形看起来有点问题,不过不要担心,这是意料中的事,下面的步骤做完,六边形的形状就会和开头显示的徽章的六边形形状一致了

    path fill

    步骤5 使用addQuadCurve(to:control:)方法绘制贝塞尔曲线,让六边形的角变的更圆润些。

    badge hexagonal

    步骤6 把徽章路径包裹在一个Geometry Reader中,这样徽章可以使用容器的大小,定义自己绘制的尺寸,这样就不需要硬编码绘制尺寸了(100)。当绘制区域不是正方形时,使用绘制区域的最小边长(长宽中哪个最小使用哪个)作为绘制徽章背景的边长,并保持徽章背景的长宽比为1:1

    geometry reader

    步骤7 使用xScalexOffset参数调整变量,把徽章几何绘图区域居中绘制出来

    badge square

    步骤8 把黑色实心填充色改为渐变色,使徽章看上去和开始设计的样式一致

    badge gradient

    步骤9 渐变色上再使用aspectRatio(_:contentMode:)修改器,让渐变色按内容宽高比进行成比例渐变填充。保持1:1的长宽比,徽章背景可以保持居中在徽章视图中,不管徽章视图本身是不是正方形

    badge center

收起阅读 »

学不好Lambda,能学好Kotlin吗

嗯,当然 不能 进入正题,Kotlin中,高阶函数的身影无处不在,听上去高端大气上档次的高阶函数,简化一点讲,其实就是Lambda + 函数。 如果,Lambda学不好,就会导致高阶函数学不好,就会导致协程等等一系列的Kotlin核心学不好,Kotlin自然就...
继续阅读 »

嗯,当然


不能


进入正题,Kotlin中,高阶函数的身影无处不在,听上去高端大气上档次的高阶函数,简化一点讲,其实就是Lambda + 函数。


如果,Lambda学不好,就会导致高阶函数学不好,就会导致协程等等一系列的Kotlin核心学不好,Kotlin自然就一知半解了。所以,下面,一起来学习吧。


开始一个稍微复杂一点的实现


需求如下:传入一个参数,打印该参数,并且返回该参数


分析


乍看需求,这还不简单,一个print加一个return不就完事了,但是如果用Lambda,该怎么写呢?


val myPrint = { str: String ->
print("str is $str")
str
}


  • 这里划第一个重点,Lambda的最后一行作为返回值输出。此时,如果直接打印myPrint,是可以直接输出的


fun main() {
println(myPrint("this is kotlin"))
}

image.png


结果和预想一致。如果对这种函数的写法结构有什么疑惑的,可以查看juejin.cn/post/701173…


String.()


一脸懵逼,这是啥玩意?(此处应有表情 尼克杨问号脸)


先写个例子看看


val testStr : String.() -> Unit = {
print(this)
}


  • 官方一点解释,在.和()之间没有函数名,所以这是给String增加了一个匿名的扩展函数,这个函数的功能是打印String。在括号内,也就是Lambda体中,会持有String本身,也就是this。怎么调用呢?如下:


fun main() {
"hello kotlin".testStr()
}
// 执行结果:hello kotlin


  • 此外这里还有一个重点:扩展函数是可以全局调用的

  • 扩展函数有啥用?举个例子,如果对Glide提供的方法不满意,可以直接扩展一个Glide.xxx函数供自己调用,在xxx函数内部,可以取到this,也就是Glide本身。

  • 有兴趣可以看一下Compose的源码,原来扩展函数还可以这么用


终极形态


先看代码


val addInt : Int.(Int) -> String = {
"两数相加的结果是${this + it}"
}

用已有的知识分析一下:



  • Int.():匿名的扩展函数

  • this:当前的Int,也就是调用这个扩展函数的对象

  • "两数相加的结果是${this + it}" : Lambda的最后一行,也就返回值


如何调用


一般有如下两种调用方式:


fun main() {
println(addInt(1,2))
println(1.addInt(2))
}


  • 第二种更加符合规范,之所以可以有第一种写法,是因为this会默认作为第一个参数

  • 此处可以记住一个知识点,扩展了某一个函数,扩展函数内部的this就是被扩展的函数本身


Kotlin函数返回值那些事


在Kotlin函数中,如果不指定函数的返回值类型,则默认为Unit


fun output() {println("helle kotlin")}


  • 上述函数的返回值为Unit类型


当函数体中出现return的时候,则需要手动为函数指定类型


fun output2() : Int {
return 0
}


  • 返回Int类型的0,需要手动指定函数的返回值类型,否则报错


如果是以下的函数,那么返回值为?


fun output3() = {}


  • 此处的返回值为() -> Unit,可省略,写全了,就是如下的样子:


fun output3() : () -> Unit = {}


  • 此处函数返回函数,已经是高阶函数的范畴了


如果函数接着套一个函数呢,比如


fun output4() = run { println("hello kotlin") }


  • 虽说run是一个函数,但是此处的返回值就不是() -> Unit

  • 此处的返回就是run的返回值,但是run是什么?


@kotlin.internal.InlineOnly
public inline fun <R> run(block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}


  • run的作用就是执行内部的函数,在这里就是println方法。

  • run的返回自是R,也就是泛型,具体一点就是println的返回值,这里println的返回值是Unit,所以可以得出上面的output4的返回值就是Unit。

  • 这里如果不是很懂的话,可以看一个简单一点的例子


fun output5() = run {true}


  • 此处,函数的返回值就是true的类型,Boolen


函数中套一个函数怎么传参呢


刚刚的例子中,知道了怎么写一个函数中套函数,那么其中嵌套得函数怎么传参呢


fun output6() = {a: Int ->  println("this is $a")}


  • a为参数,函数是println,所以output6的返回值类型为(Int) -> Unit

  • 如果需要调用的话,需要这么写:


output6()(1)

最后一个重点:在写Lambda的时候,记住换行


几种函数写法的区别


fun a()


常见的函数


val a = {}


a是一个变量,只不过是一个接受了匿名函数的变量,可以执行这个函数,和第一种现象一致。


这里的a还可以赋值给另一个变量 val a2 = a,但是函数本身不能直接赋给一个变量,可以使用::,让函数本身变成函数的引用


--end---


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

Flutter开发·Stream的理解与简单使用

介绍 Stream和Future都是在Flutter中常用来处理异步事件的对象,与Future只能处理单次异步操作不同的是,Stream具有多次响应异步事件监听的功能,是一系列异步事件的序列。 分类 Stream从订阅模式上分可以分为两类,一个是单订阅模式,另...
继续阅读 »

介绍


Stream和Future都是在Flutter中常用来处理异步事件的对象,与Future只能处理单次异步操作不同的是,Stream具有多次响应异步事件监听的功能,是一系列异步事件的序列。


分类


Stream从订阅模式上分可以分为两类,一个是单订阅模式,另一个是多订阅模式,也称广播模式,单订阅模式表示只能有1个监听器对事件进行监听,即使前面的监听器取消了监听,也无法继续对这个stream进行二次监听,而多订阅模式则可以有多个监听器。


组成


Stream中主要包含了四个对象:



  • Stream:事件源,一般用于事件监听或事件转换等。

  • StreamController: 方便进行Stream管理的控制器。

  • StreamSink: 事件的输入口,包含add等方法进行事件发送。

  • StreamSubscription: Stream进行listen监听后得到的对象,用来管理事件订阅,包含取消监听等方法。


写法


单订阅模式


直接使用StreamController<int>()进行初始化,然后获取到stream对象进行listen监听。当点击按钮时通过获取sink对象,调用add方法进行事件发送,这样listen方法中就可以监听到事件响应。


StreamController<int> singleStreamController = StreamController<int>();

int num = 0;

@override
void initState() {
// TODO: implement initState
super.initState();
singleStreamController.stream.listen((event) {
setState(() {
num = event;
});
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
children: [
Text("the num is $num"),
FlatButton(onPressed: (){
singleStreamController.sink.add(1);
}, child: Text("点击"))
],
),
);
}

在上面的代码中,如果我在点击按钮时再开启一个监听的话,则会报如下错误,这就是单订阅模式的限制。


image.png


多订阅模式(广播模式)


正如广播这个名字一样,Stream也提供了broadcast方法生成可以注册多个监听器的stream。只需要将上面的初始化代码替换成这句话即可。


StreamController<int> singleStreamController = StreamController<int>.broadcast();

当我多次进行stream事件监听时,程序没有出现出任何的错误,这就是多订阅模式。


image.png


注意,在不再使用stream事件监听时要及时调用close和cancel方法进行取消订阅和事件流关闭。


StreamBuilder


上面的例子是点击界面中的按钮改变数值,通过sink发送事件,然后stream监听到数值变化再进行setState进行状态改变。其实流程还是有点绕的,Stream中也提供了可以直接在控件中获取Stream监听的写法就是StreamBuilder。如上面的代码可以写成:


StreamController<int> singleStreamController = StreamController<int>.broadcast();

@override
void initState() {
// TODO: implement initState
super.initState();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Column(
children: [
StreamBuilder<Object>(
stream: singleStreamController.stream,
builder: (context, snapshot) {
return Text("the num is ${snapshot.data}");
}
),
FlatButton(onPressed: (){
singleStreamController.sink.add(1);
}, child: Text("点击"))
],
),
);
}

其中,stream参数就是需要进行监听的数据对应的事件流对象,initialData参数是指定当stream还没有存储过数据时的默认值。builder方法中返回需要构建的控件,其中的snapshot参数为数据快照,它的data就是所存储的数据。
这样就可以在控件中监听到数据的变化,无需再调用setState方法。通过指定StreamBuilder中的stream对象,从而实现数据与界面的绑定。


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

Flutter输入框获取剪切板-合规问题踩坑

前言:公司法务部检测出Flutter开发的App存在未同意隐私协议先获取系统剪切板数据的问题,要求整改。经过一系列调试后,定位到原来是Flutter输入框的坑,只要使用到输入框,就会先获取下剪切板数据。还没有属性可以关闭,着实踩坑,以下记录分享给大家,希望能稳...
继续阅读 »

前言:公司法务部检测出Flutter开发的App存在未同意隐私协议先获取系统剪切板数据的问题,要求整改。经过一系列调试后,定位到原来是Flutter输入框的坑,只要使用到输入框,就会先获取下剪切板数据。还没有属性可以关闭,着实踩坑,以下记录分享给大家,希望能稳稳避坑......



合规问题-获取剪切板数据


这个问题首次出现其实是在去年iOS14上线直接把app应用获取剪贴板内容的行为直接暴露出来。2020年6月29日,抖音海外版TikTok因为频繁读取用户剪贴板内容引争议,甚至被作为后面将其驱逐出海外市场的导火索。
国内监管部门虽然并没有明确的对访问剪贴板内容的直接要求,但是随着近年来社会上对隐私保护的重视和媒体关注,接下来会有发酵可能。


获取剪切板内容的应用场景


目前国内剪切板内容主要应用场景是类似淘口令之类的方式,通过读取剪切板的内容,弹出对应的内容;更有甚者,采集用户剪切板数据进行大数据分析,因为用户复制的内容,具备极高的用户兴趣导向,作为大数据训练素材准确性很高。
而Flutter输入框为何也获取剪切板内容,有留意过长按输入框的交互吗? 长按会有toolbar提供粘贴、复制等功能,而粘贴就必须先获取剪切板的内容。
然后基本上App的登录页都有输入框,只要你在用户同意隐私协议之前,显示了Flutter中的TextField,就必然会触发这个潜在的合规问题。 🐶


Flutter输入框是如何获取剪切板数据的


这个问题需要我们一步步来跟踪源码。



  1. 首先看TextField的源码,有一个属性enableInteractiveSelection,可以理解为启用交互式选择。从业务逻辑出发,把这个属性设为false,应该就不会出现toolbar了,那应该不需要获取剪切板数据以提供粘贴功能。


/// text_field.dart
/// TextField的常量构造函数
const TextField({
Key? key,
this.controller,
this.focusNode,
this.decoration = const InputDecoration(),
TextInputType? keyboardType,
this.textInputAction,
this.textCapitalization = TextCapitalization.none,
this.style,
this.strutStyle,
this.textAlign = TextAlign.start,
this.textAlignVertical,
this.textDirection,
this.readOnly = false,
ToolbarOptions? toolbarOptions,
this.showCursor,
this.autofocus = false,
this.obscuringCharacter = '•',
this.obscureText = false,
this.autocorrect = true,
SmartDashesType? smartDashesType,
SmartQuotesType? smartQuotesType,
this.enableSuggestions = true,
this.maxLines = 1,
this.minLines,
this.expands = false,
this.maxLength,
@Deprecated(
'Use maxLengthEnforcement parameter which provides more specific '
'behavior related to the maxLength limit. '
'This feature was deprecated after v1.25.0-5.0.pre.',
)
this.maxLengthEnforced = true,
this.maxLengthEnforcement,
this.onChanged,
this.onEditingComplete,
this.onSubmitted,
this.onAppPrivateCommand,
this.inputFormatters,
this.enabled,
this.cursorWidth = 2.0,
this.cursorHeight,
this.cursorRadius,
this.cursorColor,
this.selectionHeightStyle = ui.BoxHeightStyle.tight,
this.selectionWidthStyle = ui.BoxWidthStyle.tight,
this.keyboardAppearance,
this.scrollPadding = const EdgeInsets.all(20.0),
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true // 这个属性
})

/// 确实也是通过这个变量控制交互toolbar的显示与否
class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder {
_TextFieldSelectionGestureDetectorBuilder({
required _TextFieldState state,
}) : _state = state,
super(delegate: state);

final _TextFieldState _state;

@override
void onForcePressStart(ForcePressDetails details) {
super.onForcePressStart(details);
if (delegate.selectionEnabled && shouldShowSelectionToolbar) {
editableText.showToolbar();
}
}

@override
void onForcePressEnd(ForcePressDetails details) {
// Not required.
}
// 省略源码 *****
}

通过源码可以知道,TextField的真实渲染对象是editableText,editableText中会判断传入的enableInteractiveSelection,为false不去获取剪切板内容


/// editable_text.dart

bool get selectionEnabled => enableInteractiveSelection;

@override
void didUpdateWidget(EditableText oldWidget) {
super.didUpdateWidget(oldWidget);
// 省略代码*****
if (widget.style != oldWidget.style) {
final TextStyle style = widget.style;
// The _textInputConnection will pick up the new style when it attaches in
// _openInputConnection.
if (_hasInputConnection) {
_textInputConnection!.setStyle(
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
textDirection: _textDirection,
textAlign: widget.textAlign,
);
}
}
// selectionEnabled即enableInteractiveSelection,
// 为false不调用update()。update方法后面会讲到,其实就是这个方法在获取剪切板内容
if (widget.selectionEnabled && pasteEnabled && widget.selectionControls?.canPaste(this) == true) {
_clipboardStatus?.update();
}
}

到这里,一切都很顺利,因为业务不需要启用交互,那么Flutter就没理由随意获取剪切板数据。然而坑就出在这里,即便enableInteractiveSelection设置为false,Flutter还是在另一个地方获取了剪切板内容,而且没有属性可配置!!!🔥
我们来到EditableTextState类,里面有_clipboardStatus私有变量,监听系统剪切板变化的变量,通过ValueNotifier进行通知。


/// editable_text.dart
void _onChangedClipboardStatus() {
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
});
}

// State lifecycle:

@override
void initState() {
super.initState();
_clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue);
_focusAttachment = widget.focusNode.attach(context);
widget.focusNode.addListener(_handleFocusChanged);
_scrollController = widget.scrollController ?? ScrollController();
_scrollController!.addListener(() { _selectionOverlay?.updateForScroll(); });
_cursorBlinkOpacityController = AnimationController(vsync: this, duration: _fadeDuration);
_cursorBlinkOpacityController.addListener(_onCursorColorTick);
_floatingCursorResetController = AnimationController(vsync: this);
_floatingCursorResetController.addListener(_onFloatingCursorResetTick);
_cursorVisibilityNotifier.value = widget.showCursor;
}

initState是必定要走addListener方法的,而addListener里面就自动调用了前面的_clipboardStatus.update()方法,读取了剪切板内容


/// text_selection.dart
@override
void addListener(VoidCallback listener) {
if (!hasListeners) {
WidgetsBinding.instance!.addObserver(this);
}
if (value == ClipboardStatus.unknown) {
update();
}
super.addListener(listener);
}

/// Check the [Clipboard] and update [value] if needed.
Future<void> update() async {
// iOS 14 added a notification that appears when an app accesses the
// clipboard. To avoid the notification, don't access the clipboard on iOS,
// and instead always show the paste button, even when the clipboard is
// empty.
// TODO(justinmc): Use the new iOS 14 clipboard API method hasStrings that
// won't trigger the notification.
// https://github.com/flutter/flutter/issues/60145
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
value = ClipboardStatus.pasteable;
return;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
}

ClipboardData? data;
try {
// 这里获取了剪切板数据
data = await Clipboard.getData(Clipboard.kTextPlain);
} catch (stacktrace) {
// In the case of an error from the Clipboard API, set the value to
// unknown so that it will try to update again later.
if (_disposed || value == ClipboardStatus.unknown) {
return;
}
value = ClipboardStatus.unknown;
return;
}

final ClipboardStatus clipboardStatus = data != null && data.text != null && data.text!.isNotEmpty
? ClipboardStatus.pasteable
: ClipboardStatus.notPasteable;
if (_disposed || clipboardStatus == value) {
return;
}
value = clipboardStatus;
}

解析完毕,坑的原因找出来了,但是填坑却没那么简单!


如何避坑


既然源码实现如此,要改只能改源码,但我并不建议这么改,改源码对于协同开发很不友好。



  1. 当用户禁用了交互,且合规问题暴露出来,我们认为官方势必要解决这个问题,于是我先给官方提了issue

  2. 合规规定同意用户协议后,才能获取剪切板行为,那么我们完全可以从流程去避开这个问题:


用户未同意协议前,不要进入到带有输入框的页面;现在很多app也是这样做的,未同意协议就停留在闪屏页吧,能省好多事;
② 流程实在难改,就把输入框先换成普通的Container,同意后再换成textField就可以啦。


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