分类目录归档:Android

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 微信支付签名问题

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步的时间戳"
}

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 Studio 多个编译环境配置 多渠道打包 APK输出配置

看完这篇你学到什么:

  • 熟悉gradle的构建配置
  • 熟悉代码构建环境的目录结构,你知道的不仅仅是只有src/main
  • 开发、生成环境等等环境可以任意切换打包
  • 多渠道打包
  • APK输出文件配置

需求

一般我们开发的环境分为:debug 和 release,但是你想再分内测1环境、内测2环境等等怎么办呢?

这就需要依赖强大的gradle 来配置了。

相关的配置也可以参考谷歌官方文档

配置构建类型 buildTypes

您可以在模块级 build.gradle 文件的 android {} 代码块内部创建和配置构建类型。当您创建新模块时,Android Studio 会自动为您创建调试和发布这两种构建类型。尽管调试构建类型不会出现在构建配置文件中,Android Studio 会将其配置为 debuggable true。这样,您可以在安全的 Android 设备上调试应用并使用通用调试密钥库配置 APK 签署。

如果您希望添加或更改特定设置,您可以将调试构建类型添加到您的配置中。以下示例为调试构建类型指定了 applicationIdSuffix,并配置了一个使用调试构建类型中的设置进行初始化的jnidebug构建类型。

applicationIdSuffix: 字段表示,在不改变你默认的程序ID(包名)的情况下,为其添加后缀。比如你的包名是com.rae.app,但你想区分测试包和正式包的情况,这个时候将applicationIdSuffix设置为.debug,那么你的应用程序对应的包名就变成了com.rae.app.debug

android {
    ...
    defaultConfig {...}
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }

        debug {
            applicationIdSuffix ".debug"
        }

        /**
         * The 'initWith' property allows you to copy configurations from other build types,
         * so you don't have to configure one from the beginning. You can then configure
         * just the settings you want to change. The following line initializes
         * 'jnidebug' using the debug build type, and changes only the
         * applicationIdSuffix and versionNameSuffix settings.
         */

        jnidebug {

            // This copies the debuggable attribute and debug signing configurations.
            initWith debug

            applicationIdSuffix ".jnidebug"
            jniDebuggable true
        }
    }
}

构建源集

我们都知道,源代码是放在src/main 文件夹下的,但是由于配置了不同的构建类型像想要区分不同的源文件怎么办呢?这个时候就可以在src对应你的buildTypes来建立文件夹了 ,更多参考谷歌源集

Android Studio 按逻辑关系将每个模块的源代码和资源分组为源集。模块的 main/ 源集包括其所有构建变体共用的代码和资源。其他源集目录为可选项,在您配置新的构建变体时,Android Studio 不会自动为您创建这些目录。不过,创建类似于 main/ 的源集有助于让 Gradle 只应在构建特定应用版本时使用的文件和资源井然有序:

productFlavor 表示渠道包,可以看下面的多渠道打包

src/main/

此源集包括所有构建变体共用的代码和资源。

src/<buildType>/

创建此源集可加入特定构建类型专用的代码和资源。示例:src/jnidebug

src/<productFlavor>/

创建此源集可加入特定产品风味专用的代码和资源。比如百度渠道包:src/baidu

src/<productFlavorBuildType>/

创建此源集可加入特定构建变体专用的代码和资源。
例如,要生成应用的“完全调试”版本,构建系统需要合并来自以下源集的代码、设置和资源。比如:百度的开发环境包:src/baiduDebug

构建类型依赖配置

很多时候我们会把sdk或者api接口单独做成一个库,一般会有生产环境和测试环境之分,但在依赖的时候往往我们会像这样去引用: compile project(':sdk'),这样依赖的环境就是release,在开发调试的时候测试环境的时候就不行了。我们得换另外一种方式:

<buildType>Compile project()

这样会根据不同的构建类型去依赖不同的包,比如我们测试环境的依赖包:debugCompile project(':sdk'),再比如上面的jnidebugjnidebugCompile project(':sdk')

那么问题来了,我当前的构建类型怎么对应到其他的module去呢?比如你的app要依赖sdk module 的debug 环境,那么你可以这么做:

configuration:目标module<buildType>,比如你sdk 中<buildType>debug构建类型

debugCompile project(path: ':sdk', configuration: 'debug')

综合示例:

1、先看app这边的build.gradle配置:

apply plugin: 'com.android.application'

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug {
            applicationIdSuffix '.debug'
            minifyEnabled false
        }

        // 自定义的构建类型,名字随便取,一定要有意义
        raedebug {
            initWith debug
            applicationIdSuffix '.raedebug'
        }
    }
}

dependencies {
    // 生成环境依赖
    releaseCompile project(path: ':sdk', configuration: 'release')
    // 测试环境依赖
    debugCompile project(path: ':sdk', configuration: 'debug')
    // 自定义构建类型依赖
    raedebugCompile project(path: ':sdk', configuration: 'uutest')
}

2、sdk module的build.gradle配置:

apply plugin: 'com.android.library'

android {
       buildTypes {
        debug {
            debuggable true
            minifyEnabled false
        }
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }

        // 注意这里,跟第一点的 raedebugCompile project的configuration要匹配。
        uutest {
            initWith debug
        }
    }
}

多渠道打包 productFlavors

先看看build.gradle配置你就懂了

android{

    // 渠道包定义,默认定义的名称就是渠道名称
    productFlavors {

        dev {} // 测试
        baidu {}        // 百度手机助手
        yinyongbao {}   // 应用宝
        m360 {}         // 360手机助手
        pp {}           // PP助手
        anzhi{}         // 安智市场
        xiaomi {}       // 小米商店
        letv {}         // 乐视商店
        huawei {}       // 华为商店
        lenovomm {}     // 联想乐商店
        other {}        // 其他市场
        official{}      // 官方版本

    }

    // 批量渠道包值替换
    productFlavors.all { flavor ->
        // 友盟、极光推送渠道包, UMENG_CHANNEL 是根据你AndroidManifest.xml来配置的,请看下面。
        flavor.manifestPlaceholders = [UMENG_CHANNEL: name, JPUSH_CHANNEL: name]
    }
}

AndroidManifest.xml 配置:


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.rae.demo">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">

       <!--变量采用${变量名}这样来替换,不仅限与<meta-data /> 标签,任何你想替换的都行。-->
         <meta-data
            android:name="UMENG_APPKEY"
            android:value="${UMENG_APPKEY}"/>

        <meta-data
            android:name="UMENG_CHANNEL"
            android:value="${UMENG_CHANNEL}"/>

        <!--${变量随变换}-->   
        <activity
            android:name=".DemoActivity"
            android:label="${变量随变换}"/>

    </application>

</manifest>

sync gradle之后看看gradle projects 面板列表就多出了好到渠道的任务了,Build Variants 面板也相对应多了这些构建类型。

APK输出配置

在结合到多渠道打包后,运营的那边希望我们给的渠道包是这种格式的app-{版本号}-{渠道名称}.apk,那我们来看看怎么来满足这个多渠道打包输出apk文件名修改的。

android{

    // 输出文件配置
   applicationVariants.all { variant ->
        variant.outputs.each { output ->
            def outputFile = output.outputFile
            if (outputFile != null && outputFile.name.endsWith('.apk')) {
                def dirName = outputFile.parent // 输出文件夹所在的位置

                // 文件名修改
                def fileName = "app-${output.processResources.variantName}-${defaultConfig.versionName}-${variant.flavorName}.apk"

                // 比如不想这么麻烦,直接在后面加上版本号也行:
                // def fileName = outputFile.name.replace(".apk", "-${defaultConfig.versionName}.apk")

                output.outputFile = new File(dirName, fileName)
            }
        }
    }
}

Android Studio APK输出文件配置

android{
  applicationVariants.all { variant ->
        variant.outputs.each { output ->
            def outputFile = output.outputFile
            if (outputFile != null && outputFile.name.endsWith('.apk')) {
                def dirName = APP_OUTPUT_DIR + "v${defaultConfig.versionName}"
                def fileName = outputFile.name.replace(".apk", "-${defaultConfig.versionName}.apk")
                output.outputFile = new File(dirName, fileName)
            }
        }
    }

APP_OUTPUT_DIRgradle.properties,如:

#小盘beta版 输出路径
APP_OUTPUT_DIR = F\:\\100bei\\xiaopan\\dev\\

JS 数组组合算法

先来看看要实现什么样的效果

var data = [[1,2,3],[4,5,6]];

var result =  data.group();

// 结果:[1,4] [1,5], [1,6],[2,4],[2,5],[2,6]....以此类推;

【源码】扩展Array方法:

Array.prototype.group = function() {
    var $this = this;
    var result = new Array();
    var findNext = function next(currentIndex, arr) {
        var item = $this[currentIndex];
        var tempArr = arr;
        var index = currentIndex;
        for (var k = 0; k &lt; item.length; k++) {
            if (!arr) tempArr = new Array();
            tempArr[tempArr.length] = item[k];
            if (tempArr.length == $this.length) {
                result[result.length] = tempArr;
                tempArr = arr.slice();
                tempArr.length = tempArr.length - 1;
                continue;
            }
            index++;
            if (index &lt; $this.length &amp;&amp; next(index, tempArr)) {
                index--; // 恢复递归之前的索引
                tempArr = arr ? arr.slice() : new Array();
                tempArr.length = tempArr.length &gt; 0 ? tempArr.length - ($this.length - index) : 0;
            }
        }
        return true;
    };

    findNext(0);

    return result;
}

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进行数据处理:

交易模块

本文参考链接:

把自己的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库中去看看是否上传成功了。