安卓-存储相关

数据存储

存储方式 说明
SharedPreferences 在键值对中存储私有原始数据
内部存储 在设备内存中存储私有数据
外部存储 在共享的外部存储中存储公共数据
SQLite 数据库 在私有数据库中存储结构化数据

SharedPreferences

SharedPreferences采用key-value(键值对)形式, 主要用于轻量级的数据存储, 尤其适合保存应用的配置参数, 但不建议使用SharedPreferences来存储大规模的数据, 可能会降低性能.

SharedPreferences采用xml文件格式来保存数据, 该文件所在目录位于/data/data/<package name>/shared_prefs,如:

1
2
3
4
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="blog">https://github.com/JasonWu1111/Android-Review</string>
</map>

从Android N开始, 创建的SP文件模式, 不允许MODE_WORLD_READABLEMODE_WORLD_WRITEABLE模块, 否则会直接抛出异常SecurityException。MODE_MULTI_PROCESS这种多进程的方式也是Google不推荐的方式, 后续同样会不再支持。

当设置MODE_MULTI_PROCESS模式, 则每次getSharedPreferences过程, 会检查SP文件上次修改时间和文件大小, 一旦所有修改则会重新从磁盘加载文件.

获取方式

getPreferences

Activity.getPreferences(mode): 以当前Activity的类名作为SP的文件名. 即xxxActivity.xml
Activity.java

1
2
3
public SharedPreferences getPreferences(int mode) {
return getSharedPreferences(getLocalClassName(), mode);
}

getDefaultSharedPreferences

PreferenceManager.getDefaultSharedPreferences(Context): 以包名加上_preferences作为文件名, 以MODE_PRIVATE模式创建SP文件. 即packgeName_preferences.xml.

1
2
3
4
public static SharedPreferences getDefaultSharedPreferences(Context context) {
return context.getSharedPreferences(getDefaultSharedPreferencesName(context),
getDefaultSharedPreferencesMode());
}

getSharedPreferences

直接调用Context.getSharedPreferences(name, mode),所有的方法最终都是调用到如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ContextImpl extends Context {
private ArrayMap<String, File> mSharedPrefsPaths;

public SharedPreferences getSharedPreferences(String name, int mode) {
File file;
synchronized (ContextImpl.class) {
if (mSharedPrefsPaths == null) {
mSharedPrefsPaths = new ArrayMap<>();
}
//先从mSharedPrefsPaths查询是否存在相应文件
file = mSharedPrefsPaths.get(name);
if (file == null) {
//如果文件不存在, 则创建新的文件
file = getSharedPreferencesPath(name);
mSharedPrefsPaths.put(name, file);
}
}

return getSharedPreferences(file, mode);
}
}

架构

SharedPreferences与Editor只是两个接口. SharedPreferencesImpl和EditorImpl分别实现了对应接口. 另外, ContextImpl记录着SharedPreferences的重要数据。

putxxx()操作把数据写入到EditorImpl.mModified;

apply()/commit()操作先调用commitToMemory(`, 将数据同步到SharedPreferencesImpl的mMap, 并保存到MemoryCommitResult的mapToWriteToDisk,再调用enqueueDiskWrite(), 写入到磁盘文件; 先之前把原有数据保存到.bak为后缀的文件,用于在写磁盘的过程出现任何异常可恢复数据;

getxxx()操作从SharedPreferencesImpl.mMap读取数据.

apply / commit

  • apply没有返回值, commit有返回值能知道修改是否提交成功
  • apply是将修改提交到内存,再异步提交到磁盘文件,而commit是同步的提交到磁盘文件
  • 多并发的提交commit时,需等待正在处理的commit数据更新到磁盘文件后才会继续往下执行,从而降低效率; 而apply只是原子更新到内存,后调用apply函数会直接覆盖前面内存数据,从一定程度上提高很多效率。

SharedPreference源码

Android/SharedPreference

注意

  • 强烈建议不要在sp里面存储特别大的key/value,有助于减少卡顿/anr
  • 不要高频地使用apply,尽可能地批量提交
  • 不要使用MODE_MULTI_PROCESS
  • 高频写操作的key与高频读操作的key可以适当地拆分文件,由于减少同步锁竞争
  • 不要连续多次edit(),应该获取一次获取edit(),然后多次执行putxxx(),减少内存波动

第三方-数据库

GreenDao相关

关系注解

在greenDAO,实体使用@to-one@to-many 关联起来

@ToOne一对一

1
2
3
4
5
6
7
@Entity
public class Order {
@Id private Long id;
private long customerId;//定义外间
@ToOne(joinProperty = "customerId")
private Customer customer;持有目标。
}
1
2
3
4
@Entity
public class Customer {
@Id private Long id;
}

外键约束语句:

constraint FK_name foreign key(customerId) references Customer(id);
@ToOne 定义到另一个实体对象的关系,应在持有目标实体对象的字段上使用该注解。
如一个Order对一个Customer,应在实体Order的customer字段使用@ToOne

在实体内部,需要定义一个属性来指向目标实体的ID,也就是定义外键(如Order实体的customerId),
使用@ToOne的joinProperty参数来指明外键

如果改变了外键属性(customerId),下次调用getter(getCustomer())时会更新实体;如果设置了新的实体(setCustomer()),外键也会更新

第一次调用to-one关系的getter方法(getCustomer())时会延迟加载,在随后的调用会立即返回之前加载的对象。

@ToMany 一对多
@ToMany定义一对多关系(一对一个其他实体的集合),使用@ToMany的属性代表目标实体的List,集合里的对象都必须至少有一个属性指向拥有@ToMany的实体

referencedJoinProperty指定目标实体的外键

1
2
3
4
5
6
7
8
@Entity
public class Customer {
@Id private Long id;

@ToMany(referencedJoinProperty = "customerId") //指定目标实体的外键
@OrderBy("date ASC")
private List<Order> orders; //目标实体的List
}
1
2
3
4
5
6
@Entity
public class Order {
@Id private Long id;
private Date date;
private long customerId;
}

joinProperties parameter
对于更复杂的关系,可以指定一个@joinproperty注释列表,每个@joinproperty需要原始实体中的源属性和目标实体中的引用属性。

1
2
3
4
5
6
7
8
9
10
11
@Entity
public class Customer {
@Id private Long id;
@Unique private String tag;

@ToMany(joinProperties = {
@JoinProperty(name = "tag", referencedName = "customerTag")
})
@OrderBy("date ASC")
private List<Site> orders;
}
1
2
3
4
5
6
@Entity
public class Order {
@Id private Long id;
private Date date;
@NotNull private String customerTag;
}

@JoinEntity 多对多
如果两个实体是多对多的关系,那么需要第三张表(表示两个实体关系的表)
例如学生与课程是多对多关系,需要一个选课表来转成两个一对多的关系(学生与选课是一对多,课程与选课是一对多) 所以一定要配合 @ToMany一起使用

1
2
3
4
5
6
7
8
9
10
11
12
@Entity
public class Product {
@Id private Long id;
// 对多。@JoinEntity:entity 中间表;中间表属性 sourceProperty 对应实体ID;中间表属性 targetProperty 对应外联实体ID
@ToMany
@JoinEntity(
entity = JoinProductsWithOrders.class,
sourceProperty = "productId",
targetProperty = "orderId"
)
private List<Order> ordersWithThisProduct;
}
1
2
3
4
5
6
@Entity
public class JoinProductsWithOrders {
@Id private Long id;
private Long productId;
private Long orderId;
}
1
2
3
4
@Entity
public class Order {
@Id private Long id;
}

获取和更新To-many关系
To-many在首次请求时会延迟加载,之后,相关的实体缓存到源实体的内部List对象中。随后的访问不再查询数据库,而是直接从缓存中获取。所以当数据库更新to-many关系后,需要手动更新to-many的list缓存。

1
2
3
4
5
6
7
8
9
10
// 插入新的实体之前获取to-many list,否则新实体可能在list中出现两次
List<Order> orders = customer.getOrders();
// 创建新实体
Order newOrder = ...
// 设置外键
newOrder.setCustomerId(customer.getId());
// 插入新实体
daoSession.insert(newOrder);
// 添加新实体到to-many list
orders.add(newOrder);

同样,可以删掉关联的实体

1
2
3
4
5
List<Order> orders = customer.getOrders();
// 从数据库中删掉其中一个关联的实体
daoSession.delete(someOrder);
// 手动从to-many list中删除
orders.remove(someOrder);

当添加、更新或删除很多关联实体时可以使用reset方法来清掉缓存,然后调用getter方法时会重新查询

1
2
3
// clear any cached list of related orders
customer.resetOrders();
List<Order> orders = customer.getOrders();

基础属性注解

@Id
主键,选择使用Long,必须是大写的 L 可以通过@Id(autoincrement = true)设置自增长,引用一下官方文档的说明

Currently, entities must have a long or Long property as their primary key. This is recommended practice for Android and SQLite.

To work around this, define your key property as an additional property, but create a unique index for it:

1
2
3
4
5
@Id
private Long id;

@Index(unique = true)
private String key;

@Property
为该属性映射的列设置一个非默认的名称,默认是将单词大写,用下划线分割单词,如属性名customName对应列名CUSTOM_NAME

1
2
@Property(nameInDb = "USERNAME")
private String name;

@NotNull
表明这个列非空,通常使用@NotNull标记基本类型(long,int,short,byte),然而可使用包装类型(Long, Integer, Short, Byte)使其可空

1
2
@NotNull
private int repos;

@Transient
表明此字段不存储到数据库中,用于不需要持久化的字段,比如临时状态

1
2
@Transient
private int tempUsageCount;

实体注解

@Entity 实体注解,为greendao指明这是一个需要映射到数据库的实体类

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
@Entity(
// 告知GreenDao当前实体属于哪个schema (pick any string as a name).
schema = "myschema",

// 标记一个实体处于活动状态,活动实体有更新、删除和刷新方法
active = true,

// 指定该表在数据库中的名称,默认是基于实体类名
nameInDb = "AWESOME_USERS",

// 定义跨多个列的索引
indexes = {
@Index(value = "name DESC", unique = true)
},

// 标识DAO类是否应该创建该数据库表(默认为true)
// 如果有多个实体映射一个表,或者该表已在greenDAO外部创建,则置为false
createInDb = false,

// 是否生成all-properties的构造器
// 无参构造器总是会生成
generateConstructors = true,

// 如果丢失,是否应该生成属性的getter和setter
generateGettersSetters = true
)

索引注解

@Index
为相应的列创建索引
name 如果不想使用greenDAO为该属性生成的默认索引名称,可通过name设置
unique 给索引添加唯一性约束

1
2
@Index(unique = true)
private String name;

@Unique
为相应列添加唯一约束,注意,SQLite会隐式地为该列创建索引

1
2
@Unique
private String name;

LitePal相关笔记

使用步骤

添加依赖

1
2
3
4
5
6
// 添加jitPack仓库使用
maven { url 'https://jitpack.io' }

// 添加依赖
/** 轻量级数据库 */
compile 'org.litepal.android:core:1.5.1'//lastVersion 2.0.0

在src/main/assets下新建 litepal.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>
<litepal>
<!--数据库名称-->
<dbname value="ldy"></dbname>

<!--数据库版本号-->
<version value="42"></version>

<!--列出所有实体类 bean-->
<list>
<mapping class="xxx.xxx.model.javabean.UserBean"></mapping>
</list>
</litepal>

实体类 bean 需要 extends DataSupport;另外还需要加个主键

1
2
3
private int id;
public void getId(){return id;}
public void setId(int id){this.id = id;}

存取调用

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public static UserBean cust = null;//新的用户实体类
/**
* 从本地数据库中获取用户实体类
*/
public static boolean getCustomer() {
cust = DataSupport.findFirst(UserBean.class);
return cust != null && cust.getCustomerId() > 0 && cust.getGuiderId() > 0;
}

/**
* 获取缓存用户数据,该方法用于避免cust空指针异常
*
* @return
*/
public static UserBean getCust() {
if (cust == null) {
getCustomer();
}
if (cust == null) {
cust = new UserBean();
}
return cust;
}

/**
* 保存用户信息到数据库并刷新UserBean缓存数据
*
* @param userBean
*/
public static void saveUserBean(UserBean userBean) {
UserBean dataBean = DataSupport.findFirst(UserBean.class);
if (dataBean != null) {
//本地数据库里面已经有数据了,更新数据
userBean.update(dataBean.getId());
} else {
//数据库里面没数据,保存数据
userBean.saveThrows();
}
getCustomer();
// 同时更新融云中的缓存信息
RongHelper.getInstance().updateCustomerSelfInfoForRc();
}

/**
* 从本地数据库中清除用户数据
*/
public static void delUserBean() {
DataSupport.deleteAll(UserBean.class);
cust = null;
}

网上说 DataSupport 被弃用,可用 LitePalSupport 来替代

MMKV

Internal和External Storage

现在大部分设备只有一个存储区域,即内置存储,分成 Internal 和 External 两个分区。

区别 Internal storage External storage
可见性 一直可见 不总是可见,mount了就可见,remove了就不可见
访问权限 保存在这里的文件默认只有对应app才能访问 其他app共享
卸载表现 保存在此的与app相关的文件会被删除 保存在这里的文件除了在getExternalFilesDir()和getExternalCacheDir()文件夹之外,都会保留
权限 无需 需 WRITE_EXTERNAL_STORAGE权限 (注意:getExternalFilesDir()和getExternalCacheDir()这两个路径无需权限)
适用条件 app私有数据 存放的数据不需要访问限制,为了分享数据或允许其他用户访问

通过Context.getExternalFilesDir()方法可以获取到 SDCard/Android/data/应用包名/files/ 目录,一般放一些长时间保存的数据【设置->应用->应用详情里面的“清除数据”】
通过Context.getExternalCacheDir()方法可以获取到 SDCard/Android/data/应用包名/cache/目录,一般存放临时缓存数据【设置->应用->应用详情里面的与“清除缓存”】

一些路径的标准写法

1
2
3
4
5
Environment.getExternalStorageDirectory() = /mnt/sdcard
context.getExternalFilesDir() = /mnt/sdcard/Android/data/<application package>/files
context.getExternalCacheDir() = /mnt/sdcard/Android/data/<application package>/cache
context.getFilesDir() = /data/data/<application package>/files
context.getCacheDir() = /data/data/<application package>/cache

file_path.xml

Android中有以下几种用于访问存储设备的路径

  • Context.getExternalFilesDir():获取外部应用程序存储空间中的应用程序特定的目录。应用程序在该目录中创建的文件只能该应用程序访问,别的应用程序无法访问。这个路径通常用于存储应用程序相关的私人数据,如:文件缓存、用户数据等。通常,应用程序被卸载时,这个目录内容也会被删除。

  • Context.getExternalCacheDir():获取外部应用程序存储空间中的应用程序特定的缓存目录。应用程序通常在这个目录中存储临时文件,如图片缓存等。这个目录下的文件也只能由该应用程序访问

  • Environment.getExternalStorageDirectory():获取外部存储设备的根目录,通常是 /sdcard/storage/emulated/0。该目录是存储设备的根目录,所有应用程序皆可访问该目录,但使用时应该注意权限,因为该目录中的文件可能是其它应用程序使用的。在 Android 10 及以上,使用此方法无法直接访问存储设备的根目录,需要改用其他方式进行访问。

  • Context.getExternalMediaDirs():获取外部存储设备中相对应用程序的媒体文件目录。媒体文件包括图片、视频、音频等,应用程序可以在该目录中对媒体文件进行保存和访问。这个目录是与应用程序私有相关联的,其他应用程序无法访问该目录中的文件。需要权限 android.permission.WRITE_EXTERNAL_STORAGEandroid.permission.READ_EXTERNAL_STORAGE

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
<!--上面两个一般不用,可能在某个版本中就会被废弃-->
<!--代表外部存储区域的根目录下的文件 Environment.getExternalStorageDirectory()/DCIM/camerademo目录-->
<!--/storage/emulated/0/DCIM/camerademo-->
<external-path name="hm_DCIM" path="DCIM/camerademo" />
<!--代表外部存储区域的根目录下的文件 Environment.getExternalStorageDirectory()/Pictures/camerademo目录-->
<!--/storage/emulated/0/Pictures/camerademo-->
<external-path name="hm_Pictures" path="Pictures/camerademo" />


<!--app私有的,外部应用无法访问-->
<!--代表app 私有的存储区域 Context.getFilesDir()目录下的images目录-->
<!--/data/user/0/packName/files/images-->
<files-path name="hm_private_files" path="images" />
<!-- 代表app 私有的存储区域 Context.getCacheDir()目录下的images目录-->
<!--/data/user/0/packName/cache/images-->
<cache-path name="hm_private_cache" path="images" />


<!--app私有的,通过ContentProvider提供给外部调用-->
<!--代表app外部存储区域根目录下的文件 Context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)目录下的Pictures目录-->
<!--/storage/emulated/0/Android/data/packName/files/Pictures-->
<external-files-path name="hm_external_files" path="Pictures" />
<!--代表app 外部存储区域根目录下的文件 Context.getExternalCacheDir目录下的images目录-->
<!--/storage/emulated/0/Android/data/packName/cache/images-->
<external-cache-path name="hm_external_cache" path="images" />
<!-- Context.getExternalMediaDirs() 中返回的路径 -->
<external-files-path name="my_media_files_name" path="Android/media/com.example.MyApp" />

<root-path>:在应用程序中访问存储设备的根目录。供 fileProvider 使用。需要注意的是,这种方法在 Android 10 及以上版本已被弃用

调用:

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
//Context.getExternalFilesDir(Environment.DIRECTORY_PICTURES);<external-files-path name="hm_external_files" path="Pictures" />
// 获取名为 hm_external_files 的访问路径
Uri uri = FileProvider.getUriForFile(this, "com.example.fileprovider", new File(getExternalFilesDir("hm_external_files"), "example.jpg"));
// 启动 Action.VIEW Intent
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(uri, "image/jpeg");
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(intent);


//Context.getExternalCacheDir;<external-cache-path name="hm_external_cache" path="images" />
// 获取名为 hm_external_cache 的访问路径
Uri uri = FileProvider.getUriForFile(this, "com.example.fileprovider", new File(getExternalCacheDir() + "/images/example.jpg"));//此处需要手动拼接 "/images"
// 启动 Action.VIEW Intent
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(uri, "image/jpeg");
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(intent);


//Context.getExternalMediaDirs();<external-files-path name="my_media_files_name" path="Android/media/com.example.MyApp" />
File mediaFile = new File(context.getExternalMediaDirs()[0], "photo.jpg");
Uri photoUri = FileProvider.getUriForFile(context, "com.example.MyApp.fileprovider", mediaFile);
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
startActivityForResult(intent, REQUEST_IMAGE_CAPTURE);

存储相关问题

Android 也需要防止 SQL 注入

原文链接

背景:

在移动端发现一个用户无法登陆的 bug,经排查,发现是不规范的 Sql 语句以及没做 SQL 攻击的防止导致的。

出现的原因及分析

大多数情况下,我们不需要考虑移动端 SQL 攻击,因为 Android SDK提供的 query/insert/update/delete 等 API 是安全的,但是execSql 这个 API 却不是安全的,尽量别使用这个 API

由于我们的代码,原先的数据存储是靠C++的sqlite,而没借助Android的Sdk来做的。因此,原先一些SQL可能存在问题。

例子

1
2
3
String userName = "";
int age = 24;
mDBHelper.getWritableDatabase().execSQL(String.format(Locale.getDefault(),"insert into " + UserDBHelper.TABLE_NAME+ " (UserName,Age) values(%s,%d)",userName,age));

类似上面的代码,我想 大家的项目里或许也有。看上去,这个没有任何问题,但是却存在sql攻击,当我把userName这个字符串的内容换成,带特殊字符的,如 guolei’ 的时候,这个拼接出来的sql语句就有问题了,回报如下错误。

1
2
3
Process: com.guolei.sqlinsert, PID: 22520
android.database.sqlite.SQLiteException: unrecognized token: "',24)" (code 1): , while compiling: insert into user (UserName,Age) values(error',24)
at android.database.sqlite.SQLiteConnection.nativePrepareStatement(Native Method)

这是因为单引号 ‘ 使sql语句发生了截断,在sql语句中,要用连续两个单引号去表达一个单引号字符。这仅仅是一个例子。有兴趣的,大家去了解SQL攻击相关的东西吧。