目录
1. 预先准备
1.1. 先看一下最终效果图
1.2. 确认默认启动界面仍是登录界面
下面的章节会先从登录逻辑的完整实现开始讲起。
有些同学在调试的过程中为了方便,在AndroidManifest中把默认启动项改成了MainActivity。所以请预先确认将启动项设置为LoginActivity(如图所示):
(PS:换用了新的主题,具体可见:http://blog.csdn.net/likianta/article/details/78701481 )
1.3. 创建LogUtil.java用来记录日志
由于在项目测试期间会留下大量的日志信息,会给将来项目的正式发布带来麻烦。所以现在开始编写一个LogUtil用来管理日志的输出与关闭,主要功能如下:
- 使用方便,只传一个参量(String msg)即可
- 可以通过调节阀值来控制打印等级,甚至关闭所有打印
代码如下(参考:LogUtil:郭霖《第一行代码 第二版》p464):
LogUtil.java
import android.util.Log;
/**
* Created by Likianta_DoDoRa on 2017/11/30 0030.
*/
public class LogUtil {
/**
* 自制Log管理工具,通过设定level的等级,来显示自己想看的Log,而且只需要传入msg字段即可,tag统一为默认的“likianta_ddr”。
* level设的越高,显示的信息就越危险。不过level设为NOTHING(=6)的时候,该Log工具将会关闭。因此在apk正式发布前,应把level调到NOTHING,从而把LogUtil关闭掉。
*/
public static final int VERBOSE = 1;
public static final int DEBUG = 2;
public static final int INFO = 3;
public static final int WARN = 4;
public static final int ERROR = 5;
public static final int NOTHING = 6;
public static final String TAG = "likianta_ddr"; //你也可以更改为自己想要的TAG
public static int level = VERBOSE;
public static void v(String msg){
if (level <= VERBOSE){
Log.d(TAG,msg);
}
}
public static void d(String msg){
if (level <= DEBUG){
Log.d(TAG,msg);
}
}
public static void i(String msg){
if (level <= INFO){
Log.d(TAG,msg);
}
}
public static void w(String msg){
if (level <= WARN){
Log.d(TAG,msg);
}
}
public static void e(String msg){
if (level <= ERROR){
Log.d(TAG,msg);
}
}
}
使用方法:
LogUtil.d("输入一些文字");
- 打开Logcat,在过滤器中输入自定义的TAG名称以查看日志信息
1.4. 添加以下依赖库
本章中新增的有:Glide(用于加载图片)、Glide-transformation(Glide增强工具)、Gson(用于处理Json数据):
app:build.gradle
dependencies {
...
compile 'me.relex:circleindicator:1.2.2'
compile 'de.hdodenhof:circleimageview:2.2.0'
compile 'com.github.bumptech.glide:glide:3.7.0'
compile 'jp.wasabeef:glide-transformations:2.0.0'
compile 'com.google.code.gson:gson:2.8.0'
}
配置完成注意点击“Sync Now”(后面的文章不再提醒此操作)。
下面开始正文。
2. 使用SecuritySharedPreference完成登录的加/解密过程
2.1. 配置SecuritySharedPreference
这里本来想用Github上的SecuredPreferenceStore项目,但是发现初始化函数会出错,并且EncrtyManager也无法正常使用,所以就放弃了。
后来使用@随便想个名字 的代码,功能和之前想用的SecuredPreferenceStore相同,并且支持旧数据的迁移,所以就完全照搬了:
作者的博客地址:【Android安全】自带加密光环的SharedPreference - CSDN博客 http://blog.csdn.net/voidmain_123/article/details/53338393
1. 新建SecuritySharedPreference.java
(以下代码直接复制即可):
import android.annotation.TargetApi;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import android.util.Log;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* 自动加密SharedPreference
* Created by Max on 2016/11/23.
*/
public class SecuritySharedPreference implements SharedPreferences {
private SharedPreferences mSharedPreferences;
private static final String TAG = SecuritySharedPreference.class.getName();
private Context mContext;
/**
* constructor
* @param context should be ApplicationContext not activity
* @param name file name
* @param mode context mode
*/
public SecuritySharedPreference(Context context, String name, int mode){
mContext = context;
if (TextUtils.isEmpty(name)){
mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
} else {
mSharedPreferences = context.getSharedPreferences(name, mode);
}
}
@Override
public Map<String, String> getAll() {
final Map<String, ?> encryptMap = mSharedPreferences.getAll();
final Map<String, String> decryptMap = new HashMap<>();
for (Map.Entry<String, ?> entry : encryptMap.entrySet()){
Object cipherText = entry.getValue();
if (cipherText != null){
decryptMap.put(entry.getKey(), entry.getValue().toString());
}
}
return decryptMap;
}
/**
* encrypt function
* @return cipherText base64
*/
private String encryptPreference(String plainText){
return EncryptUtil.getInstance(mContext).encrypt(plainText);
}
/**
* decrypt function
* @return plainText
*/
private String decryptPreference(String cipherText){
return EncryptUtil.getInstance(mContext).decrypt(cipherText);
}
@Nullable
@Override
public String getString(String key, String defValue) {
final String encryptValue = mSharedPreferences.getString(encryptPreference(key), null);
return encryptValue == null ? defValue : decryptPreference(encryptValue);
}
@Nullable
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
final Set<String> encryptSet = mSharedPreferences.getStringSet(encryptPreference(key), null);
if (encryptSet == null){
return defValues;
}
final Set<String> decryptSet = new HashSet<>();
for (String encryptValue : encryptSet){
decryptSet.add(decryptPreference(encryptValue));
}
return decryptSet;
}
@Override
public int getInt(String key, int defValue) {
final String encryptValue = mSharedPreferences.getString(encryptPreference(key), null);
if (encryptValue == null) {
return defValue;
}
return Integer.parseInt(decryptPreference(encryptValue));
}
@Override
public long getLong(String key, long defValue) {
final String encryptValue = mSharedPreferences.getString(encryptPreference(key), null);
if (encryptValue == null) {
return defValue;
}
return Long.parseLong(decryptPreference(encryptValue));
}
@Override
public float getFloat(String key, float defValue) {
final String encryptValue = mSharedPreferences.getString(encryptPreference(key), null);
if (encryptValue == null) {
return defValue;
}
return Float.parseFloat(decryptPreference(encryptValue));
}
@Override
public boolean getBoolean(String key, boolean defValue) {
final String encryptValue = mSharedPreferences.getString(encryptPreference(key), null);
if (encryptValue == null) {
return defValue;
}
return Boolean.parseBoolean(decryptPreference(encryptValue));
}
@Override
public boolean contains(String key) {
return mSharedPreferences.contains(encryptPreference(key));
}
@Override
public SecurityEditor edit() {
return new SecurityEditor();
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
mSharedPreferences.registerOnSharedPreferenceChangeListener(listener);
}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
mSharedPreferences.unregisterOnSharedPreferenceChangeListener(listener);
}
/**
* 处理加密过渡
*/
public void handleTransition(){
Map<String, ?> oldMap = mSharedPreferences.getAll();
Map<String, String> newMap = new HashMap<>();
for (Map.Entry<String, ?> entry : oldMap.entrySet()){
Log.i(TAG, "key:"+entry.getKey()+", value:"+ entry.getValue());
newMap.put(encryptPreference(entry.getKey()), encryptPreference(entry.getValue().toString()));
}
Editor editor = mSharedPreferences.edit();
editor.clear().commit();
for (Map.Entry<String, String> entry : newMap.entrySet()){
editor.putString(entry.getKey(), entry.getValue());
}
editor.commit();
}
/**
* 自动加密Editor
*/
final class SecurityEditor implements Editor {
private Editor mEditor;
/**
* constructor
*/
private SecurityEditor(){
mEditor = mSharedPreferences.edit();
}
@Override
public Editor putString(String key, String value) {
mEditor.putString(encryptPreference(key), encryptPreference(value));
return this;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
final Set<String> encryptSet = new HashSet<>();
for (String value : values){
encryptSet.add(encryptPreference(value));
}
mEditor.putStringSet(encryptPreference(key), encryptSet);
return this;
}
@Override
public Editor putInt(String key, int value) {
mEditor.putString(encryptPreference(key), encryptPreference(Integer.toString(value)));
return this;
}
@Override
public Editor putLong(String key, long value) {
mEditor.putString(encryptPreference(key), encryptPreference(Long.toString(value)));
return this;
}
@Override
public Editor putFloat(String key, float value) {
mEditor.putString(encryptPreference(key), encryptPreference(Float.toString(value)));
return this;
}
@Override
public Editor putBoolean(String key, boolean value) {
mEditor.putString(encryptPreference(key), encryptPreference(Boolean.toString(value)));
return this;
}
@Override
public Editor remove(String key) {
mEditor.remove(encryptPreference(key));
return this;
}
/**
* Mark in the editor to remove all values from the preferences.
* @return this
*/
@Override
public Editor clear() {
mEditor.clear();
return this;
}
/**
* 提交数据到本地
* @return Boolean 判断是否提交成功
*/
@Override
public boolean commit() {
return mEditor.commit();
}
/**
* Unlike commit(), which writes its preferences out to persistent storage synchronously,
* apply() commits its changes to the in-memory SharedPreferences immediately but starts
* an asynchronous commit to disk and you won't be notified of any failures.
*/
@Override
@TargetApi(Build.VERSION_CODES.GINGERBREAD)
public void apply() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) {
mEditor.apply();
} else {
commit();
}
}
}
}
2. 新建EncryptUtil.java
:
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Build;
import android.provider.Settings;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Log;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
/**
* AES加密解密工具
* @author Max
* 2016年11月25日15:25:17
*/
public class EncryptUtil {
private String key;
private static EncryptUtil instance;
private static final String TAG = EncryptUtil.class.getSimpleName();
private EncryptUtil(Context context){
String serialNo = getDeviceSerialNumber(context);
//加密随机字符串生成AES key
key = SHA(serialNo + "#$ERDTS$D%F^Gojikbh").substring(0, 16);
Log.e(TAG, key);
}
/**
* 单例模式
* @param context context
* @return
*/
public static EncryptUtil getInstance(Context context){
if (instance == null){
synchronized (EncryptUtil.class){
if (instance == null){
instance = new EncryptUtil(context);
}
}
}
return instance;
}
/**
* Gets the hardware serial number of this device.
*
* @return serial number or Settings.Secure.ANDROID_ID if not available.
*/
@SuppressLint("HardwareIds")
private String getDeviceSerialNumber(Context context) {
// We're using the Reflection API because Build.SERIAL is only available
// since API Level 9 (Gingerbread, Android 2.3).
try {
String deviceSerial = (String) Build.class.getField("SERIAL").get(null);
if (TextUtils.isEmpty(deviceSerial)) {
return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
} else {
return deviceSerial;
}
} catch (Exception ignored) {
// Fall back to Android_ID
return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
}
}
/**
* SHA加密
* @param strText 明文
* @return
*/
private String SHA(final String strText){
// 返回值
String strResult = null;
// 是否是有效字符串
if (strText != null && strText.length() > 0){
try{
// SHA 加密开始
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
// 传入要加密的字符串
messageDigest.update(strText.getBytes());
byte byteBuffer[] = messageDigest.digest();
StringBuffer strHexString = new StringBuffer();
for (int i = 0; i < byteBuffer.length; i++){
String hex = Integer.toHexString(0xff & byteBuffer[i]);
if (hex.length() == 1){
strHexString.append('0');
}
strHexString.append(hex);
}
strResult = strHexString.toString();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
}
return strResult;
}
/**
* AES128加密
* @param plainText 明文
* @return
*/
public String encrypt(String plainText) {
try {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKeySpec keyspec = new SecretKeySpec(key.getBytes(), "AES");
cipher.init(Cipher.ENCRYPT_MODE, keyspec);
byte[] encrypted = cipher.doFinal(plainText.getBytes());
return Base64.encodeToString(encrypted, Base64.NO_WRAP);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* AES128解密
* @param cipherText 密文
* @return
*/
public String decrypt(String cipherText) {
try {
byte[] encrypted1 = Base64.decode(cipherText, Base64.NO_WRAP);
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKeySpec keyspec = new SecretKeySpec(key.getBytes(), "AES");
cipher.init(Cipher.DECRYPT_MODE, keyspec);
byte[] original = cipher.doFinal(encrypted1);
String originalString = new String(original);
return originalString;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
作者采用的加密逻辑为:
我们实现了SharedPreference接口和SharedPreference.Editor接口,重写了保存数据和取出数据的方法,我们在保存数据和取出数据的时候加了加解密层,这样可以保证我们在操作自定义的SharedPreference时候像调用原生的一样简单。
我采用的是AES128的加密方式,首先获取当前设备的序列号,然后拼接一个随机字符串,生成hash值,作为AES加密的key
现在还不用研究这些代码,只要记住SecuritySharedPreference的使用方法和普通的SharedPreferences相同就行了。(EncrypUtil本章中暂时用不到)
在以后的章节中我们会逐渐学习使用自定义主密码+签名的方式来生成AES256加密级别的key,这将会帮助我们实现密码被盗后通过更改签名来阻止数据被破解。
下面我们开始把第一章中使用到的SharedPreferences存取全部替换为SecuritySharedPreference:
2.2. LoginActivity重制
**LoginActivity.java
:
(注:如果要全部复制,注意第一行的包名路径要改为你自己的package。后面的文章将不再提醒。)**
package com.likianta.anykey;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.text.method.PasswordTransformationMethod;
import android.view.KeyEvent;
import android.view.View;
import android.view.animation.AlphaAnimation;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
/**
* Created by Likianta_DoDoRa on 2017/11/9 0009.
*/
public class LoginActivity extends AppCompatActivity {
final String DEFAULT = "44d5fz533393";
private EditText etPassword;
private TextView loginWelcome;
private Button loginOn;
private String passwordValue;
private SharedPreferences sharedPreferences;
private SecuritySharedPreference securitySharedPreference;
private EncryptUtil encryptUtil;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);
//隐藏标题栏
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
//绑定按钮
etPassword = (EditText) findViewById(R.id.et_password);
loginWelcome = (TextView) findViewById(R.id.login_welcome);
loginOn = (Button) findViewById(R.id.login_on);
//以密文形式显示你的输入
etPassword.setTransformationMethod(PasswordTransformationMethod.getInstance());
/*
//这些代码可以删除了
sharedPreferences = this.getSharedPreferences("UserInfo", Context.MODE_PRIVATE);
final String saved_userPassword = sharedPreferences.getString("UserPassword", "N/A");
final SharedPreferences.Editor editor = sharedPreferences.edit(); //SharedPreferences是抽象类,不能直接编辑,因此创造一个editor来负责sp的读写操作
*/
//SecuritySharedPreference
securitySharedPreference = new SecuritySharedPreference(getApplicationContext(), "user_login", MODE_PRIVATE); //PRIVATE模式表示只有本应用可以读写该实例
final String saved_masterPassword = securitySharedPreference.getString("MasterPassword", DEFAULT); //第一个参数是键,第二个参数是get不到该键时的返回值
//我们在第一次安装该应用时,sharedPreferences肯定啥都get不到,所以就会返回第二个参数DEFAULT,这是我们判断用户是不是初次使用的重要依据
final SecuritySharedPreference.Editor editor = securitySharedPreference.edit();
LogUtil.d("Your masterPassword is: " + saved_masterPassword);
//判断该用哪条欢迎语
changeLoginWelcome(saved_masterPassword);
//首次登录判断的业务逻辑为:判断密码是不是DEFAULT,是的话就是首次登录,此时文本框的欢迎词就变了;不是的话就什么事情也不做
//触发点击事件,监听登录按钮
loginOn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
passwordValue = etPassword.getText().toString().trim(); //trim()函数可以消除字符串前后的空白符,避免用户手误多按一个空格
if (saved_masterPassword.equals(DEFAULT)) {
//首次使用的业务逻辑
editor.putString("MasterPassword", passwordValue).apply();
Toast.makeText(LoginActivity.this, "创建成功", Toast.LENGTH_SHORT).show();
//跳转至主界面
Intent intent = new Intent(LoginActivity.this, MainActivity.class);
LoginActivity.this.startActivity(intent);
finish();
} else {
//非首次登录时的常规业务
if (passwordValue.equals(saved_masterPassword)) {
Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show();
//跳转至主界面
Intent intent = new Intent(LoginActivity.this, MainActivity.class);
LoginActivity.this.startActivity(intent);
finish();
} else {
//密码错误时的业务
changeLoginWelcome("WRONG_CODE");
etPassword.setText(""); //重置密码框为空
//让输入框重新获取焦点
etPassword.setFocusable(true);
etPassword.setFocusableInTouchMode(true);
etPassword.requestFocus();
//通过调用输入管理器来弹出软键盘
InputMethodManager inputMethodManager = (InputMethodManager) etPassword.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.showSoftInput(etPassword, 0);
}
}
}
});
etPassword.setOnKeyListener(new View.OnKeyListener() {
@Override
public boolean onKey(View view, int i, KeyEvent keyEvent) {
if (i == keyEvent.KEYCODE_ENTER) {
loginOn.callOnClick(); //callOnClick是performClick的简化版,适合简单触发点击监听事件
return true;
}
return false;
/*
* 另外再设置一个监听软键盘输入,当输入Enter的时候,也能触发上面的登录按钮
* 关于返回值的说明
* 返回值为true,表示事件已完全处理,系统无需再处理此键
* 返回值为false,表示事件处理过后,还要交给系统继续处理
* 参考此回答:https://zhidao.baidu.com/question/1430105248859125459.html
*/
}
});
}
// Select welcome words.
public void changeLoginWelcome(String userCode) {
switch (userCode) {
case DEFAULT:
// 首次使用时的欢迎语
loginWelcome.setText("首次使用\n请创建你的主密码");
break;
case "WRONG_CODE":
// 密码输入错误时的欢迎语
loginWelcome.setText("密码错误\n请重新登录"); // 在“欢迎词”中显示出错提示
loginWelcome.setTextColor(0xFFF4297E);
final AlphaAnimation alphaAnimation = new AlphaAnimation(1.0f, 0.6f); // 不透明度的参数取值是小数,范围在0-1之间,0表示完全透明,1表示完全显示
alphaAnimation.setDuration(500); // 动画持续时间
alphaAnimation.setFillAfter(true); // 表示动画停留在最终完成的状态上
loginWelcome.setAnimation(alphaAnimation);
alphaAnimation.startNow();
break;
}
}
//监听按下的按键,如果按下了返回键,则退出登录界面
@Override
public boolean onKeyDown(int keyCode, KeyEvent keyEvent) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
LoginActivity.this.finish();
return true;
}
return super.onKeyDown(keyCode, keyEvent);
}
}
至此登录流程已完整实现。下面开始本章的重头戏——表单数据的绑定和加载。
3. 填表数据的绑定和加载
注:这里给一些文件改名了,比如“page_item_all”和“page_item_new”改成了“page_card_list”和“page_card_new”(使用shift+f6可以全局改名)。
另外删掉了一些没有用到的类,具体变更可参照下图:
3.1. 右分页布局
在04章中已经制作好了右分页的布局,这里重新贴出整体的代码:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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="match_parent"
android:layout_height="match_parent"
android:background="@color/mycolorBackground"
android:padding="5dp">
<!-- 最外层是一个RelativeLayout,内边距5dp,背景色与左分页背景同色 -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@id/userInfoSave"
android:layout_marginBottom="5dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- 这里同样使用的是RelativeLayout。
因为考虑到以后可能还需要在上面贴一些控件等……所以用相对布局更好一些-->
<!-- 头像的背景,根据头像主色调来生成(方法在函数中实现) -->
<ImageView
android:id="@+id/userHeadBackground"
android:layout_width="match_parent"
android:layout_height="108dp"
android:background="#8e8e8e" />
<!-- 使用开源控件CircleImageView制作圆形头像 -->
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/userHead"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_centerHorizontal="true"
android:layout_marginTop="60dp"
android:padding="5dp" />
<!-- 常规表单填写 -->
<TableLayout
android:id="@+id/table1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/userHead"
android:padding="30dp"
android:shrinkColumns="1"
android:stretchColumns="1">
<!-- 第一行,标题栏 -->
<TableRow>
<TextView
android:layout_margin="5dp"
android:text="标题"
android:textSize="16sp" />
<EditText
android:id="@+id/userTitle"
android:layout_height="30dp"
android:layout_margin="5dp"
android:layout_span="5"
android:background="@drawable/bg_edittext"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textColorHint="@color/colorAccent"
android:textSize="16sp" />
<!-- 分组按钮 -->
<ImageView
android:id="@+id/userTitleGroup"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="5dp"
android:padding="2dp"
android:src="@drawable/icon_folder" />
</TableRow>
<!-- 第二行,帐号栏 -->
<TableRow>
<TextView
android:layout_margin="5dp"
android:text="帐号"
android:textSize="16sp" />
<EditText
android:id="@+id/userName"
android:layout_height="30dp"
android:layout_margin="5dp"
android:layout_span="5"
android:background="@drawable/bg_edittext"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textSize="16sp" />
<!-- 这个按钮是用来关联同标题的小号的,点击后会让你设置哪个是大号,哪个是隐私号,哪些是小号……
小号还可以手动排序 -->
<ImageView
android:id="@+id/userNameLink"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="5dp"
android:padding="2dp"
android:src="@drawable/icon_attach" />
</TableRow>
<!-- 第三行,密码栏 -->
<TableRow>
<TextView
android:layout_margin="5dp"
android:text="密码"
android:textSize="16sp" />
<EditText
android:id="@+id/userPassword"
android:layout_height="30dp"
android:layout_margin="5dp"
android:layout_span="5"
android:background="@drawable/bg_edittext"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textSize="16sp" />
<!-- 随机密码生成按钮 -->
<ImageView
android:id="@+id/userPasswordRandom"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="5dp"
android:padding="4dp"
android:src="@drawable/icon_random" />
</TableRow>
<!-- 第四行,比较特殊,是一排绑定按钮 -->
<TableRow>
<TextView
android:layout_gravity="center_vertical"
android:layout_margin="5dp"
android:text="绑定"
android:textSize="20sp" />
<!-- 这些绑定按钮可自定义还可横向滚动 -->
<HorizontalScrollView
android:id="@+id/userBoundBar"
android:layout_gravity="center"
android:layout_span="5">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent">
<ImageView
android:id="@+id/userBoundQq"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="15dp"
android:src="@drawable/icon_bound_qq" />
<ImageView
android:id="@+id/userBoundWeixin"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="15dp"
android:src="@drawable/icon_bound_weixin" />
<ImageView
android:id="@+id/userBoundWeibo"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="15dp"
android:src="@drawable/icon_bound_weibo" />
<ImageView
android:id="@+id/userBoundPhone"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="15dp"
android:src="@drawable/icon_bound_phone" />
<ImageView
android:id="@+id/userBoundEmail"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="15dp"
android:src="@drawable/icon_bound_email" />
<ImageView
android:id="@+id/userBoundWangyi"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="15dp"
android:src="@drawable/icon_bound_wangyi" />
</LinearLayout>
</HorizontalScrollView>
<!-- 对绑定按钮进行详细设置,比如默认值、同类型小号(红色显示)、新增自定义字段等 -->
<ImageView
android:id="@+id/userBoundMore"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="5dp"
android:src="@drawable/icon_more" />
</TableRow>
<!-- 第五行,URL -->
<TableRow>
<TextView
android:layout_margin="5dp"
android:text="URL"
android:textSize="16sp" />
<!-- 默认是单行输入的 -->
<EditText
android:id="@+id/userUrl"
android:layout_height="30dp"
android:layout_margin="5dp"
android:layout_span="5"
android:background="@drawable/bg_edittext"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textSize="16sp" />
<!-- 通过此按钮可以切换单/多行输入模式 -->
<ImageView
android:id="@+id/userUrlMultilines"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_margin="5dp"
android:src="@drawable/icon_add2" />
</TableRow>
<!-- 第六行,备注栏 -->
<TableRow>
<!-- 备注栏是多行输入的 -->
<EditText
android:id="@+id/userNote"
android:layout_marginTop="15dp"
android:layout_span="7"
android:background="@drawable/bg_edittext"
android:hint="请在此输入备注"
android:padding="16dp"
android:textSize="14sp" />
</TableRow>
</TableLayout>
<!-- 这个小按钮比较特殊,是专为备注栏服务的,功能是点击一下生成一条分割线
默认状态是隐藏的,只有当备注栏获取焦点时才会出现 -->
<ImageView
android:id="@+id/userNoteSlash"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_alignBottom="@id/table1"
android:layout_alignParentEnd="true"
android:layout_marginBottom="40dp"
android:layout_marginEnd="10dp"
android:background="#0000"
android:padding="6dp"
android:src="@drawable/icon_slash" />
</RelativeLayout>
</ScrollView>
<!-- 保存按钮 -->
<Button
android:id="@+id/userInfoSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:background="@drawable/bg_button"
android:text="保存"
android:textSize="16sp"
android:visibility="visible" />
</RelativeLayout>
布局中用到的drawable资源可以在此下载:链接: https://pan.baidu.com/s/1geFpBY7 密码: ivce
3.2. 保存用户数据
这里分为三部分来考虑:
- 点击保存按钮,产生新的卡片数据
- 在cardList中添加此卡片并出现在左分页中
- 在SecuritySharedPreference中保存卡片数据(保存到
/data/data//my_prefs/包名/card_data.xml
文件中)
那么首先我们来看点击保存按钮是的业务:
3.2.1. 点击“保存”按钮时的业务
1. MainActivity.java
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
//以下变量是与分页相关的变量
private TextView titleAll; //标题文字“全部”
private TextView titleNew; //标题文字“新增”
private CircleIndicator indicator; //滚动指示器
private View page1; //左分页
private View page2; //右分页
private ViewPager viewPager; //控制分页逻辑的容器
private ArrayList<View> pageList; //装载分页元素的容器
//以下变量是与左分页相关的控件
private RecyclerView recyclerView1; //卡片列表,注意改名啦(原先名字是“recyclerView”)
public CardAdapter cardAdapter;
public List<Card> cardList = new ArrayList<Card>(); //卡片数据
//以下变量是与右分页相关的控件
private Button saveUserButton; //右分页“保存”按钮
@Override
protected void onCreate(Bundle savedInstanceState) {
...
//用LayoutInflater来绑定布局
LayoutInflater inflater = getLayoutInflater();
page1 = inflater.inflate(R.layout.page_card_list, null); //预加载左分页
page2 = inflater.inflate(R.layout.page_card_new, null); //预加载右分页
pageList = new ArrayList<View>(); //pageList被实例化为装载View元素的数组
pageList.add(page1);
pageList.add(page2);
//add的先后顺序不要搞错,先add的就是array[0]位置的元素了
...
//绑定右分页的按钮
saveUserButton = (Button) page2.findViewById(R.id.userInfoSave);
//监听按钮点击
...
saveUserButton.setOnClickListener(this);
}
@Override
public void onClick(View view) {
switch (view.getId()) {
...
case R.id.userInfoSave:
//点击右分页的保存按钮
LogUtil.d("You clicked userInfoSave button.");
//首先判断标题是不是空的,空的话必须填写标题
if (new SaveUserInfo(page2).isTitleEmpty()) {
//SaveUserInfo.java是重点,会新建一个类来写
Toast.makeText(MainActivity.this, "标题不能为空!", Toast.LENGTH_SHORT).show();
break;
}
Card card = new SaveUserInfo(page2).getNewCard();
cardList.add(0, card); //在零号位(也就是第一位)添加这张新卡片
cardAdapter.notifyItemInserted(0); //添加后要给RecyclerView释放一个更新信号。具体方法写在CardAdapter.java里
viewPager.setCurrentItem(0); //自动跳转到左分页
recyclerView1.scrollToPosition(0); //自动跳转到列表首部,方便用户看到自己新增的卡片
break;
}
}
}
2. 新建SaveUserInfo.java
:
(注:如果要全部复制,注意第一行的包名路径要改为你自己的package。后面的文章将不再提醒。)
package com.likianta.anykey;
import android.content.Context;
import android.content.SharedPreferences;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import com.google.gson.Gson;
import de.hdodenhof.circleimageview.CircleImageView;
/**
* Created by Likianta_DoDoRa on 2017/11/27 0027.
*/
public class SaveUserInfo extends MainActivity {
private EditText userTitle;
private EditText userName;
private EditText userPassword;
private EditText userUrl;
private EditText userNote;
private CircleImageView userHead;
private String uTitle;
private String uName;
private String uPassword;
private String uUrl;
private String uNote;
private Card card;
public SaveUserInfo(View pageview) {
LogUtil.d("向SaveUserInfo()传入右分页布局");
userTitle = (EditText) pageview.findViewById(R.id.userTitle);
userName = (EditText) pageview.findViewById(R.id.userName);
userPassword = (EditText) pageview.findViewById(R.id.userPassword);
userUrl = (EditText) pageview.findViewById(R.id.userUrl);
userNote = (EditText) pageview.findViewById(R.id.userNote);
userHead = (CircleImageView) pageview.findViewById(R.id.userHead);
uTitle = userTitle.getText().toString();
uName = userName.getText().toString();
uPassword = userPassword.getText().toString();
uUrl = userUrl.getText().toString();
uNote = userNote.getText().toString();
LogUtil.d("User note is " + uNote);
getNewCard(); //生成新卡片的操作
}
//检测保存时的标题栏的文字是否为空,空的话禁止保存并提醒填写
public boolean isTitleEmpty() {
if (uTitle.equals("")) {
userTitle.setHint("请输入标题");
userTitle.setFocusable(true);
userTitle.setFocusableInTouchMode(true);
userTitle.requestFocus();
//通过调用输入管理器来弹出软键盘
InputMethodManager inputMethodManager = (InputMethodManager) userTitle.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.showSoftInput(userTitle, 0);
return true;
}
return false;
}
public Card getNewCard() {
String uSummary = uName + "\n" + uPassword + "\n" + uNote; //卡片的摘要=用户名+密码+备注
card = new Card(uTitle, uSummary); //目前我们用到的卡片属性只有这两个,后面会逐渐补充完整
return card;
}
}
还记得刚才在MainActivity中点击“保存”按钮时的业务代码吗?
然后CardAdapter中还需要添加相关的代码才行:
3. CardAdapter.java
public class CardAdapter extends RecyclerView.Adapter<CardAdapter.ViewHolder> {
...
public void add(int position, Card card) {
cardList.add(position, card);
notifyItemInserted(position);
}
}
当然对于一个CardAdapter来说,光有添加卡片的业务是不行的,还要有删除卡片的业务:
import android.widget.Toast;
public class CardAdapter extends RecyclerView.Adapter<CardAdapter.ViewHolder> {
...
public void add(int position, Card card) {
...
}
public void remove(Context context, int position) {
if (position < 0) {
Toast.makeText(context, "列表中并没有结果!", Toast.LENGTH_SHORT).show();
} else {
cardList.remove(position);
notifyItemRemoved(position);
}
}
}
其实还有更多的操作(比如删除全部卡片)没写,不过那些目前都用不上,等到以后有需求再添加。
至此,我们已经完成了最初构想的两大部分了:
**1. 点击保存按钮,产生新的卡片数据
2. 在cardList中添加此卡片并出现在左分页中**
接下来要做的是完成最后一部分——在SecuritySharedPreference中保存卡片数据。
3.2.2. 保存卡片数据到本地(SavedToMySharedPrefs)
新建SavedToMySharedPrefs.java
:
(注:如果要全部复制,注意第一行的包名路径要改为你自己的package。后面的文章将不再提醒。)
package com.likianta.anykey;
import android.content.Context;
import android.content.SharedPreferences;
import android.widget.Toast;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.util.ArrayList;
import java.util.List;
import static android.content.Context.MODE_PRIVATE;
/**
* Created by Likianta_DoDoRa on 2017/12/2 0002.
*/
public class SavedToMySharedPrefs {
//建议在退出程序时调用,以将数据保存到文件中
private SecuritySharedPreference securitySharedPreference;
private SharedPreferences.Editor editor;
public SavedToMySharedPrefs(Context context, String preferenceName) {
securitySharedPreference = new SecuritySharedPreference(context, preferenceName, MODE_PRIVATE);
editor = securitySharedPreference.edit();
}
/**
* 获取cardList
*/
public List<Card> getCardData() {
List<Card> cardList = new ArrayList<>();
String strJson = securitySharedPreference.getString("Card", null);
LogUtil.d("从已保存的card_data文件中获取列表!strJson="+strJson);
if (null == strJson) {
LogUtil.d("获取到的json为空!");
return cardList;
}
Gson gson = new Gson();
cardList = gson.fromJson(strJson, new TypeToken<List<Card>>() {
}.getType());
for (Card card:cardList){
LogUtil.d("Card info 1 "+card.getCardTitle());
LogUtil.d("Card info 2 "+card.getCardSummary());
}
LogUtil.d("获取到的cardList是"+cardList);
return cardList;
}
/**
* 保存cardList
*/
public void setCardData(List<Card> cardList) {
if (null == cardList || cardList.size() <= 0) {
return;
}
Gson gson = new Gson();
//转换成json数据,再保存
String strJson = gson.toJson(cardList);
editor.clear();
editor.putString("Card", strJson);
editor.apply();
}
/**
* 获取userItemList
*/
/**
* 保存userItemList
*/
}
注意代码中我写了两个获取和两个保存方法(后面两个还没有写相关代码)。
这里将Card和UserItem分开考虑,它们的关系为:
card_data.xml(加密保存) | user_data.xml(加密保存) | |
特点 | 内容量少,体积更小,适合快速装载,可用于全文搜索 | 内容详细,点击卡片后展示,用于详情页显示 |
包含对象 | 1. 标题 2. 摘要 3. 头像 4.标识码 | 1. 标题 2. 用户名(帐号) 3. 密码 4. Url 5. 备注 6. 头像及背景 7. 扩展信息(绑定关系、密保信息、二级密码、用户昵称等) 8. 自动生成的信息等 9.标识码 |
什么是标识码? | 每个card与useritem通过标识码建立唯一联系。 标识码的生成规则(暂定):标题(String)+账号等级(int)(示意图如下) ![]() 这个账号管理布局是通过点击右分页的“@id/userLink”按钮打开的 |
本章只讲cardList的绑定和加载,UserItem会在之后的学习中补上。
4. 运行测试
为了测试我们的数据保存是否成功,我们需要完成以下功能:
- 退出时加密保存当前数据到“/data/data/com.likianta.anykey/my_prefs/card_data.xml”(这里用一个test按钮实现)
- 再次启动app,进入主界面后,需要从本地(card_data.xml)取出卡片数据
具体代码如下:
1. 在activity_main.xml
中添加一个测试按钮:
activity_main.xml
<RelativeLayout ...>
...
<Button
android:id="@+id/test_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="test save"
android:layout_alignParentTop="true"
android:layout_alignParentStart="true"/>
</RelativeLayout>
2. 在MainActivity.java
中添加相关业务:
MainActivity.java
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
@Override
protected void onCreate(Bundle savedInstanceState) {
...
//test save
Button button = (Button)findViewById(R.id.test_save);
button.setOnClickListener(this);
//为分页加载卡片列表
PageRender();
}
@Override
public void onClick(View view) {
switch (view.getId()) {
...
case R.id.test_save:
new SavedToMySharedPrefs(MainActivity.this,"card_data").setCardData(cardList);
MainActivity.this.finish();
break;
}
}
//渲染分页(即加载卡片列表)
public void PageRender() {
cardList = new SavedToMySharedPrefs(MainActivity.this,"card_data").getCardData(); //从本地文件加载卡片数据
cardAdapter = new CardAdapter(cardList); //将数组数据适配为卡片数据
recyclerView1 = (RecyclerView) page1.findViewById(R.id.recyclerView);
recyclerView1.setLayoutManager(new LinearLayoutManager(this)); //为recyclerView设置线性布局,使内部元素呈线性排列
recyclerView1.setAdapter(cardAdapter); //开始加载卡片
}
}
为了保证测试能够成功,先把手机(或者模拟器)上的app给卸载掉。
然后启动调试:
至此,本章的主要内容已经讲完了。
本章的代码虽然特别多,但主要只做了两件事:一个是通过SecuritySharedPreference重新完成了登录的流程;另一个则是成功实现了卡片数据的保存以及卡片列表的加载更新。
当然我们还有很多必要的事情没有去做,比如删除卡片、点击保存按钮要把当前表单清空、以及最容易被人忽略的布局优化。
随着项目逐渐变大,对于内存的要求也开始增加。此时可能已经出现使用真机调试会发生OOM(Out
of Memory)的崩溃,下面在更新章节中提到的一些办法会在一定程度上避免OOM的问题(不同手机可能遇到的状况不同。我的红米Note2手机经过下面的修改在调试时已经不再崩溃了)。之后我们会一边继续完善当前的代码,一边严格控制运行消耗,特别是图像加载导致的oom问题需要多加注意。
在接下来的章节中将开始制作分组列表、底部导航按钮以及优化右分页布局,以减轻GPU渲染压力。
5. 更新修复
5.1. 一些小的更新修复
1. card布局微调
主要修改了距离属性。整体代码如下:
card.xml
<?xml version="1.0" encoding="utf-8"?>
<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="match_parent"
android:layout_height="120dp"
android:layout_margin="5dp"
android:elevation="4dp"
app:cardCornerRadius="8dp">
<!--elevation表示卡片的高度
cardCornerRadius表示卡片四个角的弧度
xmlns:tools用于识别TextView中的\n换行符-->
<!--CardView本身是一个FrameLayout,显然不适合摆放控件。
因此为了充分利用空间,要内建一个RelativeLayout来盛放子控件-->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--首先思路就是把RelativeLayout分为左中右三个部分,左边贴一个头像;中间占的面积最大,用来写标题和摘要;右边则放置功能按钮-->
<!--现在写的是中间的布局LinearLayout。在LinearLayout中上半部分显示标题,下半部分显示摘要-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="40dp"
android:layout_marginStart="80dp"
android:orientation="vertical">
<!--注意设定LinearLayout的方向为vertical-->
<!--标题,深色,字号较大,高度比重为40%-->
<TextView
android:id="@+id/cardTitle"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="4"
android:gravity="bottom"
android:padding="4dp"
android:text="TitleTest"
android:textColor="@color/mycolorText1"
android:textSize="32sp" />
<!--摘要,浅色或同色,字号比正常文字还要小,高度比重为60%,限制显示三行文字-->
<TextView
android:id="@+id/cardSummary"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="6"
android:ellipsize="end"
android:lines="3"
android:maxLines="3"
android:text="name: ______\npassword: ______\nnote: this view limited 3 lines" />
<!--maxLines表示最大行数,lines表示即使只有一行字也要占用三行字的高度空间,ellipsize表示多出的字数表示为省略号
参考:http://blog.csdn.net/qq_31403303/article/details/51506524-->
</LinearLayout>
<!--接下来写的是右边的布局,也是LinearLayout布局。从上到下依次显示三个按钮:菜单、复制name、复制password-->
<LinearLayout
android:layout_width="40dp"
android:layout_height="match_parent"
android:layout_alignParentEnd="true"
android:orientation="vertical"
android:padding="5dp">
<!--菜单按钮-->
<ImageView
android:id="@+id/cardMenu"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/icon_more" />
<!-- 复制用户名按钮 -->
<ImageView
android:id="@+id/copyName"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:layout_marginTop="22dp"
android:src="@drawable/shape_oval1" />
<!-- 复制密码按钮 -->
<ImageView
android:id="@+id/copyPassword"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:layout_marginTop="16dp"
android:src="@drawable/shape_oval1" />
</LinearLayout>
<!--不要忘了左边部分。之所以留待最后才写,是为了让头像以及分割线最后加载,这样就处于其它二者的上方了-->
<!--先做分割线,非常细,非均等分割(上窄下宽)-->
<TextView
android:layout_width="match_parent"
android:layout_height="0.4dp"
android:layout_marginTop="44dp"
android:background="#000000" />
<!--头像-->
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/cardHead"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginStart="10dp"
android:layout_marginTop="16dp"
android:src="@color/mycolorText2" />
</RelativeLayout>
</android.support.v7.widget.CardView>
2. 更新build.gradle库版本到最新
更新方式很简单:将鼠标移到compile代码上,AS会检测并提示“A newer version of … is available:25.4.0 …”(如图所示),只要把“available”后面的版本填上去,再点击“Sync Now”即可同步到最新。
5.2. OOM(内存溢出)问题的临时解决方案
由于在真机调试时发现应用一进入就发生oom崩溃,可以采取以下方法来暂时解决该问题:
在AndroidManifest.xml
中添加一行代码:
<manifest ...>
<application
android:largeHeap="true"
...>
</application>
</manifest>
虽然解决了一打开就崩溃的问题,但真正问题还是没有得到解决:
当我们左右滑动页面的时候,会发现滑动到右分页掉帧严重,这是因为GPU压力过大造成的,只能通过优化布局和图像加载来真正地解决。
在下章中将详细介绍如何通过优化右分页布局来解决此问题。
相关参考
- LitePal:郭霖《第一行代码 第二版》p229
- LogUtil:郭霖《第一行代码 第二版》p464
- android 怎么获取字符串中指定的字符_百度知道 https://zhidao.baidu.com/question/456088699070757285.html
- 可动态显示圆形图像或圆形文字的AvatarImageView - Carbs的博客 - CSDN博客 http://blog.csdn.net/yeah0126/article/details/51543830
- hdodenhof/CircleImageView: A circular ImageView for Android https://github.com/hdodenhof/CircleImageView
- ImageView的scaleType设置不当,导致使用Glide时出现OOM - 泡在网上的日子 http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0606/3002.html
- ImageView.ScaleType设置图解 - CSDN博客 http://blog.csdn.net/larryl2003/article/details/6919513
- ImageView的src和background的区别、padding的使用技巧、ImageView根据屏幕对缩放 - CSDN博客 http://blog.csdn.net/u010566681/article/details/70899220
- Glide使用详解(一) - CSDN博客 http://blog.csdn.net/shangmingchao/article/details/51125554/
- Android RecyclerView横向滑动 - CSDN博客 http://blog.csdn.net/kingcruel/article/details/50763186
- Android框架之路——Glide加载图片(结合RecyclerView、CardView) - CSDN博客 http://blog.csdn.net/bskfnvjtlyzmv867/article/details/71106123
- iamMehedi/Secured-Preference-Store: https://github.com/iamMehedi/Secured-Preference-Store
- SharedPreferences保存List和对象序列化数据 - jxf_access的专栏 - CSDN博客 http://m.blog.csdn.net/jxf_access/article/details/61199519
- 【Android安全】自带加密光环的SharedPreference - CSDN博客 http://blog.csdn.net/voidmain_123/article/details/53338393
- Gitbook/Markdown中插入复杂(合并单元格)的表格 - 一个Technical Writer的博客 - CSDN博客 http://blog.csdn.net/wiborgite/article/details/78044656
日志
2017年11月30日
- 【更新】采用LogUtil管理日志
- 【更新】整理代码中注释
- 【删除】不再使用LitePal,转用SharedPreferences
- 【修改】削减标题栏的文字可点击区域
- 【修改】card.xml上的小按钮改用ImageView
- 【新增】使用SecurePreferenceStore加/解密
- 【修改】首次使用时登录界面的初始值的判断
- 【新增】完成登录的加密解密过程
2017年12月2日
- 【更新】解决真机调试时的oom问题
- 【修改】右分页中的HorizontalScrollView改为横向RecyclerView
- 【删除】右分页保存按钮的selector背景和文字颜色
- 【新增】Glide库
- 【更新】使用SecuritySharedPreference加密
- 【新增】SavedToMySharedPrefs类用于本地存取
- 【更新】剔除不需要的java类(SqliteDB、OpenHelper等)