Java 等分数组

将一个数组分割成相等的数组。
如将1234567等分以3个为一组等分,结果为[123]、[456]、[7]

实现算法如下

 /**
     * 等分数组
     * @param source 来源
     * @param size 切分数量
     * @param <T> 类型
     * @return 等分后的结果
     */
    public static <T> List<List<T>> divide(List<T> source, int size) {
        if (size <= 0) {
            throw new IllegalArgumentException("等分大小必须大于0");
        }
        if (source == null || source.size() <= 0) {
            return null;
        }
        List<List<T>> result = new ArrayList<>();
        int cursor = 0;
        int total = source.size();
        while (cursor < total) {
            int index = Math.min(cursor + size, total);
            List<T> item = source.subList(cursor, index);
            result.add(item);
            cursor = index;
        }
        return result;
    }

Android中使用动态代理巧妙的管理SharedPreferences配置项

在Android应用程序中不少地方会使用SharedPreferences来保存配置文件,这样你就会出现不少下面的写法:


// 程序配置文件
public class MyAppConfig {
    SharedPreferences mSharedPreferences;

    public MyAppConfig(Context context) {
        mSharedPreferences = context.getSharedPreferences("AppConfig", Context.MODE_PRIVATE);
    }

    /**
     * 设置配置1
     */
    public void setConfig1(String value) {
        mSharedPreferences.edit().putString("config1", value).apply();
    }

    /**
     * 获取配置1
     */
    public String getConfig1() {
        return mSharedPreferences.getString("config1", "");
    }

    // ... 省略其他更多的配置项
}

这样一来我们就需要写很多跟SharedPreferences打交道的代码,其实我们关注的只有SET方法GET方法两个方法罢了。那有没有办法,我只需要定义好配置的接口就直接获取到值呢?这就是本篇文章要讨论的啦。其实我们理想效果应该是这样的:


// 定义一个配置接口
public interface ITestConfig {
    // 定义获取配置的Get、Set方法
    void setName(String name);
    String getName();
}

// 实际操作中的调用
private void runTestMethod() {
    // 能实现这样的效果就完美了
    ITestConfig config = XXX.create(context, ITestConfig.class);
    Log.i("rae", "结果:" + config.getName());
}

探索

不妨思考一下,这种需求好像我们在哪个地方见过呢?没错,就是我们经常用到的Retrofit框架中就有用到,我能对public <T> T create(final Class<T> service){}这个方法应该很熟悉了,我们好奇它为什么传入一个接口它就能构造出接口的实例呢?让我们走进源码看看:

 public <T> T create(final Class<T> service) {
    Utils.validateServiceInterface(service);
    if (validateEagerly) {
      eagerlyValidateMethods(service);
    }

    // 关键的地方来了:Proxy,动态代理!
    return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class<?>[] { service },
        new InvocationHandler() {
          private final Platform platform = Platform.get();
          private final Object[] emptyArgs = new Object[0];

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

就是这个神奇的动态代理。具体什么是动态代理这里就不做解析了,有兴趣的同学可以看看这里介绍的Java动态代理

动手前操作

知道大概的原理之后,我们就开始动手操作了。理一理咱们的思路,定义一个动态代理实现以下功能:

  • 处理我们定义的Get、Set方法,利用SharedPreferences保存配置项
  • 还可以处理一个clear()方法清除配置
  • 还可以处理一个remove(String key) 方法移除配置项
  • 还要处理保存对象类型(利用json字符串来实现)

动态代理的类


/**
 * 应用程序配置注解
 * Created by rae on 2020-02-20.
 * Copyright (c) https://github.com/raedev All rights reserved.
 */
@Documented
@Retention(RUNTIME)
public @interface Config {

    /**
     * 程序配置名称
     */
    String value();

}

/**
 * 应用程序代理类
 * Created by rae on 2020-02-20.
 * Copyright (c) https://github.com/raedev All rights reserved.
 */
public final class AppConfigHandler {

    private AppConfigHandler() {
    }

    /**
     * 创建程序配置代理类
     *
     * @param cls 类的Class
     */
    @SuppressWarnings("unchecked")
    public static <T> T create(Context context, Class<T> cls) {
        Config config = cls.getAnnotation(Config.class);
        if (config == null) {
            throw new RuntimeException("请在配置类标注@Config()");
        }
        if (!cls.isInterface()) {
            throw new RuntimeException("配置类必须是接口");
        }
        String configName = config.value();
        if (TextUtils.isEmpty(configName)) {
            configName = cls.getName();
        }
        SharedPreferences preferences = context.getSharedPreferences(configName, Context.MODE_PRIVATE);
        // 创建动态代理
        return (T) Proxy.newProxyInstance(cls.getClassLoader(), new Class<?>[]{cls}, new ConfigProxy(preferences));
    }

    private static class ConfigProxy implements InvocationHandler {

        private final SharedPreferences mPreference;
        private final Gson mGson = new Gson();

        private ConfigProxy(SharedPreferences preference) {
            this.mPreference = preference;
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) {
            String methodName = method.getName().toUpperCase();
            // 清除配置文件
            if (methodName.equalsIgnoreCase("clear")) {
                mPreference.edit().clear().apply();
            }
            // 移除配置项处理
            else if (methodName.equalsIgnoreCase("remove") && args != null) {
                String key = args[0].toString().toUpperCase();
                mPreference.edit().remove(key).apply();
            }
            // Get方法处理
            else if (methodName.startsWith("SET")) {
                setValue(methodName.replace("SET", ""), method, args);
            }
            // Set方法处理
            else if (methodName.startsWith("GET")) {
                return getValue(methodName.replace("GET", ""), method, args);
            }
            // Is方法处理,比如:isLogin()、isVip(),这类的布尔值
            else if (methodName.startsWith("IS")) {
                boolean value = mPreference.getBoolean(methodName.replace("IS", ""), false);
                return value;
            }
            return null;
        }

        /**
         * 设置配置值
         */
        private void setValue(String name, Method method, Object[] args) {
            if (args.length != 1) throw new IllegalArgumentException("set方法的方法参数只允许一个");
            Class<?>[] parameterTypes = method.getParameterTypes();
            Class<?> parameterType = parameterTypes[0];
            Object arg = args[0];
            SharedPreferences.Editor editor = mPreference.edit();
            if (parameterType == String.class) {
                editor.putString(name, (String) arg);
            } else if (parameterType == int.class) {
                editor.putInt(name, (int) arg);
            } else if (parameterType == boolean.class) {
                editor.putBoolean(name, (boolean) arg);
            } else if (parameterType == float.class) {
                editor.putFloat(name, (float) arg);
            } else if (parameterType == long.class) {
                editor.putLong(name, (long) arg);
            } else {
                // 其他值默认使用Json字符串
                String json = mGson.toJson(arg);
                editor.putString(name, json);
            }
            editor.apply();
        }

        /**
         * 获取配置值
         */
        private Object getValue(String name, Method method, Object[] args) {
            Class<?> type = method.getReturnType();
            Object defaultValue = args == null ? null : args[0];
            if (type == String.class) {
                return mPreference.getString(name, (String) defaultValue);
            } else if (type == int.class) {
                return mPreference.getInt(name, defaultValue == null ? 0 : (int) defaultValue);
            } else if (type == boolean.class) {
                return mPreference.getBoolean(name, defaultValue != null && (boolean) defaultValue);
            } else if (type == float.class) {
                return mPreference.getFloat(name, defaultValue == null ? 0 : (float) defaultValue);
            } else if (type == long.class) {
                return mPreference.getLong(name, defaultValue == null ? 0 : (long) defaultValue);
            } else {
                // 其他值默认使用Json字符串
                String json = mPreference.getString(name, null);
                return mGson.fromJson(json, type);
            }
        }
    }
}

实践操作

最终我们定义一个接口,就可以轻松实现读取配置文件啦!

/**
 * 程序配置
 * Created by rae on 2020/2/22.
 * Copyright (c) https://github.com/raedev All rights reserved.
 */
@Config("YourAppConfig")
public interface IAppConfig {

    void setUserName(String name);

    String getUserName(String defaultValue);

    void setVip(boolean isVip);

    boolean isVip();

    void setVersion(int version);

    int getVersion();

    void clear();

    void remove(String key);
}

方法调用

private void runTestMethod() {
    IAppConfig config = AppConfigHandler.create(getApplicationContext(), IAppConfig.class);
    config.clear();
    config.setUserName("RAE");
    config.remove("UserName");
    Log.i("Rae", "username is " + config.getUserName("DefaultValue"));
    config.setVip(true);
    Log.i("Rae", "is vip: " + config.isVip());
    config.setVersion(10);
    Log.i("Rae", "version is  " + config.getVersion());
}

输出结果

I/Rae: username is DefaultValue
I/Rae: is vip: true
I/Rae: version is  10

Android-View篇之自定义验证码输入框

首先,我们来看看实现的是怎么样的效果:

验证码输入框效果图

如果我们拿到这样的UI,想到的布局应该是用4个EditText包在横向的LinearLayout里面,但今天要讲的View,所以我们决定用一个自定义的EditText 画出来。

学到什么?

  • 基本理解画布概念
  • 画布的状态、平移
  • 布局测量
  • 画图片

功能需求

  • 高亮当前输入框
  • 输入满4个数字自动调用方法

思路

完全重画一个EditText,就包含了测量布局重新绘制这两个关键步骤。好了,到这里理一下整体的思路:

  • 根据验证码个数以及边框大小来计算输入框显示的宽度
  • 覆盖原来的EditText画布,重新绘制方框
  • 根据输入的索引来确定高亮的方框
  • 重写onTextChanged 但满足验证码个数的时候调用自动完成方法

开始动手

准备开始了,果断继承一个AppCompatEditText 来初始化基本参数先:

  • 验证码个数
  • 输入方框的大小
  • 边框的大小及间距

/**
 * 验证码输入框,重写EditText的绘制方法实现。
 * @author RAE
 */
public class CodeEditText extends AppCompatEditText {

   //  验证码文本颜色
    private int mTextColor;
    // 输入的最大长度
    private int mMaxLength = 4;
    // 边框宽度
    private int mStrokeWidth;
    // 边框高度
    private int mStrokeHeight;
    // 边框之间的距离
    private int mStrokePadding = 20;
    // 用矩形来保存方框的位置、大小信息
    private final Rect mRect = new Rect();
    // 方框的背景
    private Drawable mStrokeDrawable;

   /**
     * 构造方法
     *
     */
    public CodeEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CodeEditText);
        int indexCount = typedArray.getIndexCount();
        for (int i = 0; i < indexCount; i++) {
            int index = typedArray.getIndex(i);
            if (index == R.styleable.CodeEditText_strokeHeight) {
                this.mStrokeHeight = (int) typedArray.getDimension(index, 60);
            } else if (index == R.styleable.CodeEditText_strokeWidth) {
                this.mStrokeWidth = (int) typedArray.getDimension(index, 60);

            } else if (index == R.styleable.CodeEditText_strokePadding) {
                this.mStrokePadding = (int) typedArray.getDimension(index, 20);

            } else if (index == R.styleable.CodeEditText_strokeBackground) {
                this.mStrokeDrawable = typedArray.getDrawable(index);

            } else if (index == R.styleable.CodeEditText_strokeLength) {
                this.mMaxLength = typedArray.getInteger(index, 4);
            }
        }
        typedArray.recycle();

        if (mStrokeDrawable == null) {
            throw new NullPointerException("stroke drawable not allowed to be null!");
        }

        setMaxLength(mMaxLength);
        setLongClickable(false);
        // 去掉背景颜色
        setBackgroundColor(Color.TRANSPARENT);
        // 不显示光标
        setCursorVisible(false);
    }

    @Override
    public boolean onTextContextMenuItem(int id) {
        return false;
    }

   /**
     * 设置最大长度
     */
    private void setMaxLength(int maxLength) {
        if (maxLength >= 0) {
            setFilters(new InputFilter[]{new InputFilter.LengthFilter(maxLength)});
        } else {
            setFilters(new InputFilter[0]);
        }
    }
}

开始测量布局

初始化完了就要开始测量布局了,计算公式为:

输入框宽度 = 边框宽度 数量 + 边框间距 (数量-1)


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        // 当前输入框的宽高信息
        int width = getMeasuredWidth();
        int height = getMeasuredHeight();
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        // 判断高度是否小于推荐高度
        if (height < mStrokeHeight) {
            height = mStrokeHeight;
        }

        // 输入框宽度 = 边框宽度 * 数量 + 边框间距 *(数量-1)
        int recommendWidth = mStrokeWidth * mMaxLength + mStrokePadding * (mMaxLength - 1);
        // 判断宽度是否小于推荐宽度
        if (width < recommendWidth) {
            width = recommendWidth;
        }

        widthMeasureSpec = MeasureSpec.makeMeasureSpec(width, widthMode);
        heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, heightMode);
        // 设置测量布局
        setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
    }

画家登场

来到最重要的步骤了,重画输入框!来一步步看代码注释:

    @Override
    protected void onDraw(Canvas canvas) {
        // 在画支持设置文本颜色,把系统化的文本透明掉,相当于覆盖
        mTextColor = getCurrentTextColor();
        setTextColor(Color.TRANSPARENT);
        //  系统画的方法
        super.onDraw(canvas);
        // 重新设置文本颜色
        setTextColor(mTextColor);
        // 重绘背景颜色
        drawStrokeBackground(canvas);
        // 重绘文本
        drawText(canvas);
    }

绘制背景方框


    /**
     * 绘制方框
     */
    private void drawStrokeBackground(Canvas canvas) {
        // 下面绘制方框背景颜色
        // 确定反馈位置
        mRect.left = 0;
        mRect.top = 0;
        mRect.right = mStrokeWidth;
        mRect.bottom = mStrokeHeight;
        int count = canvas.getSaveCount(); //  当前画布保存的状态
        canvas.save(); // 保存画布
        for (int i = 0; i < mMaxLength; i++) {
            mStrokeDrawable.setBounds(mRect); // 设置位置
            mStrokeDrawable.setState(new int[]{android.R.attr.state_enabled}); // 设置图像状态
            mStrokeDrawable.draw(canvas); //  画到画布上
            //  确定下一个方框的位置
            float dx = mRect.right + mStrokePadding; // X坐标位置
            // 保存画布
            canvas.save();
            // [注意细节] 移动画布到下一个位置
            canvas.translate(dx, 0);
        }
        // [注意细节] 把画布还原到画反馈之前的状态,这样就还原到最初位置了
        canvas.restoreToCount(count);
        // 画布归位
        canvas.translate(0, 0);

        // 下面绘制高亮状态的边框
        // 当前高亮的索引
        int activatedIndex = Math.max(0, getEditableText().length());
        mRect.left = mStrokeWidth * activatedIndex + mStrokePadding * activatedIndex;
        mRect.right = mRect.left + mStrokeWidth;
        mStrokeDrawable.setState(new int[]{android.R.attr.state_focused});
        mStrokeDrawable.setBounds(mRect);
        mStrokeDrawable.draw(canvas);

    }

一般画布的移动canvas.translate(x,y)会结合canvas.save();来使用。
1、调用canvas.save();保存当前画布的状态,用PS来解析就是按下ctrl +s键,然后帮你新建一个新的图层。你之后画的内容不会影响到之前画的内容,要回到之前的状态就调用canvas.restoreToCount(count)来还原。
2、把画布的位置移到下一个位置canvas.translate(x,y),下图所示,你会发现方框在画布中的位置没有发生变化而是画布距离发生了变化。这就是画布平移的效果了。

画布平移

画验证码文字

    /**
     * 重绘文本
     */
    private void drawText(Canvas canvas) {
        int count = canvas.getSaveCount();
        canvas.translate(0, 0);
        int length = getEditableText().length();
        for (int i = 0; i < length; i++) {
            String text = String.valueOf(getEditableText().charAt(i));
            TextPaint textPaint = getPaint();
            textPaint.setColor(mTextColor);
            // 获取文本大小
            textPaint.getTextBounds(text, 0, 1, mRect);
            // 计算(x,y) 坐标
            int x = mStrokeWidth / 2 + (mStrokeWidth + mStrokePadding) * i - (mRect.centerX());
            int y = canvas.getHeight() / 2 + mRect.height() / 2;
            canvas.drawText(text, x, y, textPaint);
        }
        canvas.restoreToCount(count);
    }

监听文本变化回调自动完成方法

    @Override
    protected void onTextChanged(CharSequence text, int start,
                                 int lengthBefore, int lengthAfter) {
        super.onTextChanged(text, start, lengthBefore, lengthAfter);

        // 当前文本长度
        int textLength = getEditableText().length();

        if (textLength == mMaxLength) {
            hideSoftInput();
            if (mOnInputFinishListener != null) {
                mOnInputFinishListener.onTextFinish(getEditableText().toString(), mMaxLength);
            }
        }

    }

查看完整的源码

到这里你能大概理解画布的概念了,本文完。

Android-View篇之启动页倒计时动画的实现

Hello,小伙伴们大家好,今天来实现一个很简单的倒计时动画,仿酷狗音乐的启动页倒计时效果,也是大多数APP在用的一个动画,来看看效果图:

倒计时动画

实现思路

看看是不是很简单,画个圈圈动起来,整体的思路就是用一个平滑的帧动画来画圆弧就行了。

这篇文章学到什么?

  • 了解属性动画ValueAnimator的用法
  • 了解动画属性插值Interpolator,让动画过度得更自然
  • 如何画圆弧

开始准备

新建一个类继承TextView,因为中间还有跳过的文本,所以选择用TextView来画个动起来的背景图。

/**
 * 倒计时文本
 * Created by ChenRui on 2017/10/31 0031 23:01.
 */
public class CountDownTextView extends RaeTextView {
    // 倒计时动画时间
    private int duration = 5000;
    // 动画扫过的角度
    private int mSweepAngle = 360;
    // 属性动画
    private ValueAnimator animator;
    // 矩形用来保存位置大小信息
    private final RectF mRect = new RectF();
    // 圆弧的画笔
    private Paint mBackgroundPaint;

    public CountDownTextView(Context context) {
        super(context);
    }

    public CountDownTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CountDownTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    void init() {
        super.init();
        // 设置画笔平滑
        mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        // 设置画笔颜色
        mBackgroundPaint.setColor(Color.WHITE);
        // 设置画笔边框宽度
        mBackgroundPaint.setStrokeWidth(dp2px(2));
        // 设置画笔样式为边框类型
        mBackgroundPaint.setStyle(Paint.Style.STROKE);
    }

开始动画

原理: 利用圆的360度角来做属性动画,让它平滑的分配做每帧动画的角度值,然后调用invalidate() 来重绘自己本身,从而进入到本身的onDraw()方法来画图。

  /**
     * 开始倒计时
     */
    public void start() {
        // 在动画中
        if (mSweepAngle != 360) return;
        //  初始化属性动画
        animator = ValueAnimator.ofInt(mSweepAngle).setDuration(duration);
        // 设置插值
        animator.setInterpolator(new LinearInterpolator());
        // 设置动画监听
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                // 获取属性动画返回的动画值
                mSweepAngle = (int) animation.getAnimatedValue();
                // 重绘自己
                invalidate();
            }
        });
        // 开始动画
        animator.start();
    }

画圆弧

画圆弧比较简单, 从效果图来看,有的同学可能刚开始以为要画两个圆,一个背景的内圆和一个白色边框的大圆,其实这里可以利用画笔设置画笔样式paint.setStyle()和宽度大小paint.setStrokeWidth()的特性来实现。代码很简单,开始的角度选择-90,从头顶开始画。这样实现的是一个顺时针的倒计时效果。如果你想实现酷狗的逆时针效果,就控制mSweepAngle 的值用mSweepAngle = 360 - mSweepAngle 开始就可以了。

 @Override
    protected void onDraw(Canvas canvas) {
        int padding = dp2px(4);
        mRect.top = padding;
        mRect.left = padding;
        mRect.right = getWidth() - padding;
        mRect.bottom = getHeight() - padding;

        // 画倒计时线内圆
        canvas.drawArc(mRect, //弧线所使用的矩形区域大小
                -90,  //开始角度
                mSweepAngle, //扫过的角度
                false, //是否使用中心
                mBackgroundPaint); // 设置画笔

        super.onDraw(canvas);
    }

什么是插值动画?

为了让动画过度的更加自然或者添加一些动画效果,比如匀速运动、加速运动、减速运动、弹跳运动等等,这些的动画的效果就是靠插值来实现的。在Android中系统内置了一些插值,这里做下搬运工记录一下。推荐一个能在线运行Interpolator的效果以及数学公式定义的网站 http://inloop.github.io/interpolator/ 更加直观的展示下面介绍的动画效果。

插值 说明
LinearInterpolator 以常量速率改变
BounceInterpolator 动画结束的时候弹起
CycleInterpolator 动画循环播放特定的次数,速率改变沿着正弦曲线
DecelerateInterpolator 在动画开始的地方快然后慢
OvershootInterpolator 向前甩一定值后再回到原来位置
AccelerateInterpolator 在动画开始的地方速率改变比较慢,然后开始加速
AnticipateInterpolator 开始的时候向后然后向前甩
AccelerateDecelerateInterpolator 在动画开始与介绍的地方速率改变比较慢,在中间的时候加速
AnticipateOvershootInterpolator 开始的时候向后然后向前甩一定值后返回最后的值

项目使用

这里要定义文本的宽高,因为没有画底部的黑色圆背景,还要设置一下背景图。

  <com.rae.cnblogs.widget.CountDownTextView
            android:id="@+id/tv_skip"
            style="@style/Widget.AppCompat.Button.Borderless"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:background="@drawable/bg_count_down"
            android:text="跳过"
            android:textColor="#ffffff"
            android:textSize="12sp" />

背景图

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <shape android:shape="oval">
            <solid android:color="#302d2d2d" />
        </shape>
    </item>
    <item>
        <shape android:shape="oval">
            <solid android:color="#7F2d2d2d" />
        </shape>
    </item>
</selector>

到这里结束啦,希望对你有帮助,本篇文章的源码都在开源的博客园Android客户端这里。

把自己的Library库上传到Maven中去

一、在你要上传到maven的库中配置上传参数
代码


uploadArchives {
    apply plugin: 'maven'
    // 读取本地配置文件
    Properties properties = new Properties()
    properties.load(project.rootProject.file('local.properties').newInputStream())
    def userName = properties.getProperty('maven.user')
    def password = properties.getProperty('maven.password')
    def mavenUrl = properties.getProperty('maven.url')

    repositories.mavenDeployer {
        repository(url: mavenUrl) {
            authentication(userName: userName, password: password)
        }
        pom.project {
            // 注意:这里要修改一下你的库。比如:com.baidu:lib:1.0.0
            groupId 'com.github.raedev'
            artifactId 'session'
            version '1.0.0'
            packaging 'aar'
        }
    }

    task androidSourcesJar(type: Jar) {
        classifier = 'sources'
        from android.sourceSets.main.java.sourceFiles
    }
    artifacts {
        archives androidSourcesJar
    }
}

二、打开local.properties 配置好maven 参数

maven.url=http://maven.baidu.com/repository/maven-baidu/
maven.user=填写你的Maven账号
maven.password=填写你的Maven密码

三、运行uploadArchives任务

运行uploadArchives任务

恭喜!前往你的maven库中去看看是否上传成功了。

Android 金融类项目模块化架构

一、前言

在以往的开发中,我们通常会使用MVC的模式进行开发,这样导致了Activity处理的逻辑非常的复杂,而且耦合度非常高,代码结构混乱、层次不清,各业务技术方案不统一,冗余代码充斥项目的各个角落;甚至连基本的包结构也是胡乱不堪,项目架构更是无从谈起。大家只不过是不停地往上堆砌代码添加新功能罢了。

其中业务层是一种非标准的 MVC 架构,Activity 和 Fragment 承担了 View 和 Controller 的职责:

传统的MVC模式

为了适应项目快速开发以及项目中代码的复用,解决项目中的耦合度。我们不断引入了 Retrofit、UniversalImageLoader、OkHttp、ButterKnife 等一系列成熟的开源库,同时我们也开发了自己的 UI 组件库 UIComponent、基础工具库 CommonUtils、基于第三方地图封装的 MapSDK、即时聊天模块 ChatLibrary 等等。这样就由基础组件层、业务组件层和业务层组成的三层架构。如下图:

MVP模式

前面这种分层的架构本身是没太大问题的,即使到了现在我们的业务项目也已然是基于这种分层的架构来构建的,只不过在不断的迭代中我们做了些许调整(分层架构后面在介绍组件化和模块化的时候会详细介绍)。但是随着业务的不断迭代,我们慢慢发现业务层这种非标准的 MVC 架构带来了种种影响团队开发效率的问题:

Activity 和 Fragment 越来越多的同时承担了 Controller 和 View 的职责,导致他们变得及其臃肿且难以维护;

由于 Controller 和 View 的揉合,导致单元测试起来很困难;

回调嵌套太多,面对负责业务时的代码逻辑不清晰,难以理解且不利于后期维护;

各层次模块之间职责不清晰等等

二、项目整体架构

整体项目架构如下图:

整体项目架构

三、项目分层说明

整体项目分层:

View Layer: 只负责 UI 的绘制呈现,包含 Fragment 和一些自定义的 UI 组件,View 层需要实现 ViewInterface 接口。Activity 在项目中不再负责 View 的职责,仅仅是一个全局的控制者,负责创建 View 和 Presenter 的实例;

Model Layer: 负责检索、存储、操作数据,包括来自网络、数据库、磁盘文件和 SharedPreferences 的数据;

Presenter Layer: 作为 View Layer 和 Module Layer 的之间的纽带,它从 Model 层中获取数据,然后调用 View 的接口去控制 View;

Contract: 我们参照 Google 的 Demo 加入契约类 Contract 来统一管理 View 和 Presenter 的接口,使得某一功能模块的接口能更加直观的呈现出来,这样做是有利于后期维护的。

整体项目架构分为3层:

  • 模块层
  • 业务逻辑层
  • 基础组件层

为什么要这么分?首先从整体模式上看跟微盘的架构大同小异,很多模块都是相同的,可能UI展现跟接口数据不大一样,业务逻辑是差不多的,但是目前很难再从以前的微盘中直接重用。所以分成单独的模块。

第一利于团队多模块开发,第二利于开发速度,只针对单个模块进行编译,编译速度提升了。第三每个模块都可以单独成为APK运行,方便代码调试。第四就是易用性和重用性高。

模块层

对整个APP进行功能拆分,单独成立模块,每一个模块都独立依赖基础组件和业务组件。我们可以把 Basic Component Layer 和 Business Component Layer 放在一起看做是一层SDK,新的业务或者项目只需要依赖 SDK 就好。甚至我们可以做得更极致一些,开发一套自己的组件管理平台,业务方可以根据自己的需求选择自己需要的组件,定制业务专属的SDK。业务端和SDK 的关系如下图所示:

业务端和SDK 的关系

业务逻辑层

封装了与模块层的数据交和UI回调,实际上就相当于Presenter的职责。调用接口以及数据处理都在这一层里面做,最终把结果回调给界面。

另外的职责就是封装常用的公共模块如数据库操作,缓存操作,HTTP请求等。

这一层可以使用RxJava,可以很好的解决嵌套回调的问题。RxJava系列的文章可以参考这里

各 Layer 间严禁反向依赖:每个层要进行依赖,先画好层于层直接的调用关系,禁止相互依赖。有相互依赖的把公共部分单独拆分。

基础组件层

这一层比较好理解,封装基础组件,比如模块化需要用到的Router来连接,并且可以管理Activity的生命周期。还有一些基础UIWidget库,比如股票的图表。

依赖管理

项目间的依赖通过私有maven库进行管理,特别是sdk跟presenter这一层,强制把UI跟逻辑分离。使用maven的一个弊端就是需要频繁的上传跟重新build。前期可以在项目用complie project(':sdk') 来依赖。

API 接口层

API接口作为核心的一层,每个模块都需要调用该层,我们采用分功能来设计接口,并提供统一的接口工厂来获取接口的实例。UML图如下:

接口层编码的时候要注意:

全站使用HTTPS证书

如何避免回调Listener的内存泄漏?(原因:回调都是通过onSuccess()方法去处理,很容易引用到Context,而导致Http线程没办法退出)

封装成基础类去发请求,可以控制token失效重新发请求的过程。

采用Retrofit2 +okhttp3

接口缓存策略(参考Volley的缓存):

如果缓存中存在,先从缓存读取。

每一个请求都有缓存时间,缓存过期或者失效后重新获取。

可以配置每个请求启用或者禁用缓存,比如一些增删改查操作就不需要用到缓存。列表的形式一般要缓存。

缓存默认是关闭的,根据需要来给请求缓存。

Model

一般情况下我们的实体层(entity、bean、model)这些都是跟sdk处于一层的,为了避免每个模块为了使用实体层而引用sdk,所以要把这个实体层单独一层出来,避免相互之间有反向依赖的可能性。

除了常用的实体层之外,Model层还具有负责检索、存储、操作数据,包括来自网络、数据库、磁盘文件和 SharedPreferences 的数据的功能,只是都归根到Model这一块来。实际上他们都是单独开来的。

UIWidget

View划分成若干小模块,不单单可以使用当前项目,更为了以后方便集成到其他项目当中去。

UIWidget

Router 路由管理

组件化和模块化使得程序更加灵活,为了避免在app对各个模块以及组件的依赖当组件发生改变的时候代码修改很大。所以由RouterManger去统一管理各个模块组件之间的跳转。

实现方式可以采用ARouter,支持Url方式的跳转。

APP路由管理

四、项目安全说明

接口安全

接口使用HTTPS加密证书进行传输,并进行用户鉴权,用户鉴权方面则打算采用Token方式。用户登录之后分配一个accessToken和一个refreshToken,accessToken用于发起用户请求,refreshToken用于更新accessToken。accessToken会设置有效期,可以设为24小时。而用户退出登录之后,accessToken和refreshToken都将作废。重新登录之后会分配新的accessToken和refreshToken。

然后,我还打算在App层级分配AppKey和AppSecret,Android和iOS分别分配一对。每次向服务端发送请求时,AppKey都必须带上,服务端会对相应的AppKey进行校验。而AppSecret则需要安全保存在客户端,也不能在网络上进行传输,防止泄露。AppSecret只用于加密一些安全性级别较高的数据,以及为URL生成签名。URL签名算法步骤如下:

将所有参数按参数名进行升序排序;

将排序后的参数名和值拼接成字符串stringParams,格式:key1value1key2value2...;

在上一步的字符串前面拼接上请求URI的Endpoint,字符串后面拼接上AppSecret,即:stringURI + stringParams + AppSecret;

使用AppSecret为密钥,对上一步的结果字符串使用HMAC算法计算MAC值,这个MAC值就是签名。

鉴权流程如下:

接口鉴权流程

最后总结设计为:

接口采用HTTPS和签名证书进行传输

接口参数通过排序 & URL & AppSecret 最终通过HMAC加密生成sign参数

利用底层JNI的so库提供接口进行加密,保证密钥的安全性

敏感字段so加密传输,比如设计密码、银行卡号、身份证号码等有关用户安全信息的字段

数据安全利用底层JNI实现,打包成so库提供JAVA接口调用。把公钥信息、加密算法放到so库里面防止反编译信息泄漏。

APK安全

android的apk文件实际上是压缩文件,很容易被反编译工具进行反编译,像微信这些apk都能被反编译。为了加强APK的安全性,设计如下:

发布时对代码进行混淆编译

第三方APK文件加固,加固网站:

五、模块说明

行情模块(Quotation Module)

行情使用WSS安全传输。

行情以后台服务的形式,在Application onCreate() 的时候就开始建立WebSocket连接。没有订阅行情的时候关闭行情连接,当订阅的时候再次开启行情连接。目的为了节省流量以及手机电量。

因为行情基本上每个页面都会用到,所以采用发布订阅的观察者模式进行设计,实现原理:

每个Presenter都可以注册自己需要的行情(Quotation Filter),当WebSocket 接收到对应的Quotation Filter的时候调用EventBus中间件发送消息。最终会回调到该类的定义的事件方法中去。

把数据处理好前端需要展示的数据实体返回。如行情的状态是上涨还是下跌的状态。

统一使用QuotationManager来管理行情的连接、订阅;抽象成QuotationAction 来管理注册不同的行消息,解析返回。

行情服务接口方法:

  • start() 启动服务
  • stop() 停止服务
  • getStatus() 获取当前状态
  • register(object handler, QuotationAction action) 订阅行情
  • unregister(); 反注册行情

行情模块

用户模块(User Module)

依赖项:API

用户模块

说明:

User Manager :用户管理,管理当前用户的登录状态、用户信息、用户操作(退出登录,状态维持,Token刷新)

User Module:用户模块,跟用户相关的各个功能的业务处理、数据处理

交易模块(Trade Module)

依赖项:API、行情模块

其他模块需要调用交易模块都是通过路由跳转的方式调用不会直接调用到内部方法里面,所以交易模块重点还是调用API进行数据处理:

交易模块

本文参考链接:

Android RSA公钥、私钥加密和解密

在Android中使用 RSA公钥、私钥加密和解密

公钥私钥加密

     /**
     * 公钥加密
     * @throws Exception
     */
    @Test
    public void testPublicKeyEncrypt() throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchPaddingException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
        String pubKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+aOZLmOizkK325oR6SktKald6YSR8pYSFYbionJjiQKtpFjKEaAsBkiGj8WPGDMNJrYGezVvAC0PQYbxqdbjx0ybQ6JlT/nzkLIAbzQjoThS3PQDjsW/gBeELkgY4VIrqDB8VNYNohAg29zaFAP3bFkpjFwcct93c70ZvL8mz6wIDAQAB";

        String text = "test123";
        String result = "We0llfPLbCYjK6bKtauY2Ym3+vOuziObjdscv6v1uiXPDcflK81zlH2TNTLAkXzDJ9u5MgsuIp0QL6qGwFlaZU/yRV91YIJfFdOA0a1xZ+qMe5N/r6h7nCpUD+Omwc0p7pSjfkv2hUlFG062OcfVfVf2ssittW9qhLKS91WDypY=";

        // 加载公钥
        X509EncodedKeySpec data = new X509EncodedKeySpec(Base64.decode(pubKey.getBytes(), Base64.DEFAULT));
        KeyFactory factory = KeyFactory.getInstance("RSA");
        PublicKey key = factory.generatePublic(data);

        Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
        cipher.init(Cipher.ENCRYPT_MODE, key);
        byte[] encryptData = cipher.doFinal(text.getBytes());
        String encrypt = Base64.encodeToString(encryptData, Base64.DEFAULT);

        Assert.assertEquals("加密:" + encrypt, encrypt, result);

    }

    /**
     * 私钥加密
     * @throws Exception
     */
    @Test
    public void testPrivateKey() throws Exception {
        String priKey = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAL5o5kuY6LOQrfbmhHpKS0pqV3phJHylhIVhuKicmOJAq2kWMoRoCwGSIaPxY8YMw0mtgZ7NW8ALQ9BhvGp1uPHTJtDomVP+fOQsgBvNCOhOFLc9AOOxb+AF4QuSBjhUiuoMHxU1g2iECDb3NoUA/dsWSmMXBxy33dzvRm8vybPrAgMBAAECgYBVGTjjzIEjz6OQV1IZ/Z5Msd5K2aOe+bKSkiwfX22MoO561urY9k8E8rSKOtYmq4mUIjFuMcWxvNcgCK5WvipbUrYaGI1wTza34ncxO7rm7/mYB1BPhX+d5lPCTNKhYix7JlDGwaC/npxQJtR9FalhxFIU+Lmr2JZN4I3swDcikQJBAPwifquvVJ75TV/Js5xGpF5E4T8t9z8O3ceQfmszglv4hXuJJLKd2UFSa2bWGP3z0x2t3qX4ZbkJ9qUrFEsUIycCQQDBVCodYi9eVXdcD0Mosv/KZjO2mx51tS6XczWfUxoyRpYdWxLfyq5vBgGEEJt4QipkgGXKnwuUppGkXBdHMdOdAkABHKHUXfyQiublcj1Bhio5ZDJeFfTOKWGe/KsiC+MaRrlH9y3bP8jyecuRc4Y+sHGQ4vBlaPgB3eJhjhQT1K3nAkB2xoa5VsFTa57RaG8SaibM6s2KuvKTzqS5V4byQ9QsX0GK95E4/QT+IOp9gNaDo+L3rArd2aj7wvpnyExk6S/hAkEAxsFtWDChCZmt62vRmz3mmrtq9scg4LplA3U0vN2glv+lc+OoRQlG0lCRFak9BH0EWqbntREeRRMn6TOI0QZKYw==";
        String text = "raetest";

        // 加载公钥
        PKCS8EncodedKeySpec data = new PKCS8EncodedKeySpec(Base64.decode(priKey.getBytes(), Base64.DEFAULT));
        KeyFactory factory = KeyFactory.getInstance("RSA");
        PrivateKey key = factory.generatePrivate(data);

        Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
        cipher.init(Cipher.ENCRYPT_MODE, key);
        byte[] encryptData = cipher.doFinal(text.getBytes());
        String encrypt = Base64.encodeToString(encryptData, Base64.DEFAULT);

        Assert.assertEquals("加密:" + encrypt, encrypt, "12312");
    }

Android 微信支付签名问题

1、微信调用统一订单后会返回下面结果

{
    "return_code": "SUCCESS",
    "return_msg": "OK",
    "appid": "APPID",
    "mch_id": "商户号",
    "nonce_str": "随机字符串",
    "sign": "081675D3A89B1A735613CF2D777E6F06",
    "prepay_id": "wx201706052018103dd047b0880123350695",
    "result_code": "FAIL",
    "err_code": "ORDERPAID",
    "err_code_des": "该订单已支付"
}

2、Android 需要服务端再次签名返回,因为如果在APP端做校验的话会暴露appkey,对APP的安全性有影响

3、对下面的字符进行拼接之后签名产生sign字段,签名格式查看官方签名

注意:这里的key为商户的key,而不是AppSecret;timestamp为当前的时间戳

"appid=" + appid + "&noncestr=" + nonce_str + "&package=Sign=WXPay" + "&partnerid=" + mch_id + "&prepayid=" + prepayid + "&timestamp=" + timeStamp + "&key=" + key;

4、最后返回的下面的字段:

{
    "appid": "第1步的APPID",
    "mch_id": "第1步的mch_id",
    "prepay_id": "第1步的prepay_id",
    "nonce_str": "第1步的nonce_str",
    "sign": "第3步的签名,不是第1步的签名",
    "timestamp":"第3步的时间戳"
}