📱 Android | 本地存储的使用

这次来实现一个简单的评论应用,主要使用到 Android 中的存储、数据库的操作以及相册、通讯录数据的读取。

SharedPreferences

这里密码的存储使用到了SharedPreferences这个东西,这个东西用于保存和检索原始数据类型的一些永久性键值对。使用起来也是非常的简单的。

  • 检索
1
2
SharedPreferences settings = mContext.getSharedPreferences(SHAREDPREFERENCE_NAME, 0);
String password = settings.getString(KEY_PASSWORD, "");

只需要提供文件的名字以及所需要的键值对的名字,就可以读取指定类型的数据。

  • 保存
1
2
3
4
SharedPreferences sharedPreferences = mContext.getSharedPreferences(SHAREDPREFERENCE_NAME, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sharedPreferences.edit();
editor.putString(KEY_PASSWORD, newPassword);
editor.apply();

保存也是十分地简单,还是提供同样的文件名字和键值对名字,然后获取一个Editor对象,然后使用apply()方法保存。

这里没有使用commit()是因为Android Studio给了我如下的提示,然后就劝我改成apply(),然后我就改了。

Consider using apply() instead of commit on shared preferences. Whereas commit blocks and writes its data to persistent storage immediately, apply will handle it in the background.

然后运行程序,通过Android StudioDevice File Explorer,找到我们的应用的data目录,就可以看到在应用下的shared_prefs文件夹里面生成了一个我们之前指定的名字的xml,里面就存储着我们密码的键值对。

1541080247633

内部存储

内部存储是应用的私有文件,一般保存在data/data/应用包名/files文件夹里面,只有应用本身才可以访问,而其他应用不能访问,当应用被卸载的时候,这些文件也会一同被移除。

  • 写入
1
2
3
4
5
6
try (FileOutputStream fos = mContext.openFileOutput(FILE_NAME, Context.MODE_PRIVATE)) {
fos.write(mBinding.fileText.getText().toString().getBytes());
} catch (IOException e) {
Toast.makeText(mContext, "Fail to save file.", Toast.LENGTH_SHORT).show();
return;
}

这里使用了try-catch来捕获保存失败时候抛出的异常,在 try 的括号里面打开FileOutputStream,就可以不用再去手动close,把这个变量的作用域限定在这个 try 里面。

只需要指定文件名,和打开的方式(这里使用了私有模式,还有MODE_APPEND可以选择)就可以在内存存储的专属目录下写入一个文件。

然后同样使用Device File Explorer查看,可以发现指定名字和内容的文件已经成功创建了。

1541080267387

  • 读取
1
2
3
4
5
6
7
8
try (FileInputStream fis = mContext.openFileInput(FILE_NAME)) {
byte[] contents = new byte[fis.available()];
fis.read(contents);
mBinding.fileText.setText(new String(contents));
} catch (IOException e) {
Toast.makeText(mContext, "Fail to load file.", Toast.LENGTH_SHORT).show();
return;
}

读取来说也是相对简单的,和写入大同小异,使用FileInputStream打开指定名字的文件,如果抛出异常就说明文件不存在或者读取失败,这时就可以提醒用户。当读取成功的时候,使用read方法将文件内存读取到一个byte数组里面,然后转换成String设置到EditText控件当中。

外部存储

除了内部存储之外,还有缓存和外部存储。缓存可以使用getCacheDir()方法来操作,是应用的临时文件,而外部存储的文件是全局可读取文件,其他具有存储访问权限的应用或者连接计算机都可以读取这些文件。

Android 7.0 中,我们也可以使用作用域来访问外部存储中的特定目录,比如相册。

如果需要读写外部存储,那么就需要先取得权限WRITE_EXTERNAL_STORAGE,而在现在主流的 Android 版本之中,还需要显式地先用户请求该权限。

当然,应用也可以使用外部存储存放一些私有文件,比如一些大型游戏,通常需要很大的空间存放应用的数据资源。那么可以通过getExternalFilesDir()来获取外部存储中的私有存储目录,也是其他应用不可以访问的。

相对于内部存储,外部存储可以存放一些公共的可以与其他应用共享的文件(然而内部存储使用内容提供器同样可以实现),或者说是一些通用的媒体文件(如音乐,照片等,可以存放在公共的存储区域以便同一管理),也可以存储一些体积相对较大的资源文件(如游戏资源包)

简单的论坛应用

登陆界面:

Screenshot_20181103-233310_Comment

注册界面:

Screenshot_20181103-233331_Comment

评论界面:

Screenshot_20181103-233403_Comment

举报评论:

Screenshot_20181103-233415_Comment

删除评论:

Screenshot_20181103-233423_Comment

删除后评论:

Screenshot_20181103-233722_Comment

查看电话号码(不存在):

Screenshot_20181103-233518_Comment

查看电话号码(存在):

Screenshot_20181103-233527_Comment

界面

这次的实验一共有两个界面,一个登陆注册的页面,另一个是评论页面。

登陆注册页面外层使用了一个约束布局,里面放一个RadioGroupLinearLayout

前者是登陆/注册的单选按钮,后者就是登陆注册的LinearLayout

RadioGroup通过onCheckedChanged事件控制两个layoutvisibility的属性,选择显示登陆页面还是注册页面:

1
2
3
4
5
6
7
8
9
10
11
public void onCheckedChanged(RadioGroup group, int checkedId) {
if (checkedId == mBinding.mainRadioLogin.getId()) {
mBinding.layoutLogin.setVisibility(View.VISIBLE);
mBinding.layoutRegister.setVisibility(View.GONE);
onClickLoginClear(null);
} else if (checkedId == mBinding.mainRadioRegister.getId()) {
mBinding.layoutRegister.setVisibility(View.VISIBLE);
mBinding.layoutLogin.setVisibility(View.GONE);
onClickRegisterClear(null);
}
}

评论页面比较简单,就是一个LinearLayout里面加一个ListView和一个LinearLayout(发送评论)。

通过layout_weight设置ListView占满屏幕上面的位置,EditText占满下面左边的位置。

从相册选择图片

只需要发送一个特殊的Intent,就可以跳转到系统的相册选择工具选择图片

1
2
3
Intent intent = new Intent(Intent.ACTION_PICK, null);
intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*");
((MainActivity) mContext).startActivityForResult(intent, PICK_PHOTO_FOR_AVATAR);

这里指定了image/*的类型,那么系统就会跳转到默认的图片选择应用选择图片,不需要我们再设计界面和获取存储来让用户选择,这样会使得 Android 更加统一和安全。

然后在Activity里面重载 onActivityResult,获取用户选择的照片的Uri

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// MainActivity
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == MainHandler.PICK_PHOTO_FOR_AVATAR && resultCode == Activity.RESULT_OK)
{
handler.onPhotoResult(data);
}
}

// MainHandler
public void onPhotoResult(Intent data) {
if (data != null) {
Uri uri = data.getData();
try {
Bitmap bitmap = MediaStore.Images.Media.getBitmap(mContext.getContentResolver(), uri);
Bitmap bitmapSmall = Bitmap.createScaledBitmap(bitmap,150,(int)(150.0 * ((float)bitmap.getHeight() / (float)bitmap.getWidth())), true);
mBinding.imgRegisterAvatar.setImageBitmap(bitmapSmall);
this.hasAvatar = true;
} catch (IOException e) {
Toast.makeText(mContext, "Fail!", Toast.LENGTH_SHORT).show();
}
}
}

使用MediaStoreUri里面获取Bitmap,然后再填充到ImageView里面,这里使用createScaledBitmap的方法,将图片按比例缩放到宽度为 150 的大小,避免了OOM的出现。

数据库操作

这里我使用了GreenDAO来操作数据库。

使用前准备

首先,在build.gradle(Project)里面添加repositoriesdependencies

1
2
3
4
5
6
7
8
9
10
11
buildscript {
repositories {
google()
jcenter()
mavenCentral() // add repository
}
dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
classpath 'org.greenrobot:greendao-gradle-plugin:3.2.2' // add plugin
}
}

然后在build.gradle(Module)里面添加依赖以及设置

1
implementation 'org.greenrobot:greendao:3.2.2' // add library
1
2
3
4
5
greendao {
schemaVersion 1 //数据库版本号
daoPackage 'cn.zhenly.comment.db'// 设置DaoMaster、DaoSession、Dao 包名
targetGenDir 'src/main/java' //设置DaoMaster、DaoSession、Dao目录
}

开始使用

接下来,就可以创建一个数据库的Model类:

这里先建立一个User

1
2
3
4
5
6
7
8
9
10
@Entity
public class User {
@Id
private String name;
private String password;
private byte[] avatar;

@Convert(columnType = String.class, converter = StringConverter.class)
private List<Long> like;
}

使用@Entity标记需要生成数据库表的类

使用@Id标记作为主键的属性

这里我设置了一个属性like,用于存储用户点赞过的评论 ID,由于数据库里面不支持List类型,因此我们需要再写一个类手动将他转换为String模式

使用@Convert为他设置目标类型以及转换器

1
2
3
4
5
6
7
8
9
10
11
12
public class StringConverter implements PropertyConverter<List<Long>, String> {

@Override
public List<Long> convertToEntityProperty(String databaseValue) {
...
}

@Override
public String convertToDatabaseValue(List<Long> entityProperty) {
...
}
}

转换器中需要实现两个重载函数,将两种类型转换,一般的做法就是使用分割符(比如,)来分开列表项。

做完上面的事情之后,就可以执行Build-Make Project,然后GreenDAO就会为我们生成好SQL的操作类:DAOMaster, DAOSession, UserDAO

最后我们写一个Manager来实现单例模式操作数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class GreenDaoManager {
private static DaoMaster mDaoMaster;
private static DaoSession mDaoSession;
private static volatile GreenDaoManager mInstance = null;
private static final String DB_NAME = "Comment.db"; // 数据库名字

private GreenDaoManager() {
}

public static void init(Context context) {
DaoMaster.DevOpenHelper devOpenHelper = new
DaoMaster.DevOpenHelper(context, DB_NAME);
mDaoMaster = new DaoMaster(devOpenHelper.getWritableDatabase());
mDaoSession = mDaoMaster.newSession();
}

public static GreenDaoManager getInstance() {
if (mInstance == null) {
synchronized (GreenDaoManager.class) {
if (mInstance == null) {
mInstance = new GreenDaoManager();
}
}
}
return mInstance;
}

public DaoSession getSession() {
return mDaoSession;
}
}

然后在Application的创建的时候,初始数据库。

1
GreenDaoManager.init(this)

使用方法

GreenDAO的使用十分简单:

查询:

1
2
3
4
5
6
7
8
9
10
11
UserDao userDao = GreenDaoManager.getInstance().getSession().getUserDao();
// 根据Key查询
User user = userDao.load(username);
// 查询所有
List<User> users = userDao.loadall();
// 使用 query 查询
QueryBuilder qb = userDao.queryBuilder();
qb.where(UserDao.Properties.Like.like("%" +Long.toString(id) + ",%"));
List users = qb.list();
// 使用 SQL语句查询
userDAO.queryRaw("SQL语句");

增删改可以使用insert, delete, update等方法操作。

由于我们需要将用户的头像存放到数据库当中,因此需要转换为byte[]类型,下面给出转换的方法:

1
2
3
4
5
6
7
8
9
10
// Image to byte[]
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Bitmap avatar = ((BitmapDrawable)mBinding.imgRegisterAvatar.getDrawable()).getBitmap();
avatar.compress(Bitmap.CompressFormat.JPEG, 50, baos);
byte[] avatarData = baos.toByteArray();

// byte[] to Image
byte[] avatarData = user.getAvatar();
Bitmap bitmap = BitmapFactory.decodeByteArray(avatarData, 0, avatarData.length);
binding.itemCommentAvatar.setImageBitmap(bitmap);

可以看到,在应用的 data 目录下,可以找到生成的sqlite数据库文件

1541824385578

数据库设计

这次实验有一个点赞的数据需要存储,既需要保证用户对于某一条评论的点赞是唯一的(不能重复点赞两次),还需要记录每条评论的点赞数。因此这个数据就有必要分开来存储。

对于用户,我存储了以下的数据:

1
2
3
4
5
6
7
@Id
private String name;
private String password;
private byte[] avatar;

@Convert(columnType = String.class, converter = StringConverter.class)
private List<Long> like;

首先是以用户名为主键,存储用户的一些基本信息,然后用一个List存储用户点赞过的评论的id,然后再转换成String存放到数据库中。这样一来,当用户登录的时候,我们只需要获取当前用户点赞过的评论,那么就可以将用户点赞过的评论的点赞图片效果换成已经点赞。

通过GreenDAO生成的代码,我们可以发现他已经为我们生成了一个 SQL 语句

1
2
3
4
5
6
db.execSQL("CREATE TABLE " + constraint + "\"COMMENT\" (" + //
"\"_id\" INTEGER PRIMARY KEY AUTOINCREMENT ," + // 0: id
"\"USERNAME\" TEXT," + // 1: username
"\"CREATE_TIME\" INTEGER NOT NULL ," + // 2: createTime
"\"CONTENT\" TEXT," + // 3: content
"\"LIKE_COUNT\" INTEGER NOT NULL );"); // 4: likeCount

而不同的属性也生成好名字

1
2
3
4
5
public final static Property Id = new Property(0, Long.class, "id", true, "_id");
public final static Property Username = new Property(1, String.class, "username", false, "USERNAME");
public final static Property CreateTime = new Property(2, long.class, "createTime", false, "CREATE_TIME");
public final static Property Content = new Property(3, String.class, "content", false, "CONTENT");
public final static Property LikeCount = new Property(4, int.class, "likeCount", false, "LIKE_COUNT");

再来看看评论的存储

1
2
3
4
5
6
@Id(autoincrement = true)
private Long id;
private String username;
private long createTime;
private String content;
private int likeCount;

除了存储一些基本信息外,还需要记录每条评论的点赞数。

读取通讯录

首先,再Manifest.xml里面表明权限

1
<uses-permission android:name="android.permission.READ_CONTACTS"/>

通讯录权限是Android中的危险权限,声明之后还必须要动态获取才能得到。

1
2
3
4
5
if (ContextCompat.checkSelfPermission(mContext, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED){
ActivityCompat.requestPermissions((Activity) mContext,
new String[]{Manifest.permission.READ_CONTACTS},1);
return;
}

Activity里面重载onResquestPermissionsResult判断用户是否以及授权

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode) {
case 1:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED){
Toast.makeText(this,"申请权限成功",Toast.LENGTH_SHORT).show();
}else {
Toast.makeText(this,"权限被拒绝了",Toast.LENGTH_SHORT).show();
}
break;
default:
break;
}
}

然后我们使用ContentResolver来获取我们需要的数据,我们可以把它当作数据库一样使用,指定查询的内容和条件,就可以获得我们需要的数据。

1
2
3
4
5
6
Uri uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI;
String[] projection = new String[] {
ContactsContract.CommonDataKinds.Phone.NUMBER
};
String selection = ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + "=\"" + comment.getUsername() + "\"";
Cursor cursor = mContext.getContentResolver().query(uri,projection ,selection, null, null);

使用projection限定查询的数据为手机号码,用selection过滤查询的内容,选择我们需要的数据。然后执行query获取数据。

1
2
3
4
5
6
7
8
if (cursor != null && cursor.moveToFirst()) {
message.append("Phone: ");
do {
message.append(cursor.getString(0)).append("\n");
} while (cursor.moveToNext());
} else {
message.append("Phone number not exist.");
}

然后判断cursor里面是否存在数据,添加到我们的对话框中。

最后,项目的目录结构如下:

1541261351458

遇到的困难以及解决思路

这次的内容比上次多,期间遇到了不少问题,但是通过Google查找资料都很快就可以解决。

比如动态权限获取问题,之前做 Android 应用的时候就遇到过这种问题,必须要显式向用户请求权限才能获取。

参考资料

还有GreenDAOmodel类,他的自增长 ID 必须是Long类型的,而long类型是没有效的,这个又是一个小坑。

还有读取通讯录的时候一开始照着tutorial的内容做,但是发现并不能运行,仔细研究发现使用projection参数更容易操作数据,因此换了种写法就可以了。

还有各种Image to byte[]的方法,也是通过stackoverflow找到解决方案

土豪通道
0%