标注
过渡动画最好都在Activity之间实现
Android 的过渡动画可以分为四个部分:
-
Activity/Fragment 切换时的内容过渡动画(content transition)
-
Activity/Fragment 切换时的共享元素过渡动画
-
同一个页面中的场景过渡动画
-
共享元素过渡动画 + 揭露效果(circular reveal)
首先先讲解一下Transition
Lollipop(5.0) 中 Activity 和 Fragment 的过渡动画是基于 Android 一个叫作 Transition 的新特性实现的。 初次引入这个特性是在 KitKat(4.4) 中,Transition 框架提供了一个方便的 API 来构建应用中不同 UI 状态切换时的动画。 这个框架始终围绕两个关键概念:场景(scenes)和转换(transitions)。场景定义了一个确定的 UI 状态,而转换定义了两个场景切换时的动画。
当两个场景切换时,Transition 主要有下面两个行为:
(1)确定开始场景和结束场景中每个 view 的状态。
(2)根据状态差异创建 Animator,用以场景切换时 view 的动画。
Activity/Fragment 切换时的内容过渡动画
当从Activity A
跳转到Activity B
,内容布局将根据定义好的过渡做动画变换,在android
过渡动画预先定义了三种过渡动画,分别是Explode
,Slide
和Fade
,这些过渡动画会跟踪当前设置的View
,并执行相应的动画。
- 分解(Explode) - 从场景中心移入或移出视图
- 滑动(Slide)- 从场景边缘移入或移出视图
- 淡入淡出(Fade)- 通过调整透明度在场景中增添或移除视图
根据上述情景,可以为activity划分出4种场景的动画:
- Activity A 的 退出动画(ExitTransition ),即 A 启动 B 时 A 中 View 的动画
- Activity B 的 进入动画(EnterTransition ),即 A 启动 B 时 B 中 View 的动画
- Activity B 的 返回动画(ReturnTransition),即 B 返回 A 时 B 中 View 的动画
- Activity A 的 重入动画(ReenterTransition), 即 B 返回 A 时 A 中 View 的动画
实现步骤
1.打开窗口内容转换开关
- 在风格定义中设置:
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- enable window content transitions -->
<item name="android:windowContentTransitions">true</item>
//还要加一条,不然会出现白屏哦
<item name="android:windowIsTranslucent">true</item>
</style>
- 在代码中设置:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState)
getWindow().requestFeature(Window.FEATURE_CONTENT_TRANSITIONS)
}
2.指定进入、退出转换
- 在风格定义中设置:(标注:如果没有指定
returnTransition
和reenterTransition
,返回 MainActivity 时会分别执行反转的进入和退出转换)
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- specify enter and exit transitions -->
<item name="android:windowEnterTransition">@transition/activity_enter</item>
<item name="android:windowExitTransition">@transition/activity_exit</item>
</style>
转换文件存放在res/transition
目录下
activity_enter.xml
<transitionSet xmlns:android="http://schemas.android.com/apk/res/android" android:duration="@integer/transition_time" android:interpolator="@android:interpolator/decelerate_quad">
<slide android:slideEdge="right" >
</slide>
</transitionSet>
activity_exit.xml
<?xml version="1.0" encoding="utf-8"?>
<transitionSet xmlns:android="http://schemas.android.com/apk/res/android" android:duration="@integer/transition_time" android:interpolator="@android:interpolator/decelerate_quad">
<slide android:slideEdge="right" >
</slide>
</transitionSet>
-
在代码中设置:
Activity A
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_transition);
setupWindowAnimations();
}
private void setupWindowAnimations() {
// inflate from xml
Slide slide = TransitionInflater.from(this).inflateTransition(R.transition.activity_slide);
// or create directly
Slide slide = new Slide();
slide.setDuration(1000);
getWindow().setExitTransition(slide);
}
Activity B
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_transition);
setupWindowAnimations();
}
private void setupWindowAnimations() {
// inflate from xml
Fade fade = TransitionInflater.from(this).inflateTransition(R.transition.activity_fade);
// or create directly
Fade fade = new Fade();
fade.setDuration(1000);
getWindow().setEnterTransition(fade);
}
3.调用跳转命令
ActivityCompat.startActivity(TransitionActivity.this, intent, ActivityOptionsCompat.makeSceneTransitionAnimation(TransitionActivity.this).toBundle());
内容过渡动画的原理
内容过渡动画的创建之前,必须要确定视图的状态信息,而这需要修改所有过渡视图(transitioning view)的可见性。当 Activity A 启动 Activity B 时,下面的事件将会发生:
1) Activity A 调用 startActivity()
-
系统遍历 A 的视图节点,找到将要运行退退出转换的所有过渡视图
-
A 的退出转换记录所有过渡视图的开始状态
-
系统将所有过渡视图的可见性设置为
INVISIBLE
-
在下一帧,A 的退出转换记录所有过渡视图的结束状态
-
A 的退出转换比较每个过渡视图的开始状态和结束状态,然后创建 Animator 作为退出动画,运行该动画。
2)Activity B 启动了
-
系统遍历 B 的视图节点,找到将要运行进入转换的所有过渡视图,设置这些过渡视图的可见性为
INVISIBLE
-
B 的进入转换记录所有过渡视图的开始状态
-
系统将所有过渡视图的可见性设置为
VISIBLE
-
在下一帧,B 的进入转换记录所有过渡视图的结束状态
-
B 的进入转换比较每个过渡视图的开始状态和结束状态,然后创建 Animator 作为进入动画,运行该动画。
通过上面的分析可以知道,所有的内容转换都需要记录每个过渡视图的开始状态和结束状态。而抽象类Visibility
已经做了这部分内容了,Visibility
的子类只需要实现 onAppear() 和 onDisappear() 方法,创建过渡视图进入或退出场景的 Animator。Android 5.0 中Visibility
有三个子类 -- Fade、Slide、Explode,如果有需要的话也可以自定义Visibility
子类。
Transitioning View & Transition Groups
这个小节将讨论系统如何找出过渡视图,以及通过 transition group 自定义过渡视图。
在转换开始前,系统通过遍历视图节点递归寻找过渡视图,这个递归查找是从对根视图调用ViewGroup.captureTransitioningViews
方法开始的:
/** @hide */
@Override
public void captureTransitioningViews(List<View> transitioningViews) {
if (getVisibility() != View.VISIBLE) {
return;
}
if (isTransitionGroup()) {
transitioningViews.add(this);
} else {
int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
child.captureTransitioningViews(transitioningViews);
}
}
}
上面的递归查找会遍历整个视图树节点,直到找到一个 VISIBLE 的 View 或者一个 transition group。Transition group 允许我们把整个 ViewGroup 当作一个整体在运行动画时,所有的子视图都会一起作为一个元素进行动画。否则的话,递归继续进行,所有子视图会单独对待。
public boolean isTransitionGroup() {
if ((mGroupFlags & FLAG_IS_TRANSITION_GROUP_SET) != 0) {
return ((mGroupFlags & FLAG_IS_TRANSITION_GROUP) != 0);
} else {
final ViewOutlineProvider outlineProvider = getOutlineProvider();
return getBackground() != null || getTransitionName() != null ||
(outlineProvider != null && outlineProvider != ViewOutlineProvider.BACKGROUND);
}
}
默认情况下,有非空背景、transitionName 不为空、outlineProvider 不为空且不等于 ViewOutlineProvider.BACKGROUND 的 ViewGroup 会当作 Transition Group。不过我们也可以通过setTransitionGroup(boolean)
来设置是否为 Transition Group。特别在运行 WebView 相关的过渡动画时,需要调用webview.setTransitionGroup(true)
来将 WebView 当作一个整体。
Transition Overlap
默认情况下,内容过渡动画的 进入/返回 转换会在 退出/重新进入 转换结束前一点点开始,产生一个小的重叠来让整体的效果更自然、更协调。这个行为其实可以通过 Window/Fragment 的setAllowEnterTransitionOverlap(boolean)
和setAllowReturnTransitionOverlap(boolean)
方法来设置,默认 overlap 是 true,进入转换会退出转换开始后尽可能快地开始,如果设置为 false,进入转换只能在退出转换结束后开始。这个对 Activity 和 Fragment 的共享元素过渡动画也是生效的。
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- specify transition overlap -->
<item name="android:windowAllowEnterTransitionOverlap">true</item>
<item name="android:windowAllowReturnTransitionOverlap">true</item>
</style>
Activity/Fragment 切换时的共享元素过渡动画
Activity/Fragment 切换时的内容过渡动画分为 calling Activity 的退出动画和 called Activity 的进入动画,两个动画实际上是没有什么关系的。而共享元素过渡动画,在确定开始状态和结束状态是分别在两个页面上的,可以实现共享元素从一个页面到另一个页面的动画。
Android 5.0(API 21)默认提供了下面四种恭喜元素转换:
-
changeBounds - 为目标视图的布局布局边界的变化添加动画
-
changeClipBounds - 为目标视图的裁剪边界的变化添加动画
-
changeTransform - 为目标视图的缩放与旋转变化添加动画
-
changeImageTransform - 为目标图像的大小与缩放变化添加动画
与内容过渡动画类似,共享元素过渡动画也分为 sharedElementExitTransition、sharedElementEnterTransition、sharedElementReturnTransition、sharedElementReenterTransition。
实现步骤
1.打开窗口内容转换开关
- 在风格定义中设置:
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- enable window content transitions -->
<item name="android:windowContentTransitions">true</item>
</style>
- 在代码中设置:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState)
getWindow().requestFeature(Window.FEATURE_CONTENT_TRANSITIONS)
}
2.指定共享元素转换
- 在风格定义中设置:
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- specify shared element transitions -->
<item name="android:windowSharedElementEnterTransition">@transition/changebounds</item>
<item name="android:windowSharedElementExitTransition">@transition/changebounds</item>
</style>
- 在代码中设置:
下面代码中是指定 Activity 共享元素转换,Fragment 的话只需要改为 Fragment 的同名方法即可。
Activity A
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_transition);
setupWindowAnimations();
}
private void setupWindowAnimations() {
// inflate from xml
ChangeImageTransform changeImageTransform = TransitionInflater.from(this).inflateTransition(R.transition.changebounds);
// or create directly
ChangeImageTransform changeImageTransform = new ChangeImageTransform();
getWindow().setSharedElementExitTransition(changeImageTransform);
}
Activity B
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_shared_element);
setupWindowAnimations();
}
private void setupWindowAnimations() {
// inflate from xml
ChangeImageTransform changeImageTransform = TransitionInflater.from(this).inflateTransition(R.transition.changebounds);
// or create directly
ChangeImageTransform changeImageTransform = new ChangeImageTransform();
getWindow().setSharedElementEnterTransition(changeImageTransform);
}
3.为共享元素指定统一的 transition name
在布局文件中使用android:transitionName
属性或者在代码中使用View.setTransitionName(String)
方法来为两个页面的共享元素指定同样 的transitionName。
Activity A.xml(实际上在跳转时也要再上view和transition name,所以在ActivityA中不设置也没问题)
<ImageView
android:id="@+id/small_blue_icon"
style="@style/MaterialAnimations.Icon.Small"
android:src="@drawable/circle"
android:transitionName="@string/blue_name" />
Activity B.xml
<ImageView
android:id="@+id/big_blue_icon"
style="@style/MaterialAnimations.Icon.Big"
android:src="@drawable/circle"
android:transitionName="@string/blue_name" />
4a.启动 Activity 时带上共享元素参数
使用ActivityOpstions.makeSceneTransitionAnimation()
方法来指定共享元素的 origin view 和 transition name。如果要结束第二个 Activity 时也显示共享元素过渡动画,请调用Activity.finishAfterTransitino()
而非Activity.finish()
。
blueIconImageView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent i = new Intent(MainActivity.this, SharedElementActivity.class);
View sharedView = blueIconImageView;
String transitionName = getString(R.string.blue_name);
ActivityOptions transitionActivityOptions = ActivityOptions.makeSceneTransitionAnimation(MainActivity.this, sharedView, transitionName);
// version may be lower than Android 5.0
ActivityOptionsCompat transitionActivityOptions = ActivityOptionsCompat.makeSceneTransitionAnimation(MainActivity.this, sharedView, transitionName);
startActivity(i, transitionActivityOptions.toBundle());
}
});
效果演示:(只是借鉴,并不要求一样)
4b.切换 Fragment 时带上共享元素参数
在 FragmentTransaction 提交前使用FragmentTransaction.addSharedElement()
方法加上共享元素信息。
getFragmentManager().beginTransaction() .replace(R.id.content, fragmentB) .addSharedElement(blueView, getString(R.string.blue_name)) .commit();
补充:
更新共享元素对应关系
很多情况下,我们需要共享元素的页面满足下面的条件:calling
是一个列表页面,called
是一个详情页,我们在列表中选择一项打开,然后会有一个元素共享过渡到详情页,而我们在详情页是可以左右滑动切换元素的,如下图所示:
这个系统自带的相册APP,可以看到我们进入大图预览时,点击的是图片4,然后我们左右滑动,切换图片之后返回列表页,这个时候过渡动画直接返回到了图片1,那这是怎么做到的呢?
其实这里无非在过渡动画返回时,需要告诉系统,我的绑定关系改变了,我们可以在calling
设置一个SharedElementCallback
,然后在回调的onMapSharedElements
更新一下对应关系,大概的逻辑代码如下:
setExitSharedElementCallback(new SharedElementCallback() {
@Override
public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) {
super.onMapSharedElements(names, sharedElements);
sharedElements.put("avator",getItemByPosition(curentPos));
}
});
我们在celled
中左右滑动修改了列表Index
之后,需要通过某种方式告知calling
,你可以通过startActivityForResult()
也可以通过EventBus
之类的第三方库,但是需要注意的是,onMapSharedElements
在exit
和reenter
时都会触发!
延迟共享元素
想想这种场景吧,called
中的共享元素在进入时,并没有到达最终的位置(比如这个数据需要从网络获取数据之后,才展示它真正的大小位置),此时如果过渡动画如果过早的执行,那么过渡动画获取的called
中共享view
的状态并不正确,所以我们有时候希望called
中的共享元素完全layout
完毕之后再执行,幸好API
提供了支持。
在called
的onCreate
中我们可以这样处理:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 延迟共享动画的执行
postponeEnterTransition();
}
然后在共享元素的最终布局确定后,你可以执行startPostponedEnterTransition
来启动共享元素动画,我们一般可以通过下面的工具方法来启动延迟动画:
private void scheduleStartPostponedTransition(final View sharedElement) {
sharedElement.getViewTreeObserver().addOnPreDrawListener(
new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
//启动动画
sharedElement.getViewTreeObserver().removeOnPreDrawListener(this);
startPostponedEnterTransition();
return true;
}
});
}
但是使用延迟共享动画有两点需要注意:
1. 调用postponeEnterTransition
必须要在适当的时候调用startPostponedEnterTransition
,否则Activity
的过渡就会卡到这里,不能执行下去
2.基于1,我们在这两个方法间实际上也不能间隔太久(前面说的等待网络返回只是举个例子,不要在实际上使用等待网络返回的共享元素),如果间隔太大,那页面的过渡会直到调用startPostponedEnterTransition
才能进行页面切换!
共享元素过渡动画的原理
和内容过渡动画类似的是,系统也会在创建共享元素动画前直接修改共享元素视图的属性。当 Activity A 启动 Activity B 时,下面的事件将会发生:
-
A 调用
startActivity(intent, bundle)
后,B 启动时,窗口的背景是透明的。 -
系统以 A 为标准重新设置 B 的每个共享元素视图的大小和位置,过一会 B 的进入转换会记录 B 中所有共享元素的开始状态,而对于内容过渡来说,其他的 transitioning view 的可见性都是 INVISIBLE。
-
系统再重新将 B 的每个共享元素视图的大小和位置设置为原来的样子,过一会 B 的进入转换会记录 B 中所有共享元素的结束状态。
-
B 的进入转换比较每个共享元素的开始状态和结束状态,创建 Animator 作为共享元素动画。
-
系统将隐藏 A 的所有共享元素视图,然后开始运行 B 的共享元素动画。在 B 的共享元素动画过程中,B 的窗口背景会逐渐变为不透明的。
对比内容过渡动画,内容过渡动画中系统会修改 transition views 的可见性,而共享元素过渡动画中系统会修改 shared element views 的位置、大小和显示。而且我们也可以看出实际上共享元素的 view 其实并没有在 Activity/ Fragment 之间共享,事实上,我们看到的进入或者返回的共享元素过渡动画都是直接在 B 的视图中运行的。
Shared Element Overlay
默认情况下,共享元素视图是绘制在整个视图结构之上的 -- 窗口的 ViewOverlay 层。ViewOverlay 是在 Android 4.3(API 18)引入的,方便在视图上面绘制内容。添加到视图的 ViewOverlay 层的 Drawables 或者 Views 都会在所有其他视图上面绘制,不会被遮盖。而共享元素默认会在 ViewOverlay 层绘制的原因是:共享元素是整个进入转换中的焦点,如果 transitioning view 忽然遮盖了共享元素的话,整体的效果会大打折扣。
虽然共享元素视图默认是绘制在 ViewOverlay 层的,但是也可以在必要情况下使用 Window.setSharedElementsUseOverlay(false)
来禁用。除非真的有需要,最好修改这个,否则共享元素过渡动画可以会出现预料之外的效果。
更新共享元素对应关系
想象这样这个场景:GalleryActivity 里面有一个图片列表 RecyclerView,点击进入 PreviewActivity,PreviewActivity 里面是一个 ViewPager,每项都显示图片,可以左右滑动。那么如何实现在左右滑动后返回 GalleryActivity 也能有相应的共享元素动画呢?效果如下图:
shared_element_anim2.gif
下面一步一步来实现:
1. PreviewActivity 的 ViewPager 滑动时更新共享元素
通过Activity.setEnterSharedElementCallback
给 PreviewActivity 设置一个自定义的 SharedElementCallback,实现onMapSharedElements
方法,当 ViewPager 滑动项时更新共享元素。
2. 返回 GalleryActivity 更新共享元素
通过Activity.setExitSharedElementCallback
给 GalleryActivity 设置一个自定义的 SharedElementCallback,再实现onActivityReenter
回调,在回调中更新共享元素(需要 PreviewActivity 返回时 setResult())。
3. 返回 GalleryActivity 时 RecyclerView 滑动到相应图片位置
在onActivityReenter
回调让 RecyclerView 滑动到相应图片位置。
这个小节的更多实现细节,请看 “Dynamic” Shared Element Transition: How to hold a common gallery flow。
同一个页面中的场景过渡动画
前面介绍的内容过渡动画和共享元素过渡动画都是在不同页面间的过渡动画,其实过渡动画也可以在同一个页面中,只要开始场景和结束场景在同一个页面,那么创建的过渡动画就会在一个页面中运行。
在一个页面中确定开始场景和结束场景有两种方式:手动创建 Scene 和系统自动监听布局变更。
1.手动创建 Scene
我们可以通过 Scene 类来手动创建场景,在创建 Scene 实例时需要指定一个sceneRoot
,在调用TransitionManager.go()
变换场景时,转换先记录sceneRoot
子节点下所有 transitioning view 的状态作为开始场景,然后移除sceneRoot
下的所有视图,将新场景的布局添加到sceneRoot
,再记录sceneRoot
子节点下的所有 transitioning view 的状态作为结束场景,最后根据两个场景创建并运行过渡动画。
scene1 = Scene.getSceneForLayout(sceneRoot, R.layout.activity_animations_scene1, this);
scene2 = Scene.getSceneForLayout(sceneRoot, R.layout.activity_animations_scene2, this);
scene3 = Scene.getSceneForLayout(sceneRoot, R.layout.activity_animations_scene3, this);
scene4 = Scene.getSceneForLayout(sceneRoot, R.layout.activity_animations_scene4, this);
(...)
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.button1:
TransitionManager.go(scene1, new ChangeBounds());
break;
case R.id.button2:
TransitionManager.go(scene2, TransitionInflater.from(this).inflateTransition(R.transition.slide_and_changebounds));
break;
case R.id.button3:
TransitionManager.go(scene3, TransitionInflater.from(this).inflateTransition(R.transition.slide_and_changebounds_sequential));
break;
case R.id.button4:
TransitionManager.go(scene4, TransitionInflater.from(this).inflateTransition(R.transition.slide_and_changebounds_sequential_with_interpolators));
break;
}
}
演示效果
2.系统自动监听布局变更
原理和上面一种其实是一样的。
2.1、开始延迟转换
TransitionManager.beginDelayedTransition(sceneRoot);
TransitionManager.beginDelayedTransition(sceneRoot)
时会马上记录sceneRoot
子节点下所有 transitioning view 的状态作为开始场景,然后在下一帧中再次记录sceneRoot
子节点下所有 transitioning view 的状态作为结束场景,所以在下一帧之前我们修改布局属性可以被感知到,因此根据这些差异转换可以创建并运行过渡动画。
2.2、修改视图的布局属性
ViewGroup.LayoutParams params = greenIconView.getLayoutParams();
params.width = 200;
greenIconView.setLayoutParams(params);
共享元素过渡动画 + 揭露效果(circular reveal)
在 Android 5.0(API 21)中也引入揭露效果(circular reveal),当你显示或隐藏一组 UI 元素时,揭露动画可为用户提供视觉连续性。使用ViewAnimationUtils.createCircularReveal()
方法可以为裁剪区域添加动画以揭露或隐藏视图。更详细的介,绍请看官网文档 -- 使用揭露效果。
揭露动画可以和共享元素过渡动画结合起来创造更有意义的动画,来引导用户发生了什么。看下面这个效果:
实现步骤:
-
橙色的圆圈是 MainActivity 跳转到 RevealActivity 的共享元素。
-
RevealActivity 的共享元素转换有一个 listener 监听共享元素转换的结束。监听到转换结束后,做了两件事情:(1)在 Toolbar 上运行了揭露动画;(2)对 RevealActivity 底部的四个按钮运行了放大动画。
1.监听共享元素转换结束
Transition transition = TransitionInflater.from(this).inflateTransition(R.transition.changebounds_with_arcmotion);
getWindow().setSharedElementEnterTransition(transition);
transition.addListener(new Transition.TransitionListener() {
@Override
public void onTransitionEnd(Transition transition) {
animateRevealShow(toolbar);
animateButtonsIn();
}
(...)
});
2.Toolbar 的揭露效果
private void animateRevealShow(View viewRoot) {
int cx = (viewRoot.getLeft() + viewRoot.getRight()) / 2;
int cy = (viewRoot.getTop() + viewRoot.getBottom()) / 2;
int finalRadius = Math.max(viewRoot.getWidth(), viewRoot.getHeight());
Animator anim = ViewAnimationUtils.createCircularReveal(viewRoot, cx, cy, 0, finalRadius);
viewRoot.setVisibility(View.VISIBLE);
anim.setDuration(1000);
anim.setInterpolator(new AccelerateInterpolator());
anim.start();
}
3.底部四个按钮的放大动画
private void animateButtonsIn() {
for (int i = 0; i < bgViewGroup.getChildCount(); i++) {
View child = bgViewGroup.getChildAt(i);
child.animate()
.setStartDelay(100 + i * DELAY)
.setInterpolator(interpolator)
.alpha(1)
.scaleX(1)
.scaleY(1);
}
}
参考链接:
https://www.jianshu.com/p/69d48f313dc4
https://www.jianshu.com/p/50f62d9e60e1