SD - Slam Dump(并不是)

这个App的主要目的是满足广大人民群众对图片编辑的需求。

字体问题

Android默认的字体不太好看,也不一定能很好地匹配背景图。如果内置字体,遇到最大的问题是版权问题。
因此决定增加用户自行导入字体的功能,由用户来决定使用什么字体。

原来的字体文件是放在asset中。Typeface.createFromAsset直接引入并使用。

1
2
Typeface tf = Typeface.createFromAsset(mgr, "fonts/fz_grid.ttf");
mContentTv.setTypeface(tf);

设计一个字体管理界面。用户自行选择将字体文件复制到App内部存储路径。
使用字体时,再用Typeface.createFromFile()获取Typeface。

选择文件

调用系统文件选择器

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
private static final int REQ_CODE_CHOOSE_FILE = 10;

// 启动选择文件...
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
startActivityForResult(intent, REQ_CODE_CHOOSE_FILE);
// ......

// 处理选择的文件
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
switch (requestCode) {
case REQ_CODE_CHOOSE_FILE:
if (data != null) {
Uri uri = data.getData();
Log.d(TAG, "onActivityResult: uri: " + uri);
if (uri != null && !TextUtils.isEmpty(uri.getPath())) {
copyFile(uri);
} else {
Log.e(TAG, "onActivityResult: 选择的文件无效");
}
} else {
showShort(getApplicationContext(), "没选中文件");
Log.e(TAG, "onActivityResult: data is NULL 没选中文件");
}
break;
default:
super.onActivityResult(requestCode, resultCode, data);
break;
}
}

处理uri

uri形如

content://com.android.externalstorage.documents/document/primary%3ADownload%2Ffz_grid.ttf

uri.getPath获取到的并不是文件的绝对路径。但我们可以利用ContentResolver来获取到InputStream。
也可以获取到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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private void copyFile(final Uri uri) {
mAddIv.setClickable(false);
Animation rotate = AnimationUtils.loadAnimation(getApplicationContext(), R.anim.rotate_scan);
rotate.setDuration(400);
mAddIv.startAnimation(rotate);
new Thread(new Runnable() {
@Override
public void run() {
try {
Cursor cursor = getContentResolver().query(uri, null, null, null, null);
int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
cursor.moveToFirst();
String name = cursor.getString(nameIndex);
cursor.close();
InputStream fis = getContentResolver().openInputStream(uri);
File outputFile = new File(TypefaceStore.getStorePath(getApplicationContext()), name);
if (outputFile.exists()) {
boolean d = outputFile.delete();
Log.d(TAG, "删除旧文件: " + d);
}
boolean n = outputFile.createNewFile();
Log.d(TAG, "copyFile: 新建文件 " + n);
FileOutputStream fos = new FileOutputStream(outputFile);
byte[] tmp = new byte[2048];
int i;
while ((i = fis.read(tmp)) != -1) {
fos.write(tmp, 0, i);
}
fos.flush();
fos.close();
fis.close();
} catch (Exception e) {
Log.e(TAG, "copyFile ERROR:", e);

}

}
}).start();

}

也可以简单地使用uri.getLastPathSegment来获取文件名

1
2
3
uri.getLastPathSegment();
String[] t = uriPath.split(File.separator);
String name = t[t.length - 1];

https://stackoverflow.com/questions/4263002/how-to-get-file-name-from-uri

Toolbar问题

使用toolbar时经常会遇到问题。例如设置title的问题

这里自己创建一个统一的标题栏TitleBar。想要什么控件自己添加。

Google MobileAds

MobileAds.initialize(getApplicationContext(), AdsMgr.GOOGLE_ADS_APP_ID);的执行会占用很多时间。测试过程中发现小米手机甚至使用了3秒钟来执行这个方法。

https://stackoverflow.com/questions/37418663/what-is-the-proper-way-to-call-mobileads-initialize

给启动页Activity一个纯色的启动背景。

1
2
3
4
5
<style name="AppTheme.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@color/colorPrimary</item>
</style>

启动页中初始化Ads时实在是耗时太长,干脆放到子线程中去操作。
虽然官方文档建议的是越早初始化越好。但也不希望太影响用户体验。

递归查看某个路径下的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    private static void treeDir(File dir, int level) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < level; i++) {
sb.append("-");
}
sb.append(" ");
if (dir.isDirectory()) {
// LL.d(TAG, sb.toString() + dir.getName());
level++;
for (File f : dir.listFiles()) {
treeDir(f, level);
}
} else {
// LL.d(TAG, sb.toString() + dir.getName());
}
}

提供草稿功能

为方便用户使用,提供草稿功能。这就涉及到增删查改的操作。

[2019-7-31] 本来想直接用sqlite,但为了开发方便,选用了greenDAO

https://github.com/greenrobot/greenDAO

使用2个表,分别为Draft(存档)和DraftContent(图层)。DraftContent中存放着关联的存档ID。

能保存的东西都保存下来。

greendao插入元素

1
2
3
4
5
6
7
Draft draft1 = genDraft("示例1", p1Path);
Draft draft2 = genDraft("示例2", p2Path);
Draft draft3 = genDraft("示例3", p3Path);
Log.d(TAG, "addDemoDraft: id: " + draft1.getDraftId() + "," + draft3.getDraftId());
daoSession.insert(draft1);
daoSession.insert(draft2);
daoSession.insert(draft3);

插入元素后就有id了。

greendao删除元素

1
2
3
4
5
6
7
8
9
10
11
DraftDao draftDao = daoSession.getDraftDao();
DraftContentDao draftContentDao = daoSession.getDraftContentDao();
for (Draft d : drafts) {
Log.d(TAG, "删除 " + d.getName());
draftDao.queryBuilder()
.where(DraftDao.Properties.DraftId.eq(d.getDraftId())).buildDelete()
.executeDeleteWithoutDetachingEntities();
draftContentDao.queryBuilder()
.where(DraftContentDao.Properties.RelativeDraftId.eq(d.getDraftId())).buildDelete()
.executeDeleteWithoutDetachingEntities();
}

使用DrawerLayout

报错: IllegalArgumentException: No drawer view found with gravity LEFT

1
2
3
java.lang.IllegalArgumentException: No drawer view found with gravity LEFT
at androidx.drawerlayout.widget.DrawerLayout.openDrawer(DrawerLayout.java:1736)
at androidx.drawerlayout.widget.DrawerLayout.openDrawer(DrawerLayout.java:1722)

忘记中xml中加上开抽屉方向了 tools:openDrawer=”start”

1
2
3
4
5
6
7
8
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_page_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".act.MainActivity"
tools:openDrawer="start">

抽屉加上方向 android:layout_gravity=”start”

1
2
3
4
5
6
7
<!-- 抽屉 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="start"
android:layout_marginEnd="100dp"
android:orientation="vertical">

美术设计,App交互设计

设计是一个比较令我头疼的问题。在这个看脸的时代,App一定要好看!对我而言,直接采用material design的风格会比较省事。
经过调整和对比,我选择使用暗色的风格。因为现在主流的图形编辑软件,颜色风格以暗色居多。

参考:

文字编辑

文字内容,大小,旋转方向,颜色都可以调整。

需要一个调色盘来调整颜色。找个第三方的,好看能用即可。

删除存档报错

list类的经典异常 ConcurrentModificationException。

1
2
java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.next(ArrayList.java:860)

list删除元素时报错。这样写是不行的。

1
2
3
4
5
for (Data d : dataList) {
if (d.selected) {
dataList.remove(d);
}
}

用迭代器来删除元素。

1
2
3
4
5
6
7
Iterator<Data> iterator = dataList.iterator();
while (iterator.hasNext()) {
Data data = iterator.next();
if (data.selected) {
iterator.remove();
}
}

输出图片

保存View的显示内容

获取一个view的bitmap,然后保存到文件去。

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
/**
* 获取一个 View 的缓存视图
*/
private Bitmap getCacheBitmapFromView(View view) {
final boolean drawingCacheEnabled = true;
view.setDrawingCacheEnabled(drawingCacheEnabled);
view.buildDrawingCache(drawingCacheEnabled);
final Bitmap drawingCache = view.getDrawingCache();
Bitmap bitmap;
if (drawingCache != null) {
bitmap = Bitmap.createBitmap(drawingCache);
view.setDrawingCacheEnabled(false);
} else {
bitmap = null;
}
return bitmap;
}

public static boolean saveBitmapFile(Bitmap bitmap, String fileAbsPath) {
File file = new File(fileAbsPath); // 将要保存图片的路径
try {
if (file.exists()) {
file.delete();
}
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(file));
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, bos);
bos.flush();
bos.close();
} catch (IOException e) {
e.printStackTrace();
return false;
}
return true;
}

保存图片文件后的处理

用户输出图片文件后,打开微信想发送这张图片。但是用户发现微信的快捷发送功能找不到这张图片。
怎么才能让微信知道这里新增了一张图片呢?

如果要发送广播ACTION_MEDIA_MOUNTED

1
sendBroadcast(new Intent(Intent.ACTION_MEDIA_MOUNTED, Uri.fromFile(outputFile)));

报错,没有足够的权限

1
java.lang.SecurityException: Permission Denial: not allowed to send broadcast android.intent.action.MEDIA_MOUNTED

Android KK开始,这个广播开始只能由系统发出。KK及之后的版本需使用Intent.ACTION_MEDIA_SCANNER_SCAN_FILE

1
2
File outputFile = new File(filePath);
sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(outputFile)));

参考

https://stackoverflow.com/questions/24072489/java-lang-securityexception-permission-denial-not-allowed-to-send-broadcast-an

移动TextView

编辑页中有一个需求是手指拖动文字。

1.1.x版本

1.1.0版本的做法是,在Activity的onTouch方法里来改变TextView的坐标。从而实现TextView的拖动效果。
父View和子View设同一个OnTouchListener。但是只有父view来处理触摸事件。
如果是子view接收到了触摸事件,则做一个bool标记firstOnTv = true,返回false,把触摸事件交给父view来处理。
父view处理触摸事件时,判断如果刚才点中的是子view(即mContentTv),则在MotionEvent.ACTION_MOVE时更改子view的坐标。

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
51
    private View.OnTouchListener mWsOnTouchListener = new View.OnTouchListener() {

boolean firstOnTv = false; // 最开始点中的是tv
float originTvX; // tv最开始的坐标
float originTvY;

float downX;
float downY;

@Override
public boolean onTouch(View v, MotionEvent event) {
final int id = v.getId();
// Log.d(TAG, "onTouch: touch tv: " + (id == mContentTv.getId()) + ", touch ws: " + (id == mWorkspaceField.getId()));
float x = event.getX();
float y = event.getY();
// Log.d(TAG, "onTouch: [" + x + ", " + y + "] , " + event);
if (event.getAction() == MotionEvent.ACTION_DOWN) {
mSaveIv.setEnabled(true);
if (id == mContentTv.getId()) {
firstOnTv = true;
originTvX = mContentTv.getX();
originTvY = mContentTv.getY();
// Log.d(TAG, "onTouch: 保存tv坐标 (" + originTvX + ", " + originTvY + ")");
}
downX = event.getX();
downY = event.getY();
// Log.d(TAG, "onTouch: down: x,y [" + x + ", " + y + "]");
return id != mContentTv.getId();
} else if (event.getAction() == MotionEvent.ACTION_MOVE) {
// Log.d(TAG, "onTouch: move: x,y [" + x + ", " + y + "]");
if (!firstOnTv) {
return true; // 不移动tv,直接消耗掉这个操作
}
float dx = x - downX;
float dy = y - downY;
if (Math.abs(dx) > 2 && Math.abs(dy) > 2) {
mContentTv.setX(originTvX + dx);
mContentTv.setY(originTvY + dy);
}
return true;
} else if (event.getAction() == MotionEvent.ACTION_UP) {
firstOnTv = false;
if (mCanvasWid > 0 && mCanvasHeight > 0) {
mDraftContent.setTvLocationXRatio(mContentTv.getX() / mCanvasWid);
mDraftContent.setTvLocationYRatio(mContentTv.getY() / mCanvasHeight);
}
return true;
}
return false;
}
};

版本更新

  • 2019-8-8 v1.1.1 版本更新
    • 解决了一些bug
    • UI调整,增加了抽屉的头图和欢迎文字
  • 2019-8-4 v1.1.0 版本更新