Giter Site home page Giter Site logo

blog's People

Contributors

blue68 avatar

Stargazers

 avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

Forkers

ak23173969

blog's Issues

Android客户端项目组件化实践

前言

随着公司业务发展,Android 客户端代码量逐渐增多,使用一个工程管理所有代码的模式存在代码臃肿、编译时间过长等问题,需要对 Android 客户端项目进行组件化。

基础方案

基于团队规模较小且产品迭代开发较快的现状,为了快速实现模块化并且上线,对于模块化制定的标准比较简单:剥离各业务和基础组件代码,且保持彼此独立。

代码剥离过程为划分业务模块、基础业务模块、基础组件三部分;业务模块划分主要根据公司的产品线进行,划分粒度较大,包括普通投资产品、基金两大业务模块;基础业务模块涵盖各业务模块共用的业务逻辑,包括分享模块、H5交互模块、用户模块等;基础组件划分主要为了提取与业务无关的基础组件,包括网络请求、自定义控件、基础工具类等。最后划分出的模块架构如下图:

其中业务模块除了产品线模块,还包括主页面和登录/注册;主页面模块主要包括启动页、主场景的几大 Tab 页面;登录/注册模块包括用户登录、注册、修改密码等系列页面,由于内容较独立且使用场景较广泛,均被提取成独立模块。

从依赖角度分析,业务模块属于基础业务模块的上层项目,基础业务模块属于基础组件的上层项目,上层项目可依赖下层项目,反过来则不允许。几大业务模块之间保持独立,不允许被其他业务模块直接引用;基础业务模块和基础组件均可看作普通的 library,被上层项目直接引用。

每一个模块/组件均被提取为独立项目,代码也提交至不同的 git repository 进行管理。

技术框架

基于以上方案将项目代码剥离成各模块之后,整体实现机制为:

  • 除主模块仍然为 application module,其他模块/组件均为 library module,生成 aar 并 publish 至 maven 仓库;

  • 业务模块通过生成路由配置文件,使用路由机制互相调用各业务模块的功能;

  • 项目引用基础业务模块和基础组件时,直接在依赖中加入对应的aar配置即可。

业务模块之间采用路由机制的目的为保持业务模块之间的绝对独立和解耦,路由机制大致描述为:

  • 业务模块生成对应的路由配置文件,并保存至各项目的 assets 目录中;

  • 业务模块采用路由配置文件中的配置,通过反射调起其他业务模块中的功能。

路由配置文件生成

该步骤主要使用APT工具,实现路由配置文件的自动生成,文件格式采用 properties 格式。

首先,定义各业务模块路由文件;新建名为 RouterFilte r的 Annotation,每个业务模块新建一个 Router 类,使用 RouterFilter 注解并赋值;Router 类中的方法为该业务模块暴露给其他模块的方法,实现页面跳转等功能。

1 @RouterFilter("main")
2 public class MainRouter extends BaseRouter {
3    @RouterAction("some")
4    public void gotoSomeActivity(Context context, Bundle bundle) {
5    }
7 }

其次,自定义 APT 采用的 processor;建立 Processor 类,对项目中带有 RouterFilter注解的类进行处理,将 RouterFilter 注解的值和 Router 类的类名一一对应地保存在properties 文件中,生成的 properties 文件命名一般为:router_package_name.properties,properties 文件内容格式如下:

1 main=package.name.MainRouter

最后,由于 APT 自动生成的 properties 文件在 build/generated/source/apt/... 目录中,需要在生成 apk 或者 aar 之前将该目录作为 assets 路径的一部分。如果模块是application 项目,则在 gradle 配置文件中添加如下配置:

1 android.applicationVariants.all { variant ->
2    //这一步保证dofirst永远执行
3    variant.preBuild.outputs.upToDateWhen { false }
4    variant.preBuild.doFirst {
5        String productFlavor = variant.flavorName
6        android.sourceSets.debug.assets.srcDirs = ["assets", "build/generated/source/apt/${productFlavor}/debug/router"]
7        android.sourceSets.release.assets.srcDirs = ["assets", "build/generated/source/apt/${productFlavor}/release/router"]
8    }
9 }

如果模块是 library 项目,可添加如下较简单的配置:

1 sourceSets {
2    main {
3        android.sourceSets.debug.assets.srcDirs = ["assets", "build/generated/source/apt/debug/router"]
4        android.sourceSets.release.assets.srcDirs = ["assets", "build/generated/source/apt/release/router"]
5    }
6 }

调起其他业务模块功能

根据以上路由文件配置生成的步骤,在最后打包的 apk 中,各业务模块生成的路由properties 配置文件便都汇总在 apk 的 assets 目录中,所有业务模块均可读取到其他业务模块的配置文件。调起其他业务模块功能的机制较简单,只需提供 RouterFilter 注解的 value 和 RouterAction 注解的 value ;通过 RouterFilter 注解的 value 和 properties 配置文件可获取 Router 类的全类名,通过 RouterAction 注解的 value 可获取Router 类中的 Method 信息,采用反射即可调用该方法,从而调起指定页面或者实现其他功能。

辅助工具

模块化工作中除了实现上述技术框架,还需要实现很多便于开发的辅助性工具。

自定义 gradle 插件

模块化导致项目增多,每个项目都存在基本的版本配置信息,比如 minSdkVersion,targetSdkVersion 等;为了统一配置和方便修改,自定义 config 插件保存配置信息,在每个项目中使用插件中的变量而非具体的值。 config 插件中基本配置信息为:

1 project.customConfig.compileSdkVersion = 26
2 project.customConfig.minSdkVersion = 17
3 project.customConfig.targetSdkVersion = 26
4 project.customConfig.supportLibVersion = '26.1.0'

项目中使用配置信息如下:

1 apply plugin: 'xxx.xxx.xxx.config'
2 ...
3 android {
4    compileSdkVersion project.customConfig.compileSdkVersion
6    defaultConfig {
7        minSdkVersion project.customConfig.minSdkVersion
8        targetSdkVersion project.customConfig.targetSdkVersion
9        ...
10   }
11    ...
12 }

另外,每个 library 项目都需要 publish 到远程 maven 的操作,每个项目都进行 maven publish 的配置十分繁琐;同样地,自定义 publish 插件封装所有 publish 的配置参数,在各项目中只需 apply plugin: 'xxx.xxx.publish' 使用插件即可。

多项目管理的脚本

由于模块化项目属于不同git repository,需要批量操作多git项目;目前git多项目操作方式主要包括submodule,repo等。由于我们的模块化多项目之间彼此独立,而且同一个项目需要切换多分支,不同项目所处分支也不一定相同,因此目前多项目管理采用自定义脚本的方式。已实现所有git基本批量操作的脚本,并效仿repo封装成简单命令模式,便于使用。

在模块化实现之后的开发过程中,还有许多问题均需要批量脚本,比如某个库修改了版本号,为了防止存在兼容性问题,其他所有项目中依赖此库的版本号最好都提升为新版本号,该工作便依赖于脚本完成。

jenkins自动化

在团队开发过程中,每个成员都可能对同一个 library 进行修改,为了保证远程 maven库中 library aar 的版本是最新的,通过 jenkins 自动化实现了每个 library 模块项目push 到 gitlab 之后自动触发 maven publish 任务。在 jenkins 配置中,所有模块化项目共享同一个 jenkins job,通过 webhook 的参数即可动态获取当前 push 项目的 git ssh url 以及 branch 等。

实践总结

目前模块化稳定使用了一段时间,可大致总结出如下优点:

  • 由于不同模块/组件存在上下层级,可一定程度对代码进行解耦;

  • 降低了主项目的编译时间;

  • 可提取越来越多的基础组件,供公司的其他 Android 项目使用;

  • 业务模块剥离,便于应对产品线的下线;

  • 模块/组件开发过程可比较独立,在各自工程中进行调试即可,不需要在主工程调试。

当然由于项目数量增多,开发过程相对于单项目操作存在以下缺点:

  • 模块/组件开发完成后,需要执行 publish 到本地或者 publish 到远程的操作才能使用最新的模块/组件逻辑;当然也可以在 settings.gradle 中配置其他项目作为 module,便无需进行 publish 操作;

  • app 打包时需要确保使用的模块/组件均是最新的,步骤没有单项目模式时简单。

后续工作中除了优化以上开发过程中遇到的缺点以及当前技术框架,还需对模块进行不断梳理,划分较细的模块可以合并,而随着业务发展逐渐庞大的模块则应该细分,提高整体项目的灵活度。

作者简介

布恩,铜板街 Android 开发工程师,2016年4月加入团队,目前主要负责 APP 端 Android 日常开发。

更多精彩内容,请扫码关注 “铜板街科技” 微信公众号。

海报分享功能实现详解

前言

由于业务需求,需要做一个卡片分享功能,前期做了一些预研,实现类似效果可以采用如下两种方式:

  • 采用ViewPager实现
  • 采用RecyclerView实现

由于RecyclerView自带复用设计,方便后期拓展,所以就采用RecyclerView这个方案,主要实现的细节效果和功能如下:

  1. 分页,自动居中
  2. 卡片样式及效果,阴影等
  3. 背景色渐变
  4. 切换卡片,卡片的缩放效果
  5. 指示器
  6. 卡片分享

效果图:

RecyclerView这个方向的资料还是比较好查找,不过细节和想实现的效果还是有些许出入。针对这些问题,逐步探索,经过多次改良后,得到了较为满意的结果。

本文滑动是横向滑动,如果读者想要纵向的,可以使用RecyclerView的LinearLayoutManager设置方向,其他代码大体相同。

下面我就根据效果逐一给读者提供相关代码实现,并针对实现细节、难点,附上开发思路供大家参考。


难点:

  • 卡片比例适配
  • 滑动时卡片缩放动画。
  • 滑动时距离计算、速度控制、页码计算。
  • 内存控制

技术实现

分页、自动居中

public class CardPagerSnapHelper extends PagerSnapHelper {
    public boolean mNoNeedToScroll = false;

    @Override
    public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
        if (mNoNeedToScroll) {
            return new int[]{0, 0};
        } else {
            return super.calculateDistanceToFinalSnap(layoutManager, targetView);
        }
    }
}

//使用.
mPageSnapHelp.attachToRecyclerView(mRecyclerView);

这里继承PagerSnapHelper是因为想要的效果是一页的滑动。如果想要的是可以滑动多页,可以使用LinearSnapHelper,设置对应的朝向即可,另外继承这个也可以设置阻尼大小,还可控制滑动速度。

卡片效果

我这里主要是根据要求做了如下方面的修改,读者可以根据需求,增加动画,列表,点击反馈等。

1)阴影、圆角等
  • cardElevation 设置z轴阴影
  • cardCornerRadius 设置圆角大小
  • cardMaxElevation 设置z轴最大高度值
  • cardPreventCornerOverlap 是否添加内边距(避免内容与边缘重叠)
  • cardUseCompatPadding 设置内边距,V21+的版本和之前的版本仍旧具有一样的计算方式

这是我用到的设置,读者可以根据实际效果对比界面设计做调整:

<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    app:cardBackgroundColor="@color/white"
    app:cardElevation="6dp"
    app:cardMaxElevation="12dp"
    app:cardPreventCornerOverlap="true"
    app:cardUseCompatPadding="false">
2)卡片比例动态调整
卡片

要保持在不同屏幕下卡片比例保持不变,就需要根据屏幕的分辨率动态的设置卡片的宽高。

---- CardAdapter.java ----
  @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_poster, parent, false);
        mCardAdapterHelper.onCreateViewHolder(parent, itemView, 0.72f, (float) (17.0 / 25.0));
        return new ViewHolder(itemView);
    }
    
    
---- CardAdapterHelper.java ----
    /**
     * @param parent
     * @param itemView
     * @param cardPercentWidth 卡片占据屏幕宽度的百分比.
     * @param aspectRatio      宽高比.
     */
    public void onCreateViewHolder(ViewGroup parent, View itemView, float cardPercentWidth, float aspectRatio) {
        RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) itemView.getLayoutParams();
        lp.width = (int) (DisplayUtil.getScreenWidth(parent.getContext()) * cardPercentWidth);
        lp.height = (int) (lp.width / aspectRatio);
        itemView.setLayoutParams(lp);
    }
    
二维码

由于整个卡片都是按比例划分的,为了展示尽可能大的二维码区域,二维码卡片也需要动态设置,按照底部栏的最大高度的80%作为宽高(二维码是正方形)

//根据实际底部栏大小设置宽高.
    private void setQRCodeImageView(final ImageView imageView, final ViewGroup root) {
        if (imageView == null || root == null) {
            return;
        }
        imageView.post(new Runnable() {
            @Override
            public void run() {
                int height = root.getMeasuredHeight();
                int targetHeight = (int) (height * 0.8);
                if (height == 0) {
                    return;
                }

                ViewGroup.LayoutParams params = imageView.getLayoutParams();
                params.width = targetHeight;
                params.height = targetHeight;
                imageView.setLayoutParams(params);
            }
        });
    }
    

背景色渐变

这部分主要方法网上都有,就不重复造轮子了。这里是连贯步骤,就是根据当前卡片的底图做一张模糊图,列举出来只是方便读者快速实现。

----QRCodePosterActivity.java----
     private void initBlurBackground() {
            mBlurView = (ImageView) findViewById(R.id.blurView);
            mContentRv.addOnScrollListener(new RecyclerView.OnScrollListener() {
                @Override
                public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
                    super.onScrollStateChanged(recyclerView, newState);
                    if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                        notifyBackgroundChange();
                        //指示器
                    }
                }
            });
            setDefaultBackground();
        }
    
        private void notifyBackgroundChange() {
            if (mPosterModule == null || mPosterModule.getBannerInfo().size() == 0) {
                setDefaultBackground();
                return;
        }

        /**
         * 延时设置说明,由于滑动距离会出现正好一页的距离或偏离.
         * 所以滑动停止事件触发会出现一次或两次(偏离的时候,偏差.
         * 量将自动修正后再次停止),所以延时并取消上一次背景切换可以消除画面闪烁。.
         */
        mBlurView.removeCallbacks(mBlurRunnable);
        mBlurRunnable = new Runnable() {
            @Override
            public void run() {
                Bitmap bitmap = mCardScaleHelper.getCurrentBitmap();
                ViewSwitchUtils.startSwitchBackgroundAnim(mBlurView, BlurBitmapUtils.getBlurBitmap(mBlurView.getContext(), bitmap, 15));
            }
        };
        mBlurView.postDelayed(mBlurRunnable, 500);
    }
    
     private void setDefaultBackground() {
        if (mBlurView == null) {
            return;
        }
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.icon_card_default);
        mBlurView.setImageBitmap(BlurBitmapUtils.getBlurBitmap(mBlurView.getContext(), bitmap, 15));
    }
    
---- CardScaleHelper.java ----
     public Bitmap getCurrentBitmap() {
        View view = mRecyclerView.getLayoutManager().findViewByPosition(getCurrentItemPos());
        if (view == null) {
            return null;
        }
        ImageView mBgIv = (ImageView) view.findViewById(R.id.iv_bg);
        final Bitmap bitmap = ((BitmapDrawable) mBgIv.getDrawable()).getBitmap();
        return bitmap;
    }
    
---- ViewSwitchUtils.java ----
     public static void startSwitchBackgroundAnim(ImageView view, Bitmap bitmap) {
        if (view == null || bitmap == null) {
            return;
        }
        Drawable oldDrawable = view.getDrawable();
        Drawable oldBitmapDrawable;
        TransitionDrawable oldTransitionDrawable = null;
        if (oldDrawable instanceof TransitionDrawable) {
            oldTransitionDrawable = (TransitionDrawable) oldDrawable;
            oldBitmapDrawable = oldTransitionDrawable.findDrawableByLayerId(oldTransitionDrawable.getId(1));
        } else if (oldDrawable instanceof BitmapDrawable) {
            oldBitmapDrawable = oldDrawable;
        } else {
            oldBitmapDrawable = new ColorDrawable(0xffc2c2c2);
        }

        if (oldTransitionDrawable == null) {
            oldTransitionDrawable = new TransitionDrawable(new Drawable[]{oldBitmapDrawable, new BitmapDrawable(view.getResources(), bitmap)});
            oldTransitionDrawable.setId(0, 0);
            oldTransitionDrawable.setId(1, 1);
            oldTransitionDrawable.setCrossFadeEnabled(true);
            view.setImageDrawable(oldTransitionDrawable);
        } else {
            oldTransitionDrawable.setDrawableByLayerId(oldTransitionDrawable.getId(0), oldBitmapDrawable);
            oldTransitionDrawable.setDrawableByLayerId(oldTransitionDrawable.getId(1), new BitmapDrawable(view.getResources(), bitmap));
        }
        oldTransitionDrawable.startTransition(1000);
    }
    
---- BlurBitmapUtils.java ----    
     /**
     * 得到模糊后的bitmap
     *
     * @param context
     * @param bitmap
     * @param radius
     * @return
     */
    public static Bitmap getBlurBitmap(Context context, Bitmap bitmap, int radius) {
        if (bitmap == null || context == null) {
            return null;
        }
        // 将缩小后的图片做为预渲染的图片。
        Bitmap inputBitmap = Bitmap.createScaledBitmap(bitmap, SCALED_WIDTH, SCALED_HEIGHT, false);
        // 创建一张渲染后的输出图片。
        Bitmap outputBitmap = Bitmap.createBitmap(inputBitmap);
        try {
            // 创建RenderScript内核对象
            RenderScript rs = RenderScript.create(context);
            // 创建一个模糊效果的RenderScript的工具对象
            ScriptIntrinsicBlur blurScript = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));

            // 由于RenderScript并没有使用VM来分配内存,所以需要使用Allocation类来创建和分配内存空间。
            // 创建Allocation对象的时候其实内存是空的,需要使用copyTo()将数据填充进去。
            Allocation tmpIn = Allocation.createFromBitmap(rs, inputBitmap);
            Allocation tmpOut = Allocation.createFromBitmap(rs, outputBitmap);

            // 设置渲染的模糊程度, 25f是最大模糊度
            blurScript.setRadius(radius);
            // 设置blurScript对象的输入内存
            blurScript.setInput(tmpIn);
            // 将输出数据保存到输出内存中
            blurScript.forEach(tmpOut);

            // 将数据填充到Allocation中
            tmpOut.copyTo(outputBitmap);

        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            inputBitmap.recycle();
        }

        return outputBitmap;
    }
    

切换卡片,卡片的缩放效果

我们要实现如上效果,基本的滑动展示,RecyclerView都有实现,需要解决是滑动过程中卡片的缩放问题、卡片透明度变化、滑动距离的判定、页码的计算、多张卡片的内存问题等。

为了复用,主要的代码都是通过帮助类实现。用法如下

---- QRCodePosterActivity.java ----
        // mRecyclerView绑定scale效果.
        mCardScaleHelper = new CardScaleHelper();
        mCardScaleHelper.setCurrentItemPos(0);//初始化指定页面.
        mCardScaleHelper.setScale(0.8f);//两侧缩放比例.
        mCardScaleHelper.setCardPercentWidth(0.72f);//卡片占屏幕宽度比例.
        mCardScaleHelper.attachToRecyclerView(mContentRv);

下面我们来看看具体实现

初始化

我们从绑定开始初始化

---- CardScaleHelper.java ---- 
    private int mCardWidth; // 卡片宽度.
    private int mOnePageWidth; // 滑动一页的距离.
    private int mCardGalleryWidth;
    private int mCurrentItemPos;
    private int mCurrentItemOffset;
    private float mScale = 0.9f; // 两边视图scale.
    private float mCardPercentWidth = 0.60f;//卡片占据屏幕宽度的百分比,需要与CardAdapterHelper中的一致.
    private CardPagerSnapHelper mPageSnapHelp = new CardPagerSnapHelper();
    
     public void attachToRecyclerView(final RecyclerView mRecyclerView) {
        this.mRecyclerView = mRecyclerView;
        mContext = mRecyclerView.getContext();
        mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
            @Override
            public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
                super.onScrollStateChanged(recyclerView, newState);
                if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                    mPageSnapHelp.mNoNeedToScroll = mCurrentItemOffset == 0 || mCurrentItemOffset == getDestItemOffset(mRecyclerView.getAdapter().getItemCount() - 1);
                } else {
                    mPageSnapHelp.mNoNeedToScroll = false;
                }
            }

            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                super.onScrolled(recyclerView, dx, dy);
                if (dx == 0) {
                    initWidth();
                    return;
                }
                // dx>0则表示右滑, dx<0表示左滑, dy<0表示上滑, dy>0表示下滑
                mCurrentItemOffset += dx;
                computeCurrentItemPos();
                onScrolledChangedCallback();
            }


        });
        mPageSnapHelp.attachToRecyclerView(mRecyclerView);
    }
    
    /** 初始化卡片宽度**/
    private void initWidth() {
        mCardGalleryWidth = mRecyclerView.getWidth();
        mCardWidth = (int) (mCardGalleryWidth * mCardPercentWidth);
        mOnePageWidth = mCardWidth;
        mRecyclerView.smoothScrollToPosition(mCurrentItemPos);
        onScrolledChangedCallback();
    }
计算当前卡片索引
---- CardScaleHelper.java ---- 
    private void computeCurrentItemPos() {
        if (mOnePageWidth <= 0) return;
        boolean pageChanged = false;
        // 滑动超过一页说明已翻页.
        if (Math.abs(mCurrentItemOffset - mCurrentItemPos * mOnePageWidth) >= (mOnePageWidth)) {
            pageChanged = true;
        }
        if (pageChanged) {
            int tempPos = mCurrentItemPos;
            mCurrentItemPos = mCurrentItemOffset / (mOnePageWidth);
        }
    }
卡片滑动切换计算

下面的这个方法是比较核心,包含了所有卡片的缩放比计算,透明度计算,为了达到平滑过度,这里用到了三角函数,也包含了一些适配问题的解决。由于水平有限,如下方法可能还是存在优化的空间或细节修正,仅供参考,感兴趣的朋友可以自行研究。

---- CardScaleHelper.java ---- 
    /**
     * RecyclerView位移事件监听, view大小随位移事件变化.
     */
    public void onScrolledChangedCallback() {
        for (int i = 0; i < mRecyclerView.getAdapter().getItemCount(); i++) {
            LinearLayoutManager layoutManager = (LinearLayoutManager) mRecyclerView.getLayoutManager();
            final View view = layoutManager.getChildAt(i);
            if (view == null) {
                continue;
            }
            //计算当前这个view相对于中间View的偏移页码量.
            //(view相对的X的起始位置-当前scrollview滚动的位置)/每页大小.
            // = 0 为居中页.
            // = 1 为下一页 2 为下下页.
            // = -1 为上一页 -2 为上上页.
            double offsetPage = ((int) view.getTag() * (double) mOnePageWidth - mCurrentItemOffset) / (double) mOnePageWidth;
            double scale = (float) Math.cos(offsetPage);
            if (Math.abs(scale) < mScale)
                scale = mScale;
            view.setScaleX((float) scale);
            view.setScaleY((float) scale);

            BigDecimal bd = new BigDecimal((scale * 0.8)).setScale(1, RoundingMode.UP);
            if (scale > 0.99f) {
                view.setAlpha(1);
            } else {
                view.setAlpha((bd.floatValue()));
                //解决透明显示异常的问题,强制重新绘制.
                view.invalidate();
            }
        }

    }

Tag值,及滑动时卡片间隙计算。

---- CardAdapter.java ----   
    @Override
    public void onBindViewHolder(final ViewHolder holder, final int position) {
        holder.itemView.setTag(position);
        mCardAdapterHelper.onBindViewHolder(holder.itemView, position, getItemCount());
        setQRCodeImageView(holder.mQRCodeIv, holder.mBottomLl);
        //业务代码.
    }
    
---- CardScaleHelper.java ----  
    private int mPagePadding = 15;
    
    public void onBindViewHolder(View itemView, final int position, int itemCount) {
        int mOneSideWidth = (int) ((DisplayUtil.getScreenWidth(itemView.getContext()) - itemView.getLayoutParams().width) / 2.0);
        int leftMarin = position == 0 ? mOneSideWidth : 0;
        int rightMarin = position == itemCount - 1 ? mOneSideWidth : 0;
        setViewMargin(itemView, leftMarin, 0, rightMarin, 10);
    }
    
    private void setViewMargin(View view, int left, int top, int right, int bottom) {
        ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
        if (lp.leftMargin != left || lp.topMargin != top || lp.rightMargin != right || lp.bottomMargin != bottom) {
            lp.setMargins(left, top, right, bottom);
            view.setLayoutParams(lp);
        }
    }

多张卡片内存控制

  • 方案一:利用第三方框架去显示,如glide,pessones等,最简单,如果对内存没有极细的要求的话推荐使用这个方案。
  • 方案二:可以考虑回收不显示卡片的那部分内存,然后利用LruCache进行缓存管理。

指示器

由于指示器比较简单,这里简述一种实现思路,
可以直接用LinearLayout动态添加包含指示器图案的view,每次滑动结束后更新指示器位置。

卡片分享

在铜板街的应用上,卡片最终是要分享出去,所以我们继续分析下,如何在分享前做好准备,由于分享有需要文件,也有需要Bitmap的.

 @Override
    public void onClick(View v) {
        if (v.getId() == R.id.iv_back) {
            finish();
            return;
        }
        createBitmap(v);
    }

    public void createBitmap(final View clickView) {
        showLoadingCustomDialog();
        ThreadPoolManager.getInstance().addTask(new Runnable() {
            @Override
            public void run() {
                View view = linearLayoutManager.findViewByPosition(mCardScaleHelper.getCurrentItemPos());
                View mContentRl = view.findViewById(R.id.rl_content);
                mContentRl.setDrawingCacheEnabled(true);
                mContentRl.buildDrawingCache();  //启用DrawingCache并创建位图.
                final Bitmap bitmap = Bitmap.createBitmap(mContentRl.getDrawingCache()); //创建一个DrawingCache的拷贝,因为DrawingCache得到的位图在禁用后会被回收.
                mContentRl.setDrawingCacheEnabled(false);  //禁用DrawingCahce否则会影响性能.
                mContentRl.destroyDrawingCache();
                file = FileUtil.saveImage(Constant.IMAGE_CACHE_PATH, "share" + System.currentTimeMillis(), bitmap);
                dismissLoadingCustomDialog();
                clickView.post(new Runnable() {
                    @Override
                    public void run() {
                        //分享.
                    }
                });
            }
        });
    }

注意几个细节,一个是bitmap的回收,第二个是文件的处理,由于QQ分享的问题,我们并不能分享完成后立马删除原文件,所以我的做法是关闭当前页面时,会清理(文件有最后修改时间方法:lastModified)过期的文件缓存。

总结

本文总结了在开发画廊型卡片分享的一些心得和体会,对于一个复杂的程序来说,算法往往是最关键的,整个功能的开发可以说一半的时间都是在调试滑动时卡片的缩放效果。而工作中多数应用开发用到的算法往往比较简单,所以如果想提升,就必须自己去专研。

作者简介

苏哲,铜板街Android开发工程师,2017年12月加入团队,目前主要负责APP端 Android 日常开发。

更多精彩内容,请扫码关注 “铜板街科技” 微信公众号。

React(v16.8) Hooks 简析

动机

  • 在组件之间复用状态逻辑很难, providers,consumers,高阶组件,render props等可以将横切关注点 (如校验,日志,异常等) 与核心业务逻辑分离开,但是使用过程中也会带来扩展性限制,ref传值问题,“嵌套地狱”等问题;Hook 提供一种简单直接的代码复用方式,可以使开发者在无需修改组件结构的情况下复用状态逻辑。

  • 复杂组件生命周期常常包含一些不相关的逻辑,相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起;很多开发者将 React 与状态管理库结合使用,这往往会引入了很多抽象概念,开发过程中还需要在不同的文件之间来回切换;Hook 提供一种更合理的代码组织方式,可以将组件中相互关联的代码聚集在一起,而不是被生命周期方法强制拆开,使其更加可预测。

  • class 组件存在着一些问题(如:class 不利于代码压缩,并且会使热重载出现不稳定的情况);Hook支持函数组件,使开发者在非class 的情况下可以使用更多的 React 特性。

使用

Hooks 只能在函数组件 (FunctionComponent) 中使用,赋予无实例无生命周期的函数组件以 class 组件的表达力并且更合理地拆分/组织代码,解决复用问题。

实现原理

Fiber 提供了 hooks 实现的基础:hooks 是基于 Fiber 对象上能存储 memoizedState,它以双向链表的形式存储在 Fiber 的 memoizedState 字段中。

什么是 Fiber?

Fiber 把应用树区分成每一个节点的更新,每一个 ReactElement 对应一个 Fiber 对象,Fiber 呈链表结构,串联整个应用树结构 (child, siblings, return),如图:

Fiber Tree

Fiber 会记录节点的各种状态 (state, props)(包括functional Component),并且在 update 的时候,会从原来的 Fiber(current)clone 出一个新的 Fiber(alternate)。两个 Fiber diff 出的变化(side effect)记录在 alternate上,在更新结束后 alternate 会取代之前的 current 的成为新的 current 节点。

Fiber 对 react 渲染机制的改变主要的影响:

  • 异步更新:因为 Fiber 把应用树区来分成每一个节点的更新,它们的更新互相独立,不会有相互的影响,所以可以异步打断现在的更新,然后去等待一个别的任务执行完成之后回过头来继续进行更新。

  • 提供了 hooks 实现的基础:hooks 是基于 Fiber 对象上能存储 memoizedState, 基于
    memoizedState 上可以存储这些东西,一步一步向下构建了 hooks API 的体系。

主要 Hooks

  • 常用的:useState, useEffect, useContext, useReducer;

  • 此外不常用的:useLayoutEffect, useCallback, useMemo, useRef, useImperativeHandle。

以 useState 为例了解 hook 的渲染更新过程
先了解 Hook 的数据结构

export type Hook = {
  memoizedState: any, //上一次渲染的时候的state

  baseState: any, // 当前正在处理的state
  baseUpdate: Update<any, any> | null, // 当前的更新
  queue: UpdateQueue<any, any> | null, // 产生的update放在这个队列里

  next: Hook | null, // 下一个
};

运行下面的组件代码

export default function App() {
  const [name, setName] = useState('dora')

  const nameChange = e => {
    setName(e.target.value)
  }

  return (
    <React.Fragment>
      <input type='text' value={name} onChange={nameChange} />
      <p>{name}</p>
    </React.Fragment>
  )
}

初始化 state 时调用 mountState,初始化 initialState,并且记录在 workInProgressHook.memoizedState 和 workInProgressHook.baseState上,然后创建 queue 对象, queue 的 dispatch 属性是用来记录更新 state 的方法的,dispatch 就是 dispatchAction绑定了对应的 Fiber 和 queue。然后返回初始的格式 [name, setName] = useState('dora');执行源码如下:

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    ((currentlyRenderingFiber: any): Fiber),
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

再来看更新过程

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

由此可见 useState 只是个语法糖,本质就是 useReducer;那么再来看 useReducer:

function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  queue.lastRenderedReducer = reducer;
  if (numberOfReRenders > 0) {
    //在当前更新周期中又产生了新的更新
    //就继续执行这些更新直到当前渲染周期中没有更新为止
    ...
  }
  const last = queue.last;
  const baseUpdate = hook.baseUpdate;
  const baseState = hook.baseState;

  let first;
  if (baseUpdate !== null) {
    if (last !== null) {
      last.next = null;
    }
    first = baseUpdate.next;
  } else {
    first = last !== null ? last.next : null;
  }
if (first !== null) {
    let newState = baseState;
    let newBaseState = null;
    let newBaseUpdate = null;
    let prevUpdate = baseUpdate;
    let update = first;
    let didSkip = false;
    do {
      const updateExpirationTime = update.expirationTime;
      if (updateExpirationTime < renderExpirationTime) {
        if (!didSkip) {
          didSkip = true;
          newBaseUpdate = prevUpdate;
          newBaseState = newState;
        }
        if (updateExpirationTime > remainingExpirationTime) {
          remainingExpirationTime = updateExpirationTime;
        }
      } else {
        markRenderEventTimeAndConfig(
          updateExpirationTime,
          update.suspenseConfig,
        );
        if (update.eagerReducer === reducer) {
          newState = ((update.eagerState: any): S);
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }
      }
      prevUpdate = update;
      update = update.next;
    } while (update !== null && update !== first);
    if (!didSkip) {
      newBaseUpdate = prevUpdate;
      newBaseState = newState;
    }
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }
    hook.memoizedState = newState;
    hook.baseUpdate = newBaseUpdate;
    hook.baseState = newBaseState;
    queue.lastRenderedState = newState;
  }  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

就是根据 reducer 和 update.action 来创建新的 state,并赋值给 Hook.memoizedState以及 Hook.baseState;在当前更新周期中又产生了新的更新, 就继续执行这些更新直到当前渲染周期中没有更新为止。然后对每个更新判断其优先级(根据expirationTime值的大小),如果不是当前整体更新优先级内得更新会跳过,第一个跳过得 Update 会变成新的 baseUpdate,他记录了在之后所有得 Update,即便是优先级比他高得,因为在他被执行得时候,需要保证后续的更新要在他更新之后的基础上再次执行。

最后执行 dispatchAction方法,发起一次 scheduleWork 的调度,完成更新,此处省略代码。

useEffect vs useLayoutEffect

useEffect 和 useLayoutEffect 带给 FunctionalComponent 产生副作用能力的 Hooks,他们的行为非常类似 componentDidMount 和 componentDidUpdate 的合集,并且通过 return 一个函数指定如何“清除”副作用。

先看 useEffect 和 useLayoutEffect 更新的过程:

updateEffect

updateLayoutEffect

两者都调用了 updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps) 方法,传入的第二个参数又作为 pushEffect 的入参生成一个新的 effect。

updateEffectImpl

这个 effect 的 tag 就是入参 hookEffectTag,pushEffect 方法返回一个新的 effect,并且创建了一个 updateQueue,这个 queue 会在 commit 阶段被执行。

可以看到,这个阶段,useEffect 和 useLayoutEffect 的主要区别在生成的 effect 的 tag参数不同,通过计算,两者的tag值分别为二进制:0b11000000 和 0b00100100;

再来看 commit 阶段调用的 commitHookEffectList 方法。

commitHookEffectList

通过对比传入的 effectTag(unmountTag & mountTag) 和 Hook 对象上的 effectTag,判断是否需要执行对应的 destory 和 create 方法,那么又在哪些地方调用了commitHookEffectList 方法呢?可以看下其中两处:commitLifeCycles 和 commitWork。

commitLifeCycles

commitWork

在 commitLifeCycles 中传入的 unmountTag 和 mountTag 值分别为:0b00010000 和 0b00100000;
在 commitWork 中传入的 unmountTag 和 mountTag 值分别为:0b00000100 和 0b00001000;
分别计算 effect.tag & unmountTag 和 effect.tag & mountTag:

conclusion

可以看到 useLayoutEffect 的 destory 会在 commitWork 的时候被执行;而他的 create会在 commitLifeCycles 的时候被执行;useEffect 在这个流程中都不会被执行。

事实上:

  • useLayoutEffect 会在当前 commit 执行的过程中就会被执行 destroy 和 create, 而对于 useEffect,会异步地等到这次所有的 dom 节点更新完成,浏览器渲染完成后,才会去执行这部分代码。

  • 它对于 useLayoutEffect 来说,它是不会去阻塞浏览器的渲染,因为我们可能在 useLayoutEffect 里面去执行一些 dom 相关的操作,甚至 setState 来执行一些更新,这种更新都会同步执行,相当于 react 的运行时它要占用更长的 js 的运行时间,导致浏览器没有时间去渲染,最终可能会导致页面会有些卡顿。

  • 服务端渲染情况下,无论 useLayoutEffect 还是 useEffect 都无法在 Javascript 代码加载完成之前执行。可以通过使用 showChild &&进行条件渲染,并使用 useEffect(() => { setShowChild(true); }, []) 延迟展示组件。

  • useLayoutEffect 的执行过程跟 componentDidMount 和 componentDidUpdate 非常相似,所以 React 官方也说了,如果你一定要选择一个类似于生命周期方法的 Hook,那么 useLayoutEffect 是不会错的那个,但是我们推荐你使用 useEffect,在你清楚他们的区别的前提下,后者是更好的选择。

更多 Hook 使用,请查看以下文档

官方文档
https://zh-hans.reactjs.org/docs/hooks-reference.html

官方源码
https://github.com/facebook/react

作者:朵拉

扫码关注:「铜板街科技」微信公众号,精彩内容定期推送。公众号消息界面回复「推荐」「前端」「Java」「客户端」获取更多精准内容。

安卓平台Flutter启动过程全解析

前言

今天主要带大家一起分析下flutter是如何启动、初始化和加载dart代码的。这里有几点需要提前告知:

  1. 由于篇幅的问题,关于flutter界面创建、绘制过程将略过;

  2. 由于相关的c++代码比较多,而且较为复杂,建议先下载flutter engine的完整开发环境代码,阅读本文更方便;

  3. 本文只分析启动过程,参考的项目是基于android studio创建的一个默认flutter项目,以下简称demo。

正文

java层启动过程

熟悉android的朋友都知道,一个APP启动会先执行Application再执行Activity(AndroidManifest.xml中配置的启动Activity),结合这个,我们先看看Application里做了什么,在分析过程中我们将挑取一些关键的native方法作为c++层入口方法作进一步的分析。

// io.flutter.app.FlutterApplication
public class FlutterApplication extends Application {
    @Override
    @CallSuper
    public void onCreate() {
        super.onCreate();
        FlutterMain.startInitialization(this);
    }
    
    //这块代码和FlutterActivityDelegate的生命周期方法结合使用
    private Activity mCurrentActivity = null;
    public Activity getCurrentActivity() {
        return mCurrentActivity;
    }
    public void setCurrentActivity(Activity mCurrentActivity) {
        this.mCurrentActivity = mCurrentActivity;
    }
}

// io.flutter.view.FlutterMain中的方法
public static void startInitialization(Context applicationContext, FlutterMain.Settings settings) {
    if (Looper.myLooper() != Looper.getMainLooper()) {
        throw new IllegalStateException("startInitialization must be called on the main thread");
    } else if (sSettings == null) {
        sSettings = settings;
        long initStartTimestampMillis = SystemClock.uptimeMillis();
        initConfig(applicationContext);
        initAot(applicationContext);
        initResources(applicationContext);
        System.loadLibrary("flutter");
       ...
    }
}

startInitialization只能执行在主线程中,否则会抛出异常。通过sSettings这个变量可以看出,启动的过程中,这个方法将只执行一遍。initConfig初始化一些变量的配置信息(在AndroidManifest.xml中可以通过meta-data方式配置这些变量值), System.loadLibrary("flutter")则完成装载flutter库文件,期间会在c++层完成JNI方法的动态注册。initResources方法我们往下看。

private static void initResources(Context applicationContext) {
	Context context = applicationContext;
	new ResourceCleaner(context).start();
	...
	sResourceExtractor = new ResourceExtractor(context);
	...
	sResourceExtractor.start();
}

ResourceCleaner将清理带有指定标识的缓存文件,ResourceExtractor将完成asset 目录下flutter相关资源的拷贝,这些资源会在后续flutter engine和DartVM等初始化时使用。
然后我们再来看看启动activity都做了些什么

onCreate

//MainActivity.java

public class MainActivity extends FlutterActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    GeneratedPluginRegistrant.registerWith(this);
  }
}

//FlutterActivity.java

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    this.eventDelegate.onCreate(savedInstanceState);
}

先看FlutterActivity中执行onCreate,可以看到这里面并没有当前ContentView的设置,那么其内容界面是在哪里设置的呢,我们可以看到第二句this.eventDelegate.onCreate(savedInstanceState);,最终我们发现Activity中显示的view是在代理类中进行初始化的,下面看下代理类FlutterActivityDelegate的执行,

//FlutterActivityDelegate.java

public void onCreate(Bundle savedInstanceState) {
    ...
    String[] args = getArgsFromIntent(this.activity.getIntent());
    FlutterMain.ensureInitializationComplete(this.activity.getApplicationContext(), args);
    this.flutterView = this.viewFactory.createFlutterView(this.activity);
    if (this.flutterView == null) {
        FlutterNativeView nativeView = this.viewFactory.createFlutterNativeView();
        this.flutterView = new FlutterView(this.activity, (AttributeSet)null, nativeView);
        this.flutterView.setLayoutParams(matchParent);
        this.activity.setContentView(this.flutterView);
        this.launchView = this.createLaunchView();
        if (this.launchView != null) {
            this.addLaunchView();
        }
    }
   ...
   this.runBundle(appBundlePath);
   ... 
}

在这里我们需要注意FlutterMain.ensureInitializationComplete的执行,

//FlutterMain.java

public static void ensureInitializationComplete(Context applicationContext, String[] args) {
    ...
    sResourceExtractor.waitForCompletion();
    ...
    nativeInit(applicationContext, (String[])shellArgs.toArray(new String[0]), appBundlePath, appStoragePath, engineCachesPath);
    sInitialized = true;
    ...
}

//c++关键方法1
private static native void nativeInit(Context var0, String[] var1, String var2, String var3, String var4);

它将等待解压任务结束,资源处理完毕,然后拼接参数,完成参数初始化后将执行nativeInit方法对c++层初始化。

然后会创建FlutterView对象,这里面还包含了很多关键对象的创建,这个下文将会分析到。

//FlutterView.java的构造方法
public FlutterView(Context context, AttributeSet attrs, FlutterNativeView nativeView) {
    super(context, attrs);
    ...
    if (nativeView == null) {
        this.mNativeView = new FlutterNativeView(activity.getApplicationContext());
    } else {
        this.mNativeView = nativeView;
    }
    this.mNativeView.getFlutterJNI();
    this.mIsSoftwareRenderingEnabled = FlutterJNI.nativeGetIsSoftwareRenderingEnabled();
    ...
    this.mNativeView.attachViewAndActivity(this, activity);
    this.mSurfaceCallback = new Callback() {
        public void surfaceCreated(SurfaceHolder holder) {
            FlutterView.this.assertAttached();
            FlutterView.this.mNativeView.getFlutterJNI().onSurfaceCreated(holder.getSurface());
        }

        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            FlutterView.this.assertAttached();
            FlutterView.this.mNativeView.getFlutterJNI().onSurfaceChanged(width, height);
        }

        public void surfaceDestroyed(SurfaceHolder holder) {
            FlutterView.this.assertAttached();
            FlutterView.this.mNativeView.getFlutterJNI().onSurfaceDestroyed();
        }
    };
    this.getHolder().addCallback(this.mSurfaceCallback);
    this.mAccessibilityManager = (AccessibilityManager)this.getContext().getSystemService("accessibility");
    ...
    this.mFlutterLocalizationChannel = new MethodChannel(this, "flutter/localization", JSONMethodCodec.INSTANCE);
    ...
}

这个方法中先执行FlutterNativeView对象创建,然后是FlutterJNI对象创建,再通过c++层完成两者的绑定关系。另外activity和flutterView的绑定关系也在这里完成,并会在PlatformViewsController中完成注册方法回调关系。这个方法还包含了界面绘制监听,flutter绘制的关键调用,建立了通讯体系(各类Channel)。在c++层会用到的资源处理对象也是从这里创建的。

//FlutterNativeView.java构造方法

public FlutterNativeView(Context context, boolean isBackgroundView) {
    this.mPluginRegistry = new FlutterPluginRegistry(this, context);
    this.mFlutterJNI = new FlutterJNI();
    this.mFlutterJNI.setRenderSurface(new FlutterNativeView.RenderSurfaceImpl());
    this.mFlutterJNI.setPlatformMessageHandler(new FlutterNativeView.PlatformMessageHandlerImpl());
    this.mFlutterJNI.addEngineLifecycleListener(new FlutterNativeView.EngineLifecycleListenerImpl());
    this.attach(this, isBackgroundView);
    ....
}
//c++关键方法2
private native long nativeAttach(FlutterJNI var1, boolean var2);

FlutterPluginRegistry是actitiy和flutterView绑定关系操作类,而FlutterJNI创建时,将绑定绘制、跨平台通讯、生命周期的监听方法。这里还会涉及到nativeAttach这个c++方法,等一会将会分析到。

继续看runBundle的执行

//FlutterView.java

private FlutterNativeView mNativeView;
public void runFromBundle(FlutterRunArguments args) {
  assertAttached();
  preRun();
  mNativeView.runFromBundle(args);
  ...
}

//FlutterNativeView.java

public void runFromBundle(FlutterRunArguments args) {
   ...
   runFromBundleInternal(new String[] {args.bundlePath, args.defaultPath},
            args.entrypoint, args.libraryPath);
   ...
}

/**
* 这里通过demo,我们需要留意下传入的数据,方便接下来的分析
* bundlePaths:(flutter_assets目录地址)
* entrypoint:"main"
* libraryPath:null
*
*/
private void runFromBundleInternal(String[] bundlePaths, String entrypoint,
    String libraryPath) {
    ....
    mFlutterJNI.runBundleAndSnapshotFromLibrary(
        bundlePaths,
        entrypoint,
        libraryPath,
        mContext.getResources().getAssets()
    );
    ....
}

此时,runFromBundle会先判断资源的绑定,把一些参数通过runBundleAndSnapshotFromLibrary方法中mFlutterJNI对象调用JNI方法来传递指定flutter入口供DartVM执行dart层代码逻辑。

//FlutterJNI.java

@UiThread
public void runBundleAndSnapshotFromLibrary(@NonNull String[] prioritizedBundlePaths, @Nullable String entrypointFunctionName, @Nullable String pathToEntrypointFunction, @NonNull AssetManager assetManager) {
    this.ensureAttachedToNative();
    this.nativeRunBundleAndSnapshotFromLibrary(this.nativePlatformViewId, prioritizedBundlePaths, entrypointFunctionName, pathToEntrypointFunction, assetManager);
}
    
//最终样例数据:pathToEntrypointFunction = null,entrypointFunctionName="main"
//prioritizedBundlePaths同上面,nativePlatformViewId = 3719055232
private native void nativeRunBundleAndSnapshotFromLibrary(
    long nativePlatformViewId,
    @NonNull String[] prioritizedBundlePaths,
    @Nullable String entrypointFunctionName,
    @Nullable String pathToEntrypointFunction,
    @NonNull AssetManager manager
);

nativeRunBundleAndSnapshotFromLibrary 则是native启动方法的入口,另外这个 nativePlatformViewId 是在FlutterNativeView 创建的时候调用了FlutterJNIattachToNative方法,其来源是native层shell_holder对象指针,这个对象指针在native启动过程中非常关键。

再看MainActivity中onCreate执行,GeneratedPluginRegistrant.registerWith(this)将执行到如下代码中

//FlutterActivityDelegate.java

private FlutterView flutterView;
@Override
public Registrar registrarFor(String pluginKey) {
    return flutterView.getPluginRegistry().registrarFor(pluginKey);
}

//FlutterPluginRegistry.java

@Override
public Registrar registrarFor(String pluginKey) {
    if (mPluginMap.containsKey(pluginKey)) {
        throw new IllegalStateException("Plugin key " + pluginKey + " is already in use");
    }
    mPluginMap.put(pluginKey, null);
    return new FlutterRegistrar(pluginKey);
}

registrarFor保存了插件的实例,避免重复注册。

onStart:

以下方法通过生命周期对应的Platform Channel发送生命周期状态给Flutter层来告知当前的APP状态。

this.mFlutterLifecycleChannel.send("AppLifecycleState.inactive");

onResume:

public void onResume() {
    Application app = (Application)this.activity.getApplicationContext();
    FlutterMain.onResume(app);
    if (app instanceof FlutterApplication) {
        FlutterApplication flutterApp = (FlutterApplication)app;
        flutterApp.setCurrentActivity(this.activity);
    }
}

public static void onResume(Context context) {
    //热更新有关,这里也不分析
    if (sResourceUpdater != null && sResourceUpdater.getDownloadMode() == DownloadMode.ON_RESUME) {
        sResourceUpdater.startUpdateDownloadOnce();
    }
}

到这里基本完成了java层分析,主要方法调用链可以参考如下
image

接下来将需要分析的关键JNI方法罗列如下:

  • nativeInit
  • nativeAttach
  • nativeRunBundleAndSnapshotFromLibrary

c/c++层启动过程

nativeInit分析

我们直接找到对应的方法,位于shell/platform/android/flutter_main.cc

void FlutterMain::Init(JNIEnv* env,
                       jclass clazz,
                       jobject context,
                       jobjectArray jargs,
                       jstring bundlePath,
                       jstring appStoragePath,
                       jstring engineCachesPath) {
  std::vector<std::string> args;
  args.push_back("flutter");
  for (auto& arg : fml::jni::StringArrayToVector(env, jargs)) {
    args.push_back(std::move(arg));
  }
  auto command_line = fml::CommandLineFromIterators(args.begin(), args.end());

  auto settings = SettingsFromCommandLine(command_line);

  settings.assets_path = fml::jni::JavaStringToString(env, bundlePath);
  ...
  settings.task_observer_add = [](intptr_t key, fml::closure callback) {
    fml::MessageLoop::GetCurrent().AddTaskObserver(key, std::move(callback));
  };

  settings.task_observer_remove = [](intptr_t key) {
    fml::MessageLoop::GetCurrent().RemoveTaskObserver(key);
  };
  ...
  g_flutter_main.reset(new FlutterMain(std::move(settings)));
}

这里做了几件事情:

  • 解析java传过来的参数
  • 创建Setting,保存配置
  • 创建FlutterMain,重置其全局对象

nativeAttach分析

static jlong AttachJNI(JNIEnv* env,
                       jclass clazz,
                       jobject flutterJNI,
                       jboolean is_background_view) {
  fml::jni::JavaObjectWeakGlobalRef java_object(env, flutterJNI);
  auto shell_holder = std::make_unique<AndroidShellHolder>(
      FlutterMain::Get().GetSettings(), java_object, is_background_view);
  if (shell_holder->IsValid()) {
    return reinterpret_cast<jlong>(shell_holder.release());
  } else {
    return 0;
  }
}

//shell/platform/android/android_shell_holder.cc
AndroidShellHolder::AndroidShellHolder(
    blink::Settings settings,
    fml::jni::JavaObjectWeakGlobalRef java_object,
    bool is_background_view)
    : settings_(std::move(settings)), java_object_(java_object) {
  ...
  auto jni_exit_task([key = thread_destruct_key_]() {
    FML_CHECK(pthread_setspecific(key, reinterpret_cast<void*>(1)) == 0);
  });
  thread_host_.ui_thread->GetTaskRunner()->PostTask(jni_exit_task);
  if (!is_background_view) {
    thread_host_.gpu_thread->GetTaskRunner()->PostTask(jni_exit_task);
  }
  ...
  fml::MessageLoop::EnsureInitializedForCurrentThread();
  fml::RefPtr<fml::TaskRunner> gpu_runner;
  fml::RefPtr<fml::TaskRunner> ui_runner;
  fml::RefPtr<fml::TaskRunner> io_runner;
  fml::RefPtr<fml::TaskRunner> platform_runner =
      fml::MessageLoop::GetCurrent().GetTaskRunner();
  if (is_background_view) {
    auto single_task_runner = thread_host_.ui_thread->GetTaskRunner();
    gpu_runner = single_task_runner;
    ui_runner = single_task_runner;
    io_runner = single_task_runner;
  } else {
    gpu_runner = thread_host_.gpu_thread->GetTaskRunner();
    ui_runner = thread_host_.ui_thread->GetTaskRunner();
    io_runner = thread_host_.io_thread->GetTaskRunner();
  }
  blink::TaskRunners task_runners(thread_label,     // label
                                  platform_runner,  // platform
                                  gpu_runner,       // gpu
                                  ui_runner,        // ui
                                  io_runner         // io
  );
  shell_ =
      Shell::Create(task_runners,             // task runners
                    settings_,                // settings
                    on_create_platform_view,  // platform view create callback
                    on_create_rasterizer      // rasterizer create callback
      );
     ...
}

std::unique_ptr<Shell> Shell::Create(
    blink::TaskRunners task_runners,
    blink::Settings settings,
    Shell::CreateCallback<PlatformView> on_create_platform_view,
    Shell::CreateCallback<Rasterizer> on_create_rasterizer) {
   PerformInitializationTasks(settings);
  auto vm = blink::DartVM::ForProcess(settings);
  FML_CHECK(vm) << "Must be able to initialize the VM.";
  return Shell::Create(std::move(task_runners),             //
                       std::move(settings),                 //
                       vm->GetIsolateSnapshot(),            //
                       blink::DartSnapshot::Empty(),        //
                       std::move(on_create_platform_view),  //
                       std::move(on_create_rasterizer)      //
  );
}

nativeAttach的方法中,调用了AndroidShellHolder对象的创建,包含了JNI生命周期同UI和GPU线程绑定, 视图回调和c++层绘制绑定,启动一些必要的线程。而shell对象的创建中,PerformInitializationTasks包含了一些关键库的初始化,如skia(图形绘制库)、ICU(国际化库)等初始化,shell对象的创建也标志着dart vm的创建。

关键点:AndroidShellHolder对象创建完成后,会将其对象指针值返回给java层保存,用于后续安卓原生层对Flutter层各操作方法的调用。

nativeRunBundleAndSnapshotFromLibrary 分析

在shell/platform/android/io/platform_view_android_jni.cc中,我们很容易找到对应的方法,是采用动态注册的方式:

  {
          .name = "nativeRunBundleAndSnapshotFromLibrary",
          .signature = "(J[Ljava/lang/String;Ljava/lang/String;"
                       "Ljava/lang/String;Landroid/content/res/AssetManager;)V",
          .fnPtr =
              reinterpret_cast<void*>(&shell::RunBundleAndSnapshotFromLibrary),
 }
static void RunBundleAndSnapshotFromLibrary(JNIEnv* env,
                                            jobject jcaller,
                                            jlong shell_holder,
                                            jobjectArray jbundlepaths,
                                            jstring jEntrypoint,
                                            jstring jLibraryUrl,
                                            jobject jAssetManager) {
  auto asset_manager = std::make_shared<blink::AssetManager>();
  for (const auto& bundlepath :
       fml::jni::StringArrayToVector(env, jbundlepaths)) {
    ...
    const auto file_ext_index = bundlepath.rfind(".");
    if (bundlepath.substr(file_ext_index) == ".zip") {
        //资源解压
      asset_manager->PushBack(std::make_unique<blink::ZipAssetStore>(
          bundlepath, "assets/flutter_assets"));

    } else {
      //操作资源地址并存储到容器中
      asset_manager->PushBack(
          std::make_unique<blink::DirectoryAssetBundle>(fml::OpenDirectory(
              bundlepath.c_str(), false, fml::FilePermission::kRead)));
      ...
    }
  }

  auto isolate_configuration = CreateIsolateConfiguration(*asset_manager);
  ...
  RunConfiguration config(std::move(isolate_configuration),
                          std::move(asset_manager));

  {
    auto entrypoint = fml::jni::JavaStringToString(env, jEntrypoint);
    auto libraryUrl = fml::jni::JavaStringToString(env, jLibraryUrl);

    if ((entrypoint.size() > 0) && (libraryUrl.size() > 0)) {
        //设置dart的入口函数,entrypoint为“main”,引用库地址
      config.SetEntrypointAndLibrary(std::move(entrypoint),
                                     std::move(libraryUrl));
    } else if (entrypoint.size() > 0) {
      config.SetEntrypoint(std::move(entrypoint));
    }
  }

  ANDROID_SHELL_HOLDER->Launch(std::move(config));
}

从上面的方法我们可以简单的总结下这个方法做了什么:

  • 资源的解压
  • 创建AppSnapshotIsolateConfiguration对象
  • 执行配置项
  • 执行启动方法

android_shell_holder.cc

void AndroidShellHolder::Launch(RunConfiguration config) {
  //is_valid_ = shell_ != nullptr;正常情况下为true
  if (!IsValid()) {
    return;
  }

  shell_->GetTaskRunners().GetUITaskRunner()->PostTask(
      fml::MakeCopyable([engine = shell_->GetEngine(),  //拿到了引擎的弱引用对象
                         config = std::move(config)     
  ]() mutable {
        ...
        //next
        if (!engine || engine->Run(std::move(config)) ==
                           shell::Engine::RunStatus::Failure) {
        ...
        }
        ...
      }));
}

Launch方法中拿到engine对象后,调用Run的执行

//engine.cc
Engine::RunStatus Engine::Run(RunConfiguration configuration) {
  ...
  auto isolate_launch_status =
      PrepareAndLaunchIsolate(std::move(configuration));
  ....
}

shell::Engine::RunStatus Engine::PrepareAndLaunchIsolate(
    RunConfiguration configuration) {
  TRACE_EVENT0("flutter", "Engine::PrepareAndLaunchIsolate");
  UpdateAssetManager(configuration.GetAssetManager());
  auto isolate_configuration = configuration.TakeIsolateConfiguration();
  std::shared_ptr<blink::DartIsolate> isolate =
      runtime_controller_->GetRootIsolate().lock();

  if (!isolate) {
    return RunStatus::Failure;
  }
  ...
  if (!isolate_configuration->PrepareIsolate(*isolate)) {
    FML_LOG(ERROR) << "Could not prepare to run the isolate.";
    return RunStatus::Failure;
  }
  if (configuration.GetEntrypointLibrary().empty()) {
    if (!isolate->Run(configuration.GetEntrypoint())) {
      FML_LOG(ERROR) << "Could not run the isolate.";
      return RunStatus::Failure;
    }
  } else {
    if (!isolate->RunFromLibrary(configuration.GetEntrypointLibrary(),
                                 configuration.GetEntrypoint())) {
      FML_LOG(ERROR) << "Could not run the isolate.";
      return RunStatus::Failure;
    }
  }

  return RunStatus::Success;
}

在engine的启动过程中,准备和启动isolate,在这个方法中将完成对isolate创建、及状态返回处理。更新资源管理后,PrepareIsolate方法主要检查Isolate的状态,通过属性phase(枚举)来表示不同的状态,然后我们再结合java层传递的数据,可以知道将执行isolate->Run方法。

//dart_api_impl.cc
FML_WARN_UNUSED_RESULT
bool DartIsolate::Run(const std::string& entrypoint_name) {
 ...   
  auto user_entrypoint_function =
      Dart_GetField(Dart_RootLibrary(), tonic::ToDart(entrypoint_name.c_str()));
  if (!InvokeMainEntrypoint(user_entrypoint_function)) {
    return false;
  }
 ...
}

Run方法中也比较简单,继续看下文。

//dart_isolate.cc
FML_WARN_UNUSED_RESULT
static bool InvokeMainEntrypoint(Dart_Handle user_entrypoint_function) {
  ...
  Dart_Handle start_main_isolate_function =
      tonic::DartInvokeField(Dart_LookupLibrary(tonic::ToDart("dart:isolate")),
                             "_getStartMainIsolateFunction", {});
  ...
  if (tonic::LogIfError(tonic::DartInvokeField(
          Dart_LookupLibrary(tonic::ToDart("dart:ui")), "_runMainZoned",
          {start_main_isolate_function, user_entrypoint_function}))) {
    FML_LOG(ERROR) << "Could not invoke the main entrypoint.";
    return false;
  }
  return true;
}

在InvokeMainEntrypoint方法中 会拿到了Dart_Handle对象,并通过DartInvokeField方法执行Dart_Invoke方法。另外Dart_LookupLibrary中创建的对象是一个Library,这个是下个方法执行步骤的判断依据。

dart_api_impl.cc

DART_EXPORT Dart_Handle Dart_Invoke(Dart_Handle target,
                                    Dart_Handle name,
                                    int number_of_arguments,
                                    Dart_Handle* arguments) {                               
  DARTSCOPE(Thread::Current());
  API_TIMELINE_DURATION(T);
  CHECK_CALLBACK_STATE(T);
  String& function_name =
      String::Handle(Z, Api::UnwrapStringHandle(Z, name).raw());
  if (function_name.IsNull()) {
    RETURN_TYPE_ERROR(Z, name, String);
  }
  if (number_of_arguments < 0) {
    return Api::NewError(
        "%s expects argument 'number_of_arguments' to be non-negative.",
        CURRENT_FUNC);
  }
  ...
  if (obj.IsType()) {
    ...
    const Class& cls = Class::Handle(Z, Type::Cast(obj).type_class());
    ...
    //分析节点1
    return Api::NewHandle(
        T, cls.Invoke(function_name, args, arg_names, respect_reflectable,
                      check_is_entrypoint));
  } else if (obj.IsNull() || obj.IsInstance()) {
     ...
    Instance& instance = Instance::Handle(Z);
    ...
    //分析节点2
    return Api::NewHandle(
        T, instance.Invoke(function_name, args, arg_names, respect_reflectable,
                           check_is_entrypoint));
  } else if (obj.IsLibrary()) {
    ...
    const Library& lib = Library::Cast(obj);
    ...
    //分析节点3
    return Api::NewHandle(
        T, lib.Invoke(function_name, args, arg_names, respect_reflectable,
                      check_is_entrypoint));
  } 
  ...
}

Dart_Invoke方法中,会先进行状态检查 ,然后拿到由java层传递过来的dart 入口函数对应的方法名(也就"main"),注意在这个方法中,不管是错误还是正确都是返回Dart_Handle这个对像。然后再看这个三个分析节点,根据上面的分析,将会执行节点3

//object.cc
RawObject* Library::Invoke(const String& function_name,
                           const Array& args,
                           const Array& arg_names,
                           bool respect_reflectable,
                           bool check_is_entrypoint) const {
    ...
    Function& function = Function::Handle(zone, LookupStaticFunction(function_name));
    ...
    return DartEntry::InvokeFunction(function, args, args_descriptor_array);  
    ...
}

Invoke方法将会通过方法名拿到内存中Function对象,然后通过dart执行该方法。

这里留意下DartEntry这个类,看源码的注释大意是提取解析dart函数所需的功能的操作对象。是dart函数调用的重要对象之一,接着看看InvokeFunction做了什么。

third_party/dart/runtime/vm/dart_entry.cc

RawObject* DartEntry::InvokeFunction(const Function& function,
                                     const Array& arguments,
                                     const Array& arguments_descriptor,
                                     uword current_sp) {
...
#if defined(TARGET_ARCH_DBC)
  //具体方法解析调用
  return Simulator::Current()->Call(code, arguments_descriptor, arguments,
                                    thread);
#elif defined(USING_SIMULATOR)
 //模拟器
  return bit_copy<RawObject*, int64_t>(Simulator::Current()->Call(
      reinterpret_cast<intptr_t>(entrypoint), reinterpret_cast<intptr_t>(&code),
      reinterpret_cast<intptr_t>(&arguments_descriptor),
      reinterpret_cast<intptr_t>(&arguments),
      reinterpret_cast<intptr_t>(thread)));
      ...
}

InvokeFunction 中将先会对Function对象内容是否编译过进行判断(未编译将编译重新调用),拿到当前线程去执行。该方法还会区分生产环境,是否是模拟器等情况对方法进行解析,解析方法执行可以参考Simulator::Current()->Call,在Call的方法内我们可以看到整个方法非常庞大,光方法体就有几千行代码,包含了常量值、字节码等的操作,所以这一篇文章就不展开分析。有兴趣的朋友可以结合虚拟机原理,看看这部分是如何执行的。

缺图地址: https://user-gold-cdn.xitu.io/2019/4/1/169d6dcb4c3fb1f0?w=5103&h=2349&f=png&s=150244

总结

至此我们大致看到了整个启动过程,在java层主要是对flutter资源相关的参数进行了赋值、初始化,以及回调方法的注册,资源的拷贝,c++关键方法的调用,建立了通讯体系(各类Channel)。而在c++层,我们发现除去关键对象的创建,还有各类异常的处理(包含各种情况的考量),参数的解析,资源的解析,方法对象的构建等一系列的调用,最后通过dart vm的操作对象对方法进行解析与执行。

作者简介

苏哲,铜板街Android开发工程师,2017年12月加入团队,目前主要负责APP端 Android 日常开发。

本文重点介绍了安卓平台Flutter启动过程的机制,如需获取更多 Flutter 相关的知识,可以扫码关注 “ 铜板街科技 ”公众号 ,并在后台回复 "Flutter" 关键词获取更多精彩内容。

iOS组件化实践

前言

公司业务不断迭代扩张,项目的功能越来越多也越来越复杂,各个业务之间也不可避免的耦合越来越多,代码也越来越臃肿,原来的模式已经无法满足现有项目开发高复用、高可维护性的需求,目前业界解决业务多样性复杂性比较好的一种架构思路就是组件化,将项目拆分成各个模块,这样能很好的解决现有的代码耦合度高、复用性不足的问题,也方便管理各个模块。

技术选型

前期调研了一些组件化的方案,大致归纳为三个方案url-block、protocol-class、target-action,经过权衡后,我们最终选择target-action这种方案,说到这里肯定会有人问了你们是基于什么理由去定的这个方案的呢?由于三个方案的优缺点全部描述篇幅太大,下面我主要基于调用方式、传参模式给大家阐述下。

调用方式

组件之间的调用其实就是调用方调用服务方的一个过程,这里就涉及到一个问题调用方如何发现服务方的问题。

  • url-block是通过url来发现服务,服务方在应用启动时候优先注册一系列url和对应的block到内存中;
  • protocol-class其实是基于url-block的一种扩展补充的组件化方案,服务方也需要在应用启动的时候优先将class和protocol做一个映射存放到内存中;
  • target-action基于的是runtime,不涉及到任何注册映射关系的问题。

那么问题来了,url-block、protocol-class每次启动的时候都需要注册一系列的映射关系到内存,随着项目的越来越庞大,不可避免的会消耗掉更多的内存;业务的扩展变更不可避免的会涉及到服务映射关系的维护(增删改),维护成本也会不断增加,target-action则通过runtime完全避免了类似的问题,内存开销小,维护成本低。

传参模式

组件间调用,不可避免的会涉及到参数的传递。

  • url-block是基于url,这样弊端就暴露无遗了,url传递的参数限制性很强,就举个简单的列子,分享模块的分享图片问题,图片之类的参数url传递不了,再比如字典、数组等,这样url传递这些参数显然不合适也不方便;
  • protocol-class正是由于url-block传参的弊端才孕育而生的扩展方法,这里虽然能解决传递复杂参数的问题,但是内存增加和难维护的问题依旧存在;
  • target-action方案提出了去model化传参,因为如果传递的是对应的model,因为对应的model一般都是和对应的业务或者模块挂钩的,这样组件之间本质上还是没有独立,没有达到去耦合的目的,最终这个方案调用方和服务方之间是通过字典来进行传参,这样做具备字典传参的灵活性、多样性,也具备了url-block方案不具备传递复杂参数的能力,参数去model化和调用runtime也彻底斩断了服务方和中间件之间的依赖,真正意义上实现了组件化。

备注:本文我们会不断提到一个概念叫runtime(其主要特性是消息传递,如果消息在对象中找不到,就进行转发),如果对iOS runtime不是特别了解,可以先搜索了解下,方便后面理解文章。

target-action组件化方案

方案架构

target-action组件化方案分为两种调用方式,远程调用和本地调用。

a. 远程调用通过AppDelegate代理方法传递到当前应用,调用远程接口并在内部做一些处理,处理完成后会在远程接口内部调用本地接口,以实现本地调用为远程调用服务。

b. 本地调用由performTarget:action:params:方法负责,但调用方一般不直接调用此方法,会通过一个中间层Media层,Media层会提供明确参数和方法名的方法,在方法内部调用performTarget:方法和参数的转换。

方案思路

组件化完整的链路是调用方 => 中间件 => 服务方,这样整个调用算是完成,下面从后两者的角色来阐述下大致的一个实现思路(调用方其实很简单,字面意思大家都懂)。

1、中间件
TBJMediator(中间件)是基于CTMediator(target-action方案作者提供)的优化版本,基于CTMediator做了一些优化和容错处理。首先中间件对外(调用方)暴露明确参数类型的方法,调用performTarget发现服务方对应的Target和Action,实现本地组件间的调用,实际是通过runtime(俗称消息分发)发现服务方和服务方对应的方法。这里大家可以思考一个问题,每个业务或者模块所有的调用如果都写在这个中间件中,几十个甚至几百上千个方法,势必会对这个中间件的后期维护带来极大的麻烦(埋坑),基于这样的现实孕育而生了Category方案,根据每个服务方业务,对应创建一个TBJMediator的Category(中间件分类),这样每个业务对外暴露的接口和这些Category一一对应,但是所有对外接口都根据业务分离。

2、服务方
服务方顾名思义服务的提供方,其实Target-Action这个方案名称已经提前剧透了,每个Target就是对应服务方提供的服务类,其中的每个Action就是具体的某项服务。每个组件可以根据实际需要提供一个或者多个Target类,在Target类中声明Action方法,TBJMediator通过runtime主动发现服务。

具体实施

组件化的目的就是为了降低耦合,但是项目中不可能不存在耦合,换句话说项目中各个业务都是有一定的关联性,我们要做的就是不断降低不必要的耦合,让项目变的架构清晰明了。为了能优先完成整个组件化方案,我们将拆分的维度适当放宽,剥离各个基础组件和业务组件,并保证每个组件的独立性。

具体划分出基础组件、基础业务模块、业务模块。

  • 基础组件主要包含业务完全无关的一些UI控件、UI工厂类、基础工具类、网络请求等;
  • 基础业务模块包含分享模块、插件模块、以及基础服务模块等;
  • 业务模块主要包含产品模块、用户模块等。


每个模块都基于CocoaPods进行管理,并相互保持独立,业务模块相互之间的调用也均通过中间层去调用,相互之间没有直接引用。在拆分层级过程中需要注意,上层不能对下层有依赖,下层中不能包含上层的业务逻辑,对于项目中的公共资源和代码,尽量下沉到下层中。

技术实现(产品模块为例)

调用方在某处调用[[TBJMediator sharedInstance] performTarget:targetName action:actionName params:@{...}]向TBJMediator发起跨组件调用,TBJMediator根据获得的target和action信息,通过objective-C的Runtime转化生成Target实例以及对应的Action,然后最终调用到目标业务。

调用方

// ViewController.h
#import "TBJMediator+TBJProdModul.h"

UIViewController *netLoanListVC = [TBJMediator getNetLoanListVCWithTypeId:typeId title:title];
[self.navigationController pushViewController:netLoanListVC  animated:YES];

TBJMediator分类(中间件)

// TBJMediator+TBJProdModul.h

+ (UIViewController *)getNetLoanListVCWithTypeId:(NSString *)typeId title:(NSString *)title;

//  TBJMediator+TBJProdModul.m
+ (UIViewController *)getNetLoanListVCWithTypeId:(NSString *)typeId title:(NSString *)title
{
    id typeIdArg = NilObj(typeId);
    id titleArg = NilObj(typeId);
    return [[TBJMediator sharedInstance] performTarget:@"ProdModul" action:@"getNetLoanListVC" params:@{@"typeId": typeIdArg, @"title":titleArg} shouldCacheTarget:NO];
}

服务方

// Target_ProdModul.h

- (UIViewController *)getNetLoanListVC:(NSDictionary *)params;

上面代码中调用方只需要依赖TBJMediator+TBJProdModul,进而达到了调用某个产品列表的目的,我们解耦的目的达到了。当然我们也能观察到,现在的数据传递是通过字典,没有用model传递,这样做避免了直接依赖model,避免model暴露给所有组件,而且字典传递参数很灵活,可以传递各种想要的数据类型。当然字典传递参数也不是没有缺点,为了调用方清晰,当参数个数比较多的时候,方法会看上去比较冗长,而且还需要特别注意参数的非空判断。

总结

模块化拆分时候需要注意的点

1.合理的拆分粒度

一开始拆分的时候粒度要适中,粒度太细的话拆分很困难,俗话说拔出萝卜带出泥,先将相对粗粒度的业务独立的组件拆分出来,后续如果一个拆分完成的库仍然比较臃肿的化,说明仍然存在细化拆分的余地。

2.制定拆分计划

前期将项目组件大致梳理一遍,制定一个合理的拆分计划,制定详细的整体规划能够将一些前期不合理的依赖、不合理的维度暴露出来,提升后续拆分的效率。

3.拆分原则

在拆分层级过程中需要注意,上层不能对下层有依赖,下层中不能包含上层的业务逻辑。对于项目中的公共资源和代码,尽量下沉到下层中。

模块化后相比单项目的一些缺点

1.当然模块化虽然有很多优点,但是实际操作过程中由于CocoaPods上传私有库步骤繁琐,如果每个库都是手动去上传,就会比较费劲,还是需要一些额外的脚本配合。

2.由于涉及到打包编译顺序问题(CocoaPods维护的私有库优先编译),有些预编译宏要格外注意,不然可能编译后的代码并不是你想要的,可能编译成了测试环境或者其他测试环境的代码。

3.另外每次上线之前app打包也必须要保证每个模块必须是最新的版本,相对单项目就没有这个问题。

组件化目前也只是迈出了这一步,后期还有很多需要优化改进,也希望有更多的技术大咖能给出建议。

作者简介

狄仁杰,铜板街 iOS 开发工程师,2013年12月加入团队,目前主要负责 APP 端 iOS 日常开发。

更多精彩内容,请扫码关注 “铜板街科技” 微信公众号。

Flutter动画实现原理浅析

Flutter 动画

本文主要介绍 Flutter 动画相关的内容,对相关的知识点进行了梳理,并从实际例子出发,进一步分析了动画是如果实现的。

一个简单的动画效果

这是一个简单的 Flutter Logo 动画

代码如下

class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  Animation<double> animation;
  AnimationController controller;

  initState() {
    super.initState();
    controller = new AnimationController(
        duration: const Duration(milliseconds: 2000), vsync: this);
    animation = new Tween(begin: 0.0, end: 300.0).animate(controller)
      ..addListener(() {
        setState(() {
          // Animation 对象的值改变了
        });
      });
    controller.forward();
  }
  Widget build(BuildContext context) {
  
        // ....
        height: animation.value,
        width: animation.value,
        // ....
  }

这部分源码表示的就是在 2 秒内将 logo 图片从 0x0 绘制为 300x300 大小的动画。 类 _LogoAppState 是一个 StatusfulWidget的State,可以调用 setStatus() 方法进行更新 weight。 我们可以看到这个例子有两个成员变量 Animation<double> animationAnimationController controller, 这两个对象是动画能够运行的关键。

我们看到在 animation 变量的 addListener() 回调方法里面调用了 State 的重绘方法 setState() , 而在 State 的 build() 方法里使用了 animation 对象的值作为 weight 的宽度和高度使用。 从这里我们能够推测出:animation 对象通过监听器注册将 animation 需要更新的变动通知给监听器, 而监听器又调用 setStatus() 方法让 widget 去重新绘制, 而在绘制时,widget 又将 animation 的 value 值当作新绘制图形的参数, 通过这样的机制不断地重绘这个 weight 实现了动画的效果。

Animation

Animation 是 Flutter 动画库中的核心类,它会插入指导动画生成的值。 Animation 对象知道一个动画当前的状态(例如开始、 停止、 播放、 回放), 但它不知道屏幕上绘制的是什么, 因为 Animation 对象只是提供一个值表示当前需要展示的动画, UI 如何绘制出图形完全取决于 UI 自身如何在渲染和 build() 方法里处理这个值, 当然也可以不做处理。 Animation<double> 是一个比较常用的Animation类, 泛型也可以支持其它的类型,比如: Animation<Color>Animation<Size>。 Animation 对象就是会在一段时间内依次生成一个区间之间值的类, 它的输出可以是线性的、曲线的、一个步进函数或者任何其他可以设计的映射 比如:CurvedAnimation。

AnimationController

AnimationController 是一个动画控制器, 它控制动画的播放状态, 如例子里面的: controller.forward() 就是控制动画"向前"播放。 所以构建 AnimationController 对象之后动画并没有立刻开始执行。 在默认情况下, AnimationController 会在给定的时间内线性地生成从 0.0 到 1.0 之间的数字。 AnimationController 是一种特殊的 Animation 对象了, 它父类其实是一个 Animation<double>, 当硬件准备好需要一个新的帧的时候它就会产生一个新的值。 由于 AnimationController 派生自 Animation <double>,因此可以在需要 Animation 对象的任何地方使用它。 但是 AnimationController 还有其他的方法来控制动画的播放, 例如前面提到的 .forward() 方法启动动画。

AnimationController 生成的数字(默认是从 0.0 到 1.0) 是和屏幕刷新有关, 前面也提到它会在硬件需要一个新帧的时候产生新值。 因为屏幕一般都是 60 帧/秒, 所以它也通常一秒内生成 60 个数字。 每个数字生成之后, 每个 Animation 对象都会调用绑定的监听器对象。

Tween

Tween 本身表示的就是一个 Animation 对象的取值范围, 只需要设置开始和结束的边界值(值也支持泛型)。 它唯一的工作就是定义输入范围到输出范围的映射, 输入一般是 AnimationController 给出的值 0.0~1.0。 看下面的例子, 我们就能知道 animation 的 value 是怎么样通过 AnimationController 生成的值映射到 Tween 定义的取值范围里面的。

1、 Tween.animation 通过传入 aniamtionController 获得一个_AnimatedEvaluation 类型的 animation 对象(基类为 Animation), 并且将 aniamtionController 和 Tween 对象传入了 _AnimatedEvaluation 对象。

  animation = new Tween(begin: 0.0, end: 300.0).animate(controller)
    ...
  Animation<T> animate(Animation<double> parent) {
    return _AnimatedEvaluation<T>(parent, this);
  }

2、 animation.value 方法即是调用 _evaluatable.evaluate(parent) 方法, 而 _evaluatable 和 parent 分别为 Tween 对象和 AnimationController 对象。

  T get value => _evaluatable.evaluate(parent);
     ....
  class _AnimatedEvaluation<T> extends Animation<T> with AnimationWithParentMixin<double> {
     _AnimatedEvaluation(this.parent, this._evaluatable);
     ....

3、 这里的 animation 其实就是前面的 AnimationController 对象, transform 方法里面的 animation.value 则就是 AnimationController 线性生成的 0.01.0 直接的值。 在 lerp 方法里面我们可以看到这个 0.01.0 的值被映射到了 begin 和 end 范围内了。

  T evaluate(Animation<double> animation) => transform(animation.value);

    T transform(double t) {
    if (t == 0.0)
      return begin;
    if (t == 1.0)
      return end;
    return lerp(t);
  }

    T lerp(double t) {
    assert(begin != null);
    assert(end != null);
    return begin + (end - begin) * t;
  }

Flutter 的"时钟"

那么 Flutter 是怎么样让这个动画在规定时间不断地绘制的呢?

首先看 Widget 引入的 SingleTickerProviderStateMixin 类。SingleTickerProviderStateMixin 是以 with 关键字引入的, 这是 dart 语言的 mixin 特性, 可以理解成"继承", 所以 widget 相当于是继承了SingleTickerProviderStateMixin。 所以在 AnimationController 对象的构造方法参数 vsync: this, 我们看到了这个类的使用。 从 "vsync" 参数名意为"垂直帧同步"可以看出, 这个是绘制动画帧的"节奏器"。

  AnimationController({
    double value,
    this.duration,
    this.debugLabel,
    this.lowerBound = 0.0,
    this.upperBound = 1.0,
    this.animationBehavior = AnimationBehavior.normal,
    @required TickerProvider vsync,
  }) : assert(lowerBound != null),
       assert(upperBound != null),
       assert(upperBound >= lowerBound),
       assert(vsync != null),
       _direction = _AnimationDirection.forward {
    _ticker = vsync.createTicker(_tick);
    _internalSetValue(value ?? lowerBound);
  }

在 AnimationController 的构造方法中, SingleTickerProviderStateMixin 的父类 TickerProvider 会创建一个 Ticker, 并将_tick(TickerCallback 类型)回调方法绑定到了 这个 Ticker, 这样 AnimationController 就将回调方法 _tick 和 Ticker 绑定了。

@protected
void scheduleTick({ bool rescheduling = false }) {
	assert(!scheduled);
	assert(shouldScheduleTick);
	_animationId = SchedulerBinding.instance.scheduleFrameCallback(_tick, rescheduling: rescheduling);
}

而 Ticker 会在 start 函数内将_tick 被绑定到 SchedulerBinding 的帧回调方法内。 返回的_animationId 是 SchedulerBinding 给定的下一个动作回调的 ID, 可以根据_animationId 来取消 SchedulerBinding 上绑定的回调。

SchedulerBinding 则是在构造方法中将自己的 _handleBeginFrame 函数和 window 的 onBeginFrame 绑定了回调。 这个回调会在屏幕需要准备显示帧之前回调。

再回到 AnimationController 看它是如何控制 Animation 的值的。

   void _tick(Duration elapsed) {
    _lastElapsedDuration = elapsed;
    final double elapsedInSeconds = elapsed.inMicroseconds.toDouble() / Duration.microsecondsPerSecond;
    assert(elapsedInSeconds >= 0.0);
    _value = _simulation.x(elapsedInSeconds).clamp(lowerBound, upperBound);
    if (_simulation.isDone(elapsedInSeconds)) {
      _status = (_direction == _AnimationDirection.forward) ?
        AnimationStatus.completed :
        AnimationStatus.dismissed;
      stop(canceled: false);
    }
    notifyListeners();
    _checkStatusChanged();
  }

在 AnimationController 的回调当中, 会有一个 Simulation 根据动画运行了的时间(elapsed) 来计算当前的的_value 值, 而且这个值还需要处于 Animation 设置的区间之内。 除了计算_value 值之外, 该方法还会更新 Animation Status 的状态, 判断是否动画已经结束。 最后通过 notifyListeners 和_checkStatusChanged 方法通知给监听器 value 和 AnimationStatus 的变化。 监听 AnimationStatus 值的变化有一个专门的注册方法 addStatusListener。

通过监听 AnimationStatus, 在动画开始或者结束的时候反转动画, 就达到了动画循环播放的效果。

   ...
   animation.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        controller.reverse();
      } else if (status == AnimationStatus.dismissed) {
        controller.forward();
      }
    });
    controller.forward();

    ...

回顾一下这个动画绘制调用的顺序就是, window 调用 SchedulerBinding 的_handleBeginFrame 方法, SchedulerBinding 调用 Ticker 的_tick 方法, Ticker 调用 AnimationController 的_tick 的方法, AnimationContoller 通知监听器, 而监听器调用 widget 的 setStatus 方法来调用 build 更新, 最后 build 使用了 Animation 对象当前的值来绘制动画帧。

看到这里会有一个疑惑, 为什么监听器是注册在 Animation 上的, 监听通知反而由 AnimationController 发送?

还是看源码吧。

  Animation<T> animate(Animation<double> parent) {
    return _AnimatedEvaluation<T>(parent, this);
  }

class _AnimatedEvaluation<T> extends Animation<T> with AnimationWithParentMixin<double> {
  _AnimatedEvaluation(this.parent, this._evaluatable);
}

mixin AnimationWithParentMixin<T> {
  Animation<T> get parent;
  /// Listeners can be removed with [removeListener].
  void addListener(VoidCallback listener) => parent.addListener(listener);
}

首先 Animation 对象是由 Tween 的 animate 方法生成的, 它传入了 AnimationController(Animation 的子类) 参数 作为 parent 参数, 然后我们发现返回的 _AnimatedEvaluation<T> 泛型对象 使用 mixin "继承" 了 AnimationWithParentMixin<double>, 最后我们看到 Animation 作为 AnimationWithParentMixin 的"子类"实现的 addListener 方法其实是将监听器注册到 parent 对象上了, 也就是 AnimationController。

总结

本篇文章从简单的例子出发, 并且结合了源码, 分析了 Flutter 动画实现的原理。Flutter 以硬件设备刷新为驱动, 驱使 widget 依据给定的值生成新动画帧, 从而实现了动画效果。

作者简介

瑞恩,铜板街客户端开发工程师,2017年11月加入团队,目前主要负责APP端项目开发。

想要获取更多有关 Flutter 相关内容,请扫描以下二维码关注“铜板街科技”公众号,并在对话框内回复 “Flutter” 关键词即可。

基于 React.js 和 Node.js 的 SSR 实现方案

基础概念

  1. SSR:即服务端渲染(Server Side Render)
    传统的服务端渲染可以使用Java,php 等开发语言来实现,随着 Node.js 和相关前端领域技术的不断进步,前端同学也可以基于此完成独立的服务端渲染。

  2. 过程:浏览器发送请求 -> 服务器运行 react代码生成页面 -> 服务器返回页面 -> 浏览器下载HTML文档 -> 页面准备就绪
    即:当前页面的内容是服务器生成好给到浏览器的。

  1. 对应CSR:即客户端渲染(Client Side Render)
    过程:浏览器发送请求 -> 服务器返回空白 HTML(HTML里包含一个root节点和js文件) -> 浏览器下载js文件 -> 浏览器运行react代码 -> 页面准备就绪
    即:当前页面的内容是js渲染出来

  1. 如何区分页面是否服务端渲染:
    右键点击 -> 显示网页源代码,如果页面上的内容在HTML文档里,是服务端渲染,否则就是客户端渲染。

  2. 对比

  • CSR:首屏渲染时间长,react代码运行在浏览器,消耗的是浏览器的性能
  • SSR:首屏渲染时间短,react代码运行在服务器,消耗的是服务器的性能

为什么要用服务端渲染

  • 首屏加载时间优化,由于SSR是直接返回生成好内容的HTML,而普通的CSR是先返回空白的HTML,再由浏览器动态加载JavaScript脚本并渲染好后页面才有内容;所以SSR首屏加载更快、减少白屏的时间、用户体验更好。

  • SEO (搜索引擎优化),搜索关键词的时候排名,对大多数搜索引擎,不识别JavaScript 内容,只识别 HTML 内容。
    (注:原则上可以不用服务端渲染时最好不用,所以如果只有 SEO 要求,可以用预渲染等技术去替代)

构建一个服务端渲染的项目

(1) 使用 Node.js 作为服务端和客户端的中间层,承担 proxy代理,处理cookie等操作。

(2) hydrate 的使用:在有服务端渲染情况下,使用hydrate代替render,它的作用主要是将相关的事件注水进HTML页面中(即:让React组件的数据随着HTML文档一起传递给浏览器网页),这样可以保持服务端数据和浏览器端一致,避免闪屏,使第一次加载体验更高效流畅。

 ReactDom.hydrate(<App />, document.getElementById('root'));

(3) 服务端代码webpack编译:通常会建一个webpack.server.js文件,除了常规的参数配置外,还需要设置target参数为'node'。

const serverConfig = {
  target: 'node',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, '../dist')
  },
  externals: [nodeExternals()],
  module: {
    rules: [{
      test: /\.js?$/,
      loader: 'babel-loader',
      exclude: [
        path.join(__dirname, './node_modules')
      ]
    }
    ...
   ]
  }
  (此处省略样式打包,代码压缩,运行坏境配置等等...)
  ...
};

(4) 使用react-dom/server下的 renderToString方法在服务器上把各种复杂的组件和代码转化成 HTML 字符串返回到浏览器,并在初始请求时发送标记以加快页面加载速度,并允许搜索引擎抓取页面以实现SEO目的。

const render = (store, routes, req, context) => {
  const content = renderToString((
    <Provider store={store}>
      <StaticRouter location={req.path} context={context}>
        <div>
          {renderRoutes(routes)}
        </div>
      </StaticRouter>
    </Provider>
  ));
  return `
    <html>
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id='root'>${content}</div>
        <script src='/index.js'></script>
      </body>
    </html>
  `;
}
app.get('*', function (req, res) {
  ...
  const html = render(store, routes, req, context);
  res.send(html);
});
      

与renderToString类似功能的还有:
i. renderToStaticMarkup:区别在于renderToStaticMarkup 渲染出的是不带data-reactid的纯HTML,在JavaScript加载完成后因为不认识之前服务端渲染的内容导致重新渲染(可能页面会闪一下)。

ii. renderToNodeStream:将React元素渲染为其初始HTML,返回一个输出HTML字符串的可读流。

iii. renderToStaticNodeStream:与renderToNodeStream此类似,除了这不会创建React在内部使用的额外DOM属性,例如data-reactroot。

(5) 使用redux 承担数据准备,状态维护的职责,通常搭配react-redux, redux-thunk(中间件:发异步请求用到action)使用。(本猿目前使用比较多是就是Redux和Mobx,这里以Redux为例)。
A. 创建store(服务器每次请求都要创建一次,客户端只创建一次):

const reducer = combineReducers({
  home: homeReducer,
  page1: page1Reducer,
  page2: page2Reducer
});

export const getStore = (req) => {
  return createStore(reducer, applyMiddleware(thunk.withExtraArgument(serverAxios(req))));
}

export const getClientStore = () => {
  return createStore(reducer, window.STATE_FROM_SERVER, applyMiddleware(thunk.withExtraArgument(clientAxios)));
}

B. action: 负责把数据从应用传到store,是store数据的唯一来源

export const getData = () => {
  return (dispatch, getState, axiosInstance) => {
    return axiosInstance.get('interfaceUrl/xxx')
      .then((res) => {
        dispatch({
          type: 'HOME_LIST',
          list: res.list
        })
      });
  }
}
      

C. reducer:接收旧的state和action,返回新的state,响应actions并发送到store。

export default (state = { list: [] }, action) => {
  switch(action.type) {
    case 'HOME_LIST':
      return {
        ...state,
        list: action.list
      }
    default:
      return state;
  }
}
export default (state = { list: [] }, action) => {
  switch(action.type) {
    case 'HOME_LIST':
      return {
        ...state,
        list: action.list
      }
    default:
      return state;
  }
}     

D. 使用react-redux的connect,Provider把组件和store连接起来

  • Provider 将之前创建的store作为prop传给Provider
const content = renderToString((
  <Provider store={store}>
    <StaticRouter location={req.path} context={context}>
      <div>
        {renderRoutes(routes)}
      </div>
    </StaticRouter>
  </Provider>
));     
  • connect([mapStateToProps],[mapDispatchToProps],[mergeProps], [options])接收四个参数
    常用的是前两个属性
    mapStateToProps函数允许我们将store中的数据作为props绑定到组件上mapDispatchToProps将action作为props绑定到组件上
 connect(mapStateToProps(),mapDispatchToProps())(MyComponent)

(6) 使用react-router承担路由职责
服务端路由不同于客户端,它是无状态的。React 提供了一个无状态的组件StaticRouter,向StaticRouter传递当前URL,调用ReactDOMServer.renderToString() 就能匹配到路由视图。

服务端

import { StaticRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config'
import routes from './router.js'

<StaticRouter location={req.path} context={{context}}>
{renderRoutes(routes)}
</StaticRouter>     

浏览器端

import { BrowserRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config'
import routes from './router.js'

<BrowserRouter>
  {renderRoutes(routes)}
</BrowserRouter>

当浏览器的地址栏发生变化的时候,前端会去匹配路由视图,同时由于req.path发生变化,服务端匹配到路由视图,这样保持了前后端路由视图的一致,在页面刷新时,仍然可以正常显示当前视图。如果只有浏览器端路由,而且是采用BrowserRouter,当页面地址发生变化后去刷新页面时,由于没有对应的HTML,会导致页面找不到,但是加了服务端路由后,刷新发生时服务端会返回一个完整的html给客户端,页面仍然正常显示。
推荐使用 react-router-config插件,然后如上代码在StaticRouter和BrowserRouter标签的子元素里加renderRoutes(routes):建一个router.js文件

const routes = [{ component: Root,
  routes: [
    { path: '/',
      exact: true,
      component: Home,
      loadData: Home.loadData
    },
    { path: '/child/:id',
      component: Child,
      loadData: Child.loadData
      routes: [
        path: '/child/:id/grand-child',
        component: GrandChild,
        loadData: GrandChild.loadData
      ]
    }
  ]
}];

在浏览器端请求一个地址的时候,server.js 里在实际渲染前可以通过matchRouters 这种方式确定要渲染的内容,调用loaderData函数进行action派发,返回promise->promiseAll->renderToString,最终生成HTML文档返回。

import { matchRoutes } from 'react-router-config'
  const loadBranchData = (location) => {
    const branch = matchRoutes(routes, location.pathname)

    const promises = branch.map(({ route, match }) => {
      return route.loadData
        ? route.loadData(match)
        : Promise.resolve(null)
    })

    return Promise.all(promises)
}

(7) 写组件注意代码同构(即:一套React代码在服务端执行一次,在客户端再执行一次)
由于服务器端绑定事件是无效的,所以服务器返回的只有页面样式(&注水的数据),同时返回JavaScript文件,在浏览器上下载并执行JavaScript时才能把事件绑上,而我们希望这个过程只需编写一次代码,这个时候就会用到同构,服务端渲染出样式,在客户端执行时绑上事件。

优点: 共用前端代码,节省开发时间
弊端: 由于服务器端和浏览器环境差异,会带来一些问题,如document等对象找不到,DOM计算报错,前端渲染和服务端渲染内容不一致等;前端可以做非常复杂的请求合并和延迟处理,但为了同构,所有这些请求都在预先拿到结果才会渲染。

作者简介

朵拉,铜板街前端开发工程师,2015年8月加入团队,目前主要负责运营侧APP端项目开发。

本文主要通过具体的案例讲述了如何基于React.js 和 Node.js 实现SSR,如需获取更多React相关内容,请扫码关注 “铜板街技术” 微信公众号,并在后台回复“React” 或者 “react-native” 获取更多精彩内容。

JavaScript专题系列-防抖和节流

1.前言

一般来说,这一段主要是讲一些知识的大体概况,都不是那么重要的,相当于文章的摘要。但是就是有不同寻常的,比如本文对于防抖以及节流的概念理解就很重要,非常重要。

1.1 出现原因

首先需要指出的是为什么会出现这2种**。

1.由于肉眼只能分辨出一定频率的变化,也就是说一种变化1s内变化1000次和变成60次对人的感官是一样的,同理,可以类推到js代码。在一定时间内,代码执行的次数不一定要非常多。达到一定频率就足够了。因为跑得越多,带来的效果也是一样。

2.客户端的性能问题。众所周知,就目前来说兼容对应前端来说还是相当重要的,而主要的兼容点在于低端机型,所以说我们有必要把js代码的执行次数控制在合理的范围。既能节省浏览器CPU资源,又能让页面浏览更加顺畅,不会因为js的执行而发生卡顿。

以上就是函数节流和函数防抖出现的主要原因。

1.2 概念理解

上面说了那么多,只是为了说明为什么会出现防抖和节流这2种实现,下面再来形象理解一下这两种**的不同之处,很多时候我都会把这两种**混淆,所以这次特意想了很好记住的办法。

1.函数节流 是指一定时间内js方法只跑一次。

节流节流就是节省水流的意思,就想水龙头在流水,我们可以手动让水流(在一定时间内)小一点,但是他会一直在流。

当然还有一个形象的比喻,开源节流,就比如我们这个月(在一定时间内)我们少花一点钱,但是我们每天还是都需要花钱的。

2.函数防抖 只有足够的空闲时间,才执行代码一次。

比如生活中的坐公交,就是一定时间内,如果有人陆续刷卡上车,司机就不会开车。只有别人没刷卡了,司机才开车。(其实只要记住了节流的**就能通过排除法判断节流和防抖了)

2.代码

2.1 防抖

上面的解释都是为了形象生动地说明防抖和节流的**以及区别,现在我们需要从代码层面来进一步探索防抖。

首先写代码之前最重要的事情就是想在脑子里面想这段代码需要实现什么逻辑,下面就是防抖的代码逻辑思路。

你尽管触发事件,但是我一定在事件触发 n 秒后才执行,如果你在一个事件触发的 n 秒内又触发了这个事件,那我就以新的事件的时间为准,n 秒后才执行,总之,就是要等你触发完事件 n 秒内不再触发事件,我才执行!

好了,根据上面的思路我们可以很轻松地写出第一版防抖的代码。

function debounce(func, waitTime) {
  var timeout;
  return function () {
    if (timeout) {
      clearTimeout(timeout);
    }
    timeout = setTimeout(func, waitTime);
  }
}
document.querySelector('#app').onmousemove = debounce(fn, 1000);

上面的一小段代码就是最原始的防抖代码。

可以看到上面这几行代码就用到了闭包的知识,主要的目的就是为了在函数执行后保留timeout这个变量。

想让一个函数执行完后,函数内的某个变量(timer)仍旧保留,就可以使用闭包把要保存的变量在父作用域声明,其他的语句放到子作用域里,并且作为一个function返回。下面的很大实例代码都用到了闭包来解决保留变量的问题。

还有一点也许有小伙伴会有疑惑。为什么这里要返回一个函数呢。其实很好理解,我们可以来看下面的代码

var timeout;
function debounce(func, waitTime) {
  if (timeout) {
    clearTimeout(timeout);
  }
  timeout = setTimeout(func, waitTime);
}
container.onmousemove = debounce(getUserAction, 1000);

我手动删掉了debounce函数里面的return ,然后为了保留timeout,我把它放到了全局变量,这几行代码看起来和上面的很像,但是你可以直接跑一下这段代码,发现debounce只会执行一次!!!

哈哈哈,其实之所以在debounce函数里面返回一个函数,那是因为onmousemove需要的是绑定的函数,我们的测试代码执行一遍后只会返回undefined ,相当于

container.onmousemove = debounce(getUserAction, 1000);
container.onmousemove = undefined;

当然就没有正确绑定事件了。如果从好理解的角度来写,其实也是可以想下面这样绑定的

var timeout;
function debounce(func, waitTime) {
  if (timeout) {
    clearTimeout(timeout);
  }
  timeout = setTimeout(func, waitTime);
}
container.onmousemove = () => {
  debounce(getUserAction, 1000);
}

下面所有方法的道理都是和第一个函数一样的。

但是这一版本的代码我们在fn中打印this以及event对象,发现有点不对。

可以从上图中看到,fn中的this以及event对象,发现并不是希望的,所以我们需要手动把this以及event对象传递给fn函数。于是乎有了下面第二版的防抖函数。

function debounce(func, waitTime) {
  var timeout;
  return function () {
    var context = this,
        args = arguments;
    if (timeout) {
      clearTimeout(timeout);
    }
    timeout = setTimeout(function () {
      func.apply(context, args)
    },  waitTime);
  }
}

其实也就是用了apply函数把this以及event对象传递给fn函数。

2.2 节流

下面让我们继续来看一下节流**的代码逻辑。

使用时间戳,当触发事件的时候,我们取出当前的时间戳,然后减去之前的时间戳(最一开始值设为 0 ),如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于,就不执行。

ok,根据上面的逻辑,我们可以很轻松写出第一版节流函数。

function throttle(func, waitTime) {
    var context,
        args,
        previous = 0;
    return function() {
        var now = + new Date();
            context = this;
            args = arguments;
        if (now - previous > waitTime) {
            func.apply(context, args);
            previous = now;
        }
    }
}

或者我们其实还可以借助定时器来实现节流。

当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行,直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。

function throttle(func, waitTime) {
    var timeout,
        previous = 0;
    return function() {
        context = this;
        args = arguments;
        if (!timeout) {
            timeout = setTimeout(function(){
                timeout = null;
                func.apply(context, args)
            }, waitTime)
        }

    }
}

3. 知识转为技能

2018年,我最大的感悟就是尽量把所学的知识转为技能(来自老姚 [https://juejin.im/post/5c34abc56fb9a049d519ace9](2018年收获5条认知,条条振聋发聩 | 掘金年度征文))

知识是可以学到的,但是技能只能习得。

上面两部分我们都是在学防抖和节流出现的原因,对应的概念以及实现的**逻辑,这些都是知识,现在就让我们一起把学到的知识转为技能,争取成为自己项目的一部分吧。

对于像防抖和节流这种工具性质的函数,我们大可以把他们放在公共文件里面,然后在需要的地方直接调用就可以了。

防抖和节流最大的核心用处在于优化代码性能,可以用在很多地方,比如输入框的验证,图片懒加载,各种频繁触发的DOM事件等等。

下面是我自己模拟写了一个百度搜索的按钮精灵,图一是没有用防抖搜索 我是 这个关键词发现发起了N多次请求,然后改了一行代码加入了防抖,请求的情况就变成了图二。效果显而易见。

this.debounce(this.getData, 1000)();

作者简介

刀锋,铜板街前端开发工程师,2018年4月加入团队,目前主要负责运营侧APP端项目开发。

更多精彩内容,请扫码关注 “铜板街科技” 微信公众号。

分布式任务调度

前言

任务调度可以说是所有系统都必须要依赖的一个中间系统,主要负责触发一些需要定时执行的任务。传统的非分布式系统中,只需要在应用内部内置一些定时任务框架,比如 spring 整合 quartz,就可以完成一些定时任务工作。在分布式系统中,这样做的话,就会面临任务重复执行的问题(多台服务器都会触发)。另外,随着公司项目的增加,也需要一个统一的任务管理中心来解决任务配置混乱的问题。

公司的任务调度系统经历了两个版本的开发,1.0 版本始于 2013 年,主要解决当时各个系统任务配置不统一,任务管理混乱的问题,1.0 版本提供了一个统一的任务管理平台。2.0 版本主要解决 1.0 版本存在的单点问题。

任务调度系统 1.0

1.0 版本的任务调度系统架构如下图1:由一台服务器负责管理所有需要执行的任务,任务的管理与触发动作都由该机器来完成,通过内置的 quartz 框架,来完成定时任务的触发,在配置任务的时候,指定客户端 ip 与端口,任务触发的时候,根据配置的路由信息,通过 http 消息传递的方式,完成任务指令的下达。

这里存在一个比较严重的问题,任务调度服务只能部署一台,所以该服务成为了一个单点,一旦宕机或出现其他什么问题,就会导致所有任务无法执行。

任务调度系统 2.0

2.0 版本主要为了解决 1.0 版本存在的单点问题,即将任务调度服务端调整为分布式系统,改造后的项目结构如下图2:需要改造调度服务端,使其能够支持多台服务器同时存在。这带来一个问题,多台调度服务器中,只能有一台服务器允许发送任务(如果所有服务器都发任务的话,会导致一个任务在一个触发时间被触发多次),所以需要一个 Leader,只有 Leader 才有下达任务执行命令的权限。其他非 Leader 服务器这里称为 Flower,Flower 机器没有执行任务的权限,但是一旦 Leader 挂掉,需要从所有 Flower 机器中,重新选举出一个新的 Leader,继续执行后续任务。

另外一个问题是,如果某一个应用,比如说资产中心系统,我们有 A,B,C 三台机器,在凌晨12点要执行一个任务, 调度系统要如何发现 A,B,C 三台机器 ?如果 B 机器在12点的时候,恰好宕机,调度系统又要如何识别出来? 其实就是一个服务发现的问题。

群首选举

当多台任务调度服务器同时存在时,如何选举一个 Leader,是面临的第一个问题。比较成熟的算法如:基于 paxos 一致性算法的 zookeeper、Raft 一致性算法等等都可以实现。在该项目中,采用的是一个简单的办法,基于 zookeeper 的临时(ephemeral)节点功能。

zookeeper 的节点分为2类,持久节点和临时节点,持久节点需要手动 delete 才会被删除,临时节点则不需要这样,当创建该临时节点的客户端崩溃或者与 zookeeper 的连接断开,则该客户端创建的所有临时节点都会被删除。

zookeeper 另外一个功能:监视点。某一个连接到 zookeeper 的客户端,可以监视一个 zookeeper 节点的变化,比如 exists 监视操作,会监视节点是否存在,如果节点被删除,那么客户端将会收到一条通知。

基于临时节点和监视点这两个特性,可以采用 zookeeper 实现一个简单的群首选举功能:每一台任务调度服务器启动的时候,都尝试创建群首选举节点,并在节点中写入当前机器 IP,如果节点创建成功,则当前机器为 Leader。如果节点已经存在,检查节点内容,如果数据不等于当前机器 IP,则监视该节点,一旦节点被删除,则重新尝试创建群首选举节点。

使用 zookeeper 临时节点做群首选举的缺陷:有的时候,即使某一台任务调度服务器能够正常连接到 zookeeper,也并不表示该机器是可用的,比如一个极端场景,服务器无法连接到数据库,但是可以正常连接到 zookeeper,这个时候,基于 zookeeper 的临时节点功能,是无法剥离这一台异常机器的(但是可以通过一些手段处理这个问题,比如本地开发一套自检程序,检测所有可能导致服务不可用的异常,如数据库异常等等,一旦自检程序失败,则不再发送 zookeeper 心跳包,从而剥离异常机器)。

脑裂问题

群首选举中,我们选举出了一个 Leader,我们也希望系统中只有一个 Leader,但是在一些特殊情况下,会出现多个 Leader 同时发号施令的现象,即脑裂问题。

有以下几种情况会导致出现脑裂问题:

  • zookeeper 本身集群配置有问题,导致 zookeeper 本身脑裂了。

  • 同一个集群里面的多个服务器的 zookeeper 配置不一致。

  • 同一个 IP,部署了多台任务调度服务器。

  • 任务调度服务主备切换时候的瞬时脑裂问题。

其中前三个属于配置问题,应用程序没有办法解决。

第四个主备切换时候的瞬时脑裂,具体场景如下图4:

现象:

  • A 先连接上了 zookeeper,并成功创建 /leader 节点。

  • t1: A 与 zookeeper 失去连接, 此时 A 依然认为自己是 Leader。

  • t2: zookeeper 发现 A 超时,所以删除 A 的所有临时节点,包括 /leader 节点。由于此时B 正在监视 /leader 节点,故 zookeeper 在删除该节点的同时,也会通知 B 服务器,B 收到通知之后立即尝试创建 /leader 节点。

  • t3: B 创建 /leader 节点成功,当选为 Leader。

  • t4: A 网络恢复,重新访问 ZK 时,发现失去 Leader 权限,更新本地 Leader-Flag = false。

可以看出

如果 A 机器,在 T1 发现无法连接到 zookeeper 之后,如果不失效本地 Leader 权限,那么,在 T3-T4 时间段内,就有可能会出现脑裂现象,即 A、B 两台机器同时成为了Leader。(这里 A 发现超时之后,之所以不立即失效 Leader 权限,是出于系统可用性的一个权衡:尽可能减少没有 Leader 的时间。因为一旦 A 发现超时,马上就失效Leader 权限的话,会导致 T1-T3 这一段时间,没有任何一个 Leader 存在,相比于出现2个 Leader 来说,没有 Leader 的影响更严重)。

脑裂出现的原因很多,一些配置性问题导致的脑裂,无法通过程序去解决,脑裂现象无法完全避免,必须通过其他方式保障系统在脑裂情况下的数据一致性。

系统采用的是基于数据库的唯一主键约束:任务每一次触发,都会有一个触发时间(Schedule Time),该时间精确到秒,如果对于同一个任务,每一次触发执行的时候,在数据库插入一条任务执行流水,该流水表使用任务触发时间 + 任务 Id 来作为唯一主键,即可避免脑裂时带来的影响。两台服务器如果同时触发任务,且都具有 Leader 权限,此时,其中一台服务器会因为数据库唯一主键约束,导致任务执行失败,从而取消执行。(由于在分布式环境下,多台 Legends 服务器时钟可能会有一些误差, 如果任务触发时间过短,还是有可能出现并发执行的问题:A 机器执行01秒的任务,B 机器执行02秒的任务。所以不建议任务的触发时间过短)。

发现存活的客户端

服务端发送任务之前,需要知道有哪些服务器是存活的,具体实现方式如下:

应用服务器客户端启动成功之后,会向 zookeeper 注册本机 IP(即创建临时节点)

任务调度服务器通过监视 /clients 节点的子节点数据,来发现有哪些机器是可用(这里通过监视点来永久监视客户端节点的变化情况)。

当该系统有任务需要发送的时候,调度服务器只需要查询本地缓存数据,就可以知道有哪些机器是存活状态,之后根据任务配置的策略,发送任务到 zookeeper 中指定客户端的待执行任务列表中即可。

任务执行流程

任务触发的具体流程如下图6:

流程说明:

  1. Quartz 框架触发任务执行 (如果发现当前机器非 Leader,则直接结束)。

  2. 服务器查询本地缓存数据,找到对应的应用的存活服务器列表,并根据配置的任务触发策略,选取可以执行的客户端。

  3. 向 ZK 中,需要执行任务的客户端所对应的任务分配节点(/assign)写入任务信息 。

  4. 应用服务器的发现分配的任务,获取任务并执行任务。

这里存在一个问题:在任务数据发送到 zk 之后,如果存活的客户端立即死亡要如何处理?因为任务调度服务器一直在监视客户端注册节点的变化,一旦一台应用服务器死亡,任务调度服务器会收到一条客户端死亡的通知,此时,可以检测该客户端对应的任务分配节点下,是否有已经分配,但是还未来得及执行的任务,如果有,则删除任务节点,回收未处理的任务,再重新将该任务分配到其他存活服务器执行即可(这里客户端执行任务的操作是,先删除 zookeeper 中的任务节点,之后再执行任务,如果一个任务节点已经被删除,则表示该任务已经成功下达,由于删除操作只有一个 zk 客户端能够执行成功,故任务要么被服务端回收,要么被客户端执行)。

这个问题引申的还有一些其他问题,比如任务调度服务发现应用服务器死亡,回收该应用服务器未执行的任务之后,突然断电或者失去了 Leader 权限,导致内存数据丢失,此时会造成任务漏发现象。

任务变更的信息流

当一个用户在任务调度服务器后台修改或新增一个任务时,任务数据需要同步到所有的任务调度服务器,由于任务数据保存在 DB,ZK 以及每个调度服务器的内存中,任务数据的一致性,是任务更新时要处理的主要问题。

任务数据的更新顺序如图7所示:

  • 用户连接到集群中的某一台 Server, 对任务数据做修改,提交。

  • Server 接收到请求之后,先更新 DB 数据 ( version + 1 )。

  • 异步提交 ZK 数据变动( zookeeper 数据更新也是强制乐观锁更新的模式) 。

  • 所有 Server 中的 JOB Watcher 监控到 ZK 中的任务 数据发生了变化,重新查询 ZK 并更新本地 Quartz 中的内存数据。

由于 2,3,4 三步更新,都采用了乐观锁更新的模式,且所有任务数据的变动,都是按照一致的更新顺序操作,所以解决了并发更新的问题。另外这里之所以要采用异步更新zookeeper 的原因,是由于 zookeeper 客户端程序是单线程模式,任何同步的代码,都会阻塞所有的异步调用,从而降低整个系统的性能,另外也有 SessionExpired 的风险( zookeeper 一个重量级的异常)。

三步操作,任何一步都有可能失败,但是又无法做到强一致性,所以只能采用最终一致性来解决数据不一致的问题。采用的方案是用一个内置线程,查询5分钟内有过更新的任务数据,之后对三处数据做一个比对验证,以使数据达到一致。

另外这里也可以调整为:zookeeper 不存储任务数据,只在任务数据有更新的时候,发送给所有服务器任务有更新的通知即可,调度服务器接受到通知之后,直接查询 DB 数据即可,数据只保存在 DB 与各个调度服务器。

实践总结

任务调度系统 1.0 版本解决了公司的任务管理混乱的问题,提供了一个统一的任务管理平台。2.0 版本解决了 1.0 版本存在的单点问题,任务的配置也相对更简单,但是有一点过度依赖 zookeeper,编码的时候应用层与会话层也没有做好解耦,总的来说还是有很多可以优化的地方。

作者简介

卢云,铜板街资金端后台开发工程师,2015年12月加入团队,目前主要负责资金团队后端的项目开发。

更多精彩内容,请扫码关注 “铜板街科技” 微信公众号。

Fescar锁和隔离级别的理解

Fescar全局锁的理解

前几天夜里,我老大发我一篇文章说阿里的GTS开源了。 因为一直对分布式事务比较感兴趣,立马pull了代码,进行阅读。基本的原理,实现方案我就不一一细化了,详细见官方文档(写的很棒,点赞)。

在fescar的社区,大家比较关注的是通过fescar回滚到before快照前,别的线程假如更新了数据,且业务走完了,那么恢复的这个快照不就是脏数据了么。 很显然,这种情况在fescar中是不被允许的。

那么fescar是如何做的呢?

我们先简单了解一下fescar的设计原理

那些一上来就喜欢看源码的同学,一定不要错过这么官方的图文介绍,看完再读源码事半功倍。

Fescar官方介绍

了解完Fescar的基本原理,我们重点关注下Fescar的全局排他锁

Fescar设计了一个全局的排他锁,来保证事务间的 写隔离。

关于隔离性:(这是Fescar官方给的一段话)

全局事务的隔离性是建立在分支事务的本地隔离级别基础之上的。

在数据库本地隔离级别 读已提交或以上 的前提下,Fescar 设计了由事务协调器维护的 全局写排他锁,来保证事务间的 写隔离,将 全局事务默认定义在 读未提交 的隔离级别上。

我们对隔离级别的共识是:绝大部分应用在读已提交的隔离级别下工作是没有问题的。而实际上,这当中又有绝大多数的应用场景,实际上工作在读未提交的隔离级别下同样没有问题。

在极端场景下,应用如果需要达到全局的读已提交,Fescar也提供了相应的机制来达到目的。 默认,Fescar 是工作在 读无提交 的隔离级别下,保证绝大多数场景的高效性。

我的解读

本地事务【读已提交】,fescar全局事务【读未提交】。这是这段话的核心。 我理解的这段话中fescar全局事务读未提交,并不是说本地事务的db数据没有正常提交,而是指全局事务二阶段commit|rollback未真正处理完(即未释放全局锁)。

总结来说:全局未提交但是本地已提交的数据,对其他全局事务是可见的【当然在本地事务提交后,本地事务提交前,隔离级别是本地事务的管辖范围】

for example 产品份额有5W,A用户购买了2万,份额branch一阶段完毕(本地事务份额已经扣除commit),但是在下单的时候异常了。 因为本地事务读已提交,这时候fescar允许业务访问该条数据,3W,在A用户的份额branch未回滚成功前,对其他用户可见。 但是其他用户并不能买该产品,必须等到产品份额回滚到5万,其他用户才可以操作产品数据。

所以看了这个例子 真的有必要做到全局事务读已提交么?

我们先来看一下Fescar的全局锁的做法

Fescar一阶段
  1. 本地(Branch)在向TC注册的时候,把本地事务需要修改的数据table+pks提交到server端申请锁,拿到全局锁后,才能提交本地事务

  2. 全局锁的结构:resourceId + table + pks

  3. 锁是存在server端 branchSession中

Fescar二阶段

一阶段本地事务提交,db的锁释放了(for update锁),但是全局锁继续保持, 直到二阶段决议(注意释放锁的顺序):

  1. 提交:TC 释放锁,通知branch提交后 (rm端异步处理)

  2. 回滚:TC 通知branch回滚后,释放锁(rm端同步处理 执行undo_log)

Fescar如何保障锁的高效?

大家自己先思考下,最后给大家仔细解读官方的demo,并分析fescar的性能问题。

Fescar目前开源版本全局锁的实现

大家有兴趣自己阅读:com.alibaba.fescar.server.lock.DefaultLockManagerImpl

官方的图实在是做的太漂亮了,clone一份解读 TC TM RM 以及全局锁的获取和释放动作发生点

分支事务如何工作?关注全局锁的获取和释放 特别是二阶段commit和rollback全局锁释放的顺序

Fescar中 RM TM TC如何工作的?

看了这两张图,大家应该对fescar是如何工作的应该有一个大致的了解了。☺

  1. 全局锁的获取

  2. tm tc rm之间如何通信工作

  3. 隔离级别问题的思考

最后我们来解读一遍官方的demo

branch1:update storage

tbl set count = count - ? where commodity
code = ?
branch2:update account

tbl set money = money - ? where user
id = ?
branch3:insert into order

tbl (user
id, commodity_code, count, money) values (?, ?, ?, ?)

  1. 线程A:执行branch1(pk:55),执行branch2的时候发现没钱了,扔了一个异常,那么势必需要回滚branch1的份额。
  • TM通知TC开始回滚branch1份额中
  1. 线程B:执行branch1(pk:55)
  • 如果线程A中branch1(pk:55)已经回滚成功了,那么B线程可以正常拿到锁走下去

  • 如果线程A中branch1还未回滚(resourceId+table+pk锁未释放)。当线程B发起branch1向server发起申请锁,会直接失败。

Fescar全局锁简单总结:操作一条记录的分支事务,必须等待这条记录的前一个分支事务执行结束(具体commit rollback情况分析如下),才能持有锁。

其实相比XA的锁,fescar在每个分支事务的一阶段结束后都释放了db的锁,所以fescar的性能瓶颈应该在于二阶段的执行速度(释放锁的快慢)

因为分布式事务在执行事务编排前,一般会校验业务的正确性,所以发生回滚的概率相对较低,所以先考虑二阶段commit操作。

  1. Commit场景分析:

TM通知server进行commit,server立马释 branch的锁,然后再逐个通知RM提交 消耗:1 rpc操作,(branch删除undo_log放在异步队列里面做)

  1. Rollback场景分析:

TM通知server进行rollback,server通知RM回滚后立马释放 branch的锁。 消耗:1 + N的rpc操作 + N的回滚sql操作

所以总的来看fescar在commit的释放全局锁还是非常高效的。

思考

  1. server支持多台机器部署,应该如何改造?

全局锁的问题,锁改造; 全局事务向server0申请的,Branch1发到server1,branch2发到server2的问题,多机器恢复的情况,TC的改造

  1. 全局锁在Fescar中更新确实是没有问题的,但是如果就是业务方需要手动调整DB数据呢 ?

大胆猜测,依赖Fescar写了一个管理平台 用来执行sql的。哈哈

  1. 隔离级别的思考

Fescar默认工作在,本地事务读已提交,全局事务读未提交。 是否存在全局事务必须工作在【读已提交】级别而不能工作在【读未提交】的业务场景呢? 大家大胆脑洞 这个问题值得探讨。

  1. Fescar的文档中说,是支持全局事务读已提交的,那么fescar是如何实现的呢?

感兴趣的同学可以试着读一下

com.alibaba.fescar.rm.datasource.exec.SelectForUpdateExecutor

源码核心类

大家想读源码的话,可以重点关注一下几个类。有问题一起探讨。

TM相关

com.alibaba.fescar.tm.api.TransactionalTemplate

RM相关

com.alibaba.fescar.rm.datasource.exec.SelectForUpdateExecutor
com.alibaba.fescar.rm.datasource.ConnectionProxy
com.alibaba.fescar.rm.datasource.exec.AbstractDMLBaseExecutor
com.alibaba.fescar.rm.RMHandlerAT

TC相关

com.alibaba.fescar.server.coordinator.DefaultCoordinator
com.alibaba.fescar.server.coordinator.DefaultCore
com.alibaba.fescar.server.lock.DefaultLockManagerImpl

作者简介
雨人,铜板街交易团队研发工程师,2016年5月加入铜板街,目前主要负责资金端项目的开发。

                  更多精彩内容,请扫码关注 “铜板街科技” 微信公众号。 

React 之 Refs 详解

在介绍 Refs 之前,我们先来了解两个概念:受控组件 和 不受控组件。

受控组件

在HTML中,表单元素(如 input、textarea、select)之类的表单元素通常可以自己维护state,并根据用户的输入进行更新。而在React中,可变状态(mutable state)通常保存在组件的 state 属性中,并且只能通过 setState()来更新。
在此,我们将 React的state作为唯一的数据源,通过渲染表单的React组件来控制用户输入过程中表单发送的操作。
这个“被React通过此种方式控制取值的表单输入元素”被成为受控组件

不受控制组件

从字面意思来理解:不被React组件控制的组件。在受控制组件中,表单数据由 React组件处理。其替代方案是不受控制组件,其中表单数据由DOM本身处理。文件输入标签就是一个典型的不受控制组件,它的值只能由用户设置,通过DOM自身提供的一些特性来获取。

受控组件和不受控组件最大的区别就是前者自身维护的状态值变化,可以配合自身的change事件,很容易进行修改或者校验用户的输入。

在React中 因为 Refs的出现使得 不受控制组件自身状态值的维护变得容易了许多,接下来我们就重点介绍一下 Refs的使用方式。

什么是Refs

Refs 是一个 获取 DOM节点或 React元素实例的工具。在 React 中 Refs 提供了一种方式,允许用户访问DOM 节点或者在render方法中创建的React元素。

在 React单项数据流中,props是父子组件交互的唯一方式。要修改一个子组件,需要通过的新的props来重新渲染。
但是在某些情况下,需要在数据流之外强制修改子组件。被修改的子组件可能是一个React组件实例,也可能是一个DOM元素。对于这两种情况,React 都通过 Refs的使用提供了具体的解决方案。

使用场景

refs 通常适合在一下场景中使用:

  1. 对DOM 元素焦点的控制、内容选择或者媒体播放;
  2. 通过对DOM元素控制,触发动画特效;
  3. 通第三方DOM库的集成。

避免使用 refs 去做任何可以通过声明式实现来完成的事情。例如,避免在Dialog、Loading、Alert等组件内部暴露 open(), show(), hide(),close()等方法,最好通过 isXX属性的方式来控制。

使用方式

关于refs的使用有两种方式: 1)通过 React.createRef() API【在React 16.3版本之后引入了】;2)在较早的版本中,我们推荐使用 回调形式的refs。

React.createRef()
class TestComp extends React.Component {
  constructor(props) {
    super(props);
    this.tRef = React.createRef();
  }
  render() {
    return (
      <div ref={ this.tRef }></div>
    )
  }
}

以上代码 创建了一个实例属性 this.tRef, 并将其
传递给 DOM元素 div。后续对该节点的引用就可以在ref的 current属性中访问。ref的值根据节点类型的不同结果也不同:

  1. 当ref属性用于普通 HTML 元素时,构造函数中使用 React.createRef() 创建的 ref 接收底层 DOM 元素作为其 current 属性。
class TestComp extends React.Component {
  constructor(props) {
    super(props);
    // 创建一个 ref 来存储 DOM元素 input
    this.textInput = React.createRef();
    this.focusEvent = this.focusEvent.bind(this);
  }
  focusEvent() {
    // 直接通过原生API访问输入框获取焦点事件
    this.textInput.current.focus();
  }
  render() {
    return (
      <div>
        <input type="text" ref={this.textInput} />
        <input type="button" value="获取文本框焦点事件" onClick={this.focusEvent}/>
      </div>
    );
  }
}
  1. 当 ref 属性用于自定义 class 组件时,ref 对象接收组件的挂载实例作为其 current 属性。
class ParentComp extends React.Component {
  constructor(props) {
    super(props);
    // 创建ref 指向 ChildrenComp 组件实例
    this.textInput = React.createRef();
  }

  componentDidMount() {
    // 调用子组件 focusTextInput方法 触发子组件内部 文本框获取焦点事件
    this.textInput.current.focusTextInput();
  }

  render() {
    return (
      <ChildrenComp ref={ this.textInput } />
    );
  }
}
class ChildrenComp extends React.Component {
  constructor(props) {
    super(props);
    this.inputRef = React.createRef();
  }
  focusTextInput() {
    this.inputRef.current.focus();
  }
  render(){
    return(
      <div>
        <input type='text' value='父组件通过focusTextInput()方法控制获取焦点事件' ref={ this.inputRef }/>
      </div>
    )
  }
}
  1. 不能在函数组件上使用 ref 属性,因为他们没有实例。
回调 Refs

React 也支持另外一种使用 refs的方式成为 “回调 refs”,可以帮助我们更精准的控制何时 refs被设置和解除。
这个回调函数中接受 React 组件实例或 HTML DOM 元素作为参数,以使它们能在其他地方被存储和访问。

class TestComp extends React.Component {
  constructor(props) {
    super(props);
    this.textInput = null;
    // 使用'ref'的回调函数将 text输入框DOM节点的引用绑定到 React实例 this.textInput上
    this.inputRef = element => {
      this.textInput = element;
    }
    this.focus = () => {
      if (this.textInput) {
        this.textInput.focus();
      }
    }
  }
  componentDidMount() {
    this.focus();
  }
  render() {
    return (
      <div>
        <input type='text' ref={ this.inputRef } />
      </div>
    );
  }
}

React 将在组件挂载时会调用 ref 回调函数并传入DOM 元素,当卸载时调用它并传入 null。
在 componentDidMount 或 componentDidUpdate 触发前,React 会保证 refs 一定是最新的。
在类组件中,通常父组件 把它的refs回调函数 通过props的形式传递给子组件,同时子组件把相同的函数作为特殊的 ref属性
传递给对应的 DOM 元素。

如果 ref 回调函数是以内联函数的方式定义的,在更新过程中它会被执行两次,第一次传入参数 null,然后第二次会传入参数 DOM 元素。这是因为在每次渲染时会创建一个新的函数实例,所以 React 清空旧的 ref 并且设置新的。通过将 ref 的回调函数定义成 class 的绑定函数的方式可以避免上述问题,但是大多数情况下它是无关紧要的。

class TestComp extends React.Component {
  constructor(props) {
    super(props);
    this.textInput = null;
    // 初始化 flag 值为 init
    this.state = {
      flag: 'init'
    }
    this.focus = () => {
      if (this.textInput) {
        this.textInput.focus();
      }
    }
  }
  componentDidMount() {
    this.focus();
    // 当执行完 render 首次渲染之后,更新状态 flag 值 为 update
    this.setState({
      flag: 'update'
    });
  }
  render() {
    return (
      <div>
      {/* 通过内联回调形式定义 ref  */}
      <input type='text' value={this.state.flag} ref={(element) => {
        console.log('element', element); // 将传入的 element 输出控制台
        this.textInput = element;
      }} />
      </div>
    )
  }
}

过时 API:String 类型的Refs

如果你目前还在使用 this.refs.textInput 这种方式访问refs,官方建议使用 回调函数 或者 createRef API的方式来替换。

如何将DOM 通过Refs 暴露给父组件

在极少数情况下,我们可能希望在父组件中引用子节点的 DOM 节点(官方不建议这样操作,因为它会打破组件的封装),用户触发焦点或者测量子DOM
节点的大小或者位置。虽然我们可以通过向子组件添加 ref的方式来解决,但这并不是一个理想的解决方案,因为我们只能获取组件实例而不是 DOM节点。并且它还在函数组件上无效。

在react 16.3 或者更高版本中,我们推荐使用 ref 转发的方式来实现以上操作。

ref 转发使得组件可以像暴露自己的 ref一样暴露子组件的 ref。

Ref forwarding is a technique for automatically passing a ref through a component to one of its children. This is typically not necessary for most components in the application. However, it can be useful for some kinds of components, especially in reusable component libraries.

Ref forwarding 是一种自动将ref 通过组件传递给其子节点的技术。下面我们通过具体的案例来演示一下效果。

const ref = React.createRef();
const BtnComp = React.forwardRef((props, ref) => {
  return (
    <div>
      <button ref={ref} className='btn'>
        { props.children }
      </button>
    </div>
  )
});

class TestComp extends React.Component {
  clickEvent() {
    if (ref && ref.current) {
      ref.current.addEventListener('click', () => {
        console.log('hello click!')
      });
    }
  }
  componentDidMount() {
    console.log('当前按钮的class为:', ref.current.className); // btn
    this.clickEvent(); // hello click!
  }
  render() {
    return (
      <div>
        <BtnComp ref={ref}>点击我</BtnComp>
      </div>
    );
  }
}

上述案例,使用的组件BtnComp 可以获取对底层 button DOM 节点的引用并在必要时对其进行操作,就像正常的HTML元素 button直接使用DOM一样。

注意事项

第二个ref参数仅在使用React.forwardRef 回调 定义组件时存在。常规函数或类组件不接收ref参数,并且在props中也不提供ref。

Ref转发不仅限于DOM组件。您也可以将refs转发给类组件实例。

高阶组件中的refs

高阶组件(HOC)是React中用于重用组件逻辑的高级技术,高阶组件是一个获取组件并返回新组件的函数。下面我们通过具体的案例来看一下refs如何在高阶组件钟正常使用。

// 记录状态值变更操作
function logProps(Comp) {
  class LogProps extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('old props:', prevProps);
      console.log('new props:', this.props);
    }
    render() {
      const { forwardedRef, ...rest } = this.props;
      return <Comp ref={ forwardedRef } {...rest} />;
    }
  }
  return React.forwardRef((props, ref) => {
    return <LogProps { ...props } forwardedRef={ ref } />;
  });
}

// 子组件
const BtnComp = React.forwardRef((props, ref) => {
  return (
    <div>
      <button ref={ref} className='btn'>
        { props.children }
      </button>
    </div>
  )
});

// 被logProps包装后返回的新子组件
const NewBtnComp = logProps(BtnComp);


class TestComp extends React.Component {
  constructor(props) {
    super(props);
    this.btnRef = React.createRef();

    this.state = {
      value: '初始化'
    }
  }

  componentDidMount() {
    console.log('ref', this.btnRef);
    console.log('ref', this.btnRef.current.className);
    this.btnRef.current.classList.add('cancel'); // 给BtnComp中的button添加一个class
    this.btnRef.current.focus(); // focus到button元素上
    setTimeout(() => {
      this.setState({
        value: '更新'
      });
    }, 10000);
  }

  render() {
    return (
      <NewBtnComp ref={this.btnRef}>{this.state.value}</NewBtnComp>
    );
  }
}

最终的效果图如下:

作者:空闻

微服务架构下分布式事务解决方案-hoop(一)

前言

数据库事务( 简称:事务,Transaction )是指数据库执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。

事务拥有以下四个特性,习惯上被称为 ACID 特性:

  • 原子性(Atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。

  • 一致性(Consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态是指数据库中的数据应满足完整性约束。除此之外,一致性还有另外一层语义,就是事务的中间状态不能被观察到(这层语义也有说应该属于原子性)。

  • 隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行,如同只有这一个操作在被数据库所执行一样。

  • 持久性(Durability):已被提交的事务对数据库的修改应该永久保存在数据库中。在事务结束时,此操作将不可逆转。

什么是分布式事务 ?

分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。

先来了解一下广为人知的 CAP 和 BASE 理论

分布式事务

常见解决方案

二阶段协议,三阶段协议,MQ 事务消息,补偿,Saga,TCC等。本篇我们重点分享下TCC 的实现。

什么是 TCC ?

  1. Try:完成所有业务检查,预留必须的业务资源,保证幂等性。

  2. Confirm:真正执行的业务逻辑,不作任何业务检查,只使用 Try 阶段预留的业务资源。因此,只要 Try 操作成功,Confirm 必须能成功。另外,Confirm 操作需满足幂等性。

  3. Cancel:释放 Try 阶段预留的业务资源。同样的,Cancel 操作也需要满足幂等性。

TCC 分布式事务模型包括三部分:

  1. 主业务服务(全局事务):主业务服务为整个业务活动的发起方,服务的编排者,负责发起并完成整个业务活动。

  2. 从业务服务(分支事务):从业务服务是整个业务活动的参与方,负责提供 TCC 业务操作,实现初步操作(Try)、确认操作(Confirm)、取消操作(Cancel)三个接口,供主业务服务调用。

  3. 业务活动管理器(事务管理器):业务活动管理器管理控制整个业务活动,包括记录维护 TCC 全局事务的事务状态和每个从业务服务的子事务状态,并在业务活动提交时调用所有从业务服务的 Confirm 操作,在业务活动取消时调用所有从业务服务的 Cancel 操作。

在设计一个分布式事务组件中,应该考虑哪些问题 ?

  • 任何机器断电,crash怎么办?

  • 网络节点问题如何处理?

  • 什么时候提交|回滚全局事务?

走进 Hoop

了解 Hoop 之前,我简单先给大家介绍一些名词解释,默认约束

名词解释:

  • GlobalTransaction:全局事务(跟随主业务服务)

  • BranchTransaction:分支事务(跟随从业务服务)

  • GlobalTransactionStateConfirm:全局事务状态确认器【恢复事务需要】

约定俗成

  • 全局事务执行过程中没有任何异常,commit 全局事务

  • 全局事务方法中抛出异常(非指定异常),直接回滚掉抛出异常前所有执行的分支事务,全局事务回滚。

  • hoop 推荐将网络超时的异常配置到 [delayHandleStep2Exceptions] 碰到这类异常,hoop 需要事务状态翻查器来决定事务提交|回滚。

  • hoop 推荐一定要为每一个全局事务配置一个 [GlobalTransactionStateConfirm] 以免异常情况下,错误的回滚掉了本该提交的事务。

Hoop 核心角色

  • TransactionInterceptor(全局事务拦截器):管理全局事务,推进事务生命周期

  • CoordinatorInterceptor(分支事务协调者拦截器):协调分支事务和全局事务

  • HoopRecoverBootStrap(恢复启动器):管理,恢复异常事务

  • AbstractHoopStorageBootStrap(事务记录存储引擎):存储全局事务和分支事务记录。

为什么 Hoop 高性能高吞吐量 ?

  1. 有很多 tcc 的文章中会常常提到 TransactionSynchronizationAdapter,用数据库的事务来推动二阶段执行。Hoop 完全摒弃了这一点,原因在于如果使用数据库的事务来推动分布式事务的二阶段,在T阶段分支事务较多的场景下,长事务会给 db 带来很大的资源消耗,会成为性能瓶颈。Hoop 所有执行节点都不会锁定 db 资源,没有长事务。而且 hoop目前提供了mysql 的存储引擎,支持使用者重写事务记录的存储引擎,redis,mongodb的引擎正在实现中。

  2. Hoop 的恢复支持应用的所有机器并行,单台机器并发恢复,而且很好的控制了机器的资源。

  3. Hoop 支持二阶段的异步提交。

下面是 Hoop 写一串伪代码,卢云(招行卡)给小强(建行卡)转账100元

  • example1:A.doTry 成功,B.doTry 成功,hoop 自动提交 A.confirm B.confirm

  • example2:A.doTry 成功,B.doTry 失败,hoop 自动回滚 A.cancel B.cancel

  • example3:A.doTry 失败,hoop 自动执行 A.cancel

Hoop 异常情况分析

Hoop 的异常事务恢复

  • 单个应用,所有部署的实例都可以并行恢复异常全局事务,提高系统吞吐量,节约机器资源。

  • 事务恢复器主要是通过事务翻查器来推进全局事务的状态。

总结

TCC 分布式事务模型的业务实现特性决定了其可以跨 DB、跨服务实现资源管理,将对不同的 DB 访问、不同的业务操作通过 TCC 模型协调为一个原子操作,解决了分布式应用架构场景下的事务问题。TCC 模型也可以根据业务需要,做一些定制化的功能,比如交易异步化实现削峰填谷等。但是,业务接入 TCC 模型需要拆分业务逻辑成两个阶段,并实现 Try、Confirm、Cancel 三个接口,定制化程度高,开发成本高。

使用场景:由于从业务服务是同步调用,其结果会影响到主业务服务的决策,因此通用型 TCC 分布式事务解决方案适用于执行时间确定且较短的业务,比如互联网金融企业最核心的三个服务:交易、支付、账务。

后续给大家分享 hoop 的整体架构以及使用。

作者简介

雨人,铜板街交易团队研发工程师,2016 年 5 月加入团队,目前主要负责资金端项目的开发。

更多精彩内容,请扫码关注 “铜板街科技” 微信公众号。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.