持续创作,加快生长!这是我参与「日新方案 10 月更文挑战」的第11天,点击检查活动概况

前语

正如我在某社交渠道上填写的简介:

标准理工科直男一枚,喜欢骑行,最近在研讨拍照

拍照是我最近在研讨的新技能点。

不久之前,我在尝试拍照延时拍照时发现一个问题,运用相机拍照时都无法在机内增加水印。(废话,哪个拍照会给相片打水印啊喂)

而我想将拍照的延时相片每一张都加上拍照时刻的水印,以此增加合成的视频趣味性。(技术不行,花活来凑)

不过好在相机的图片文件 Exif 信息够丰厚,其间就包含了拍照时刻。

可是,假如运用后期软件手动增加的话还得一张一张的加,显然不符合咱们的需求。

所以,作为一个程序员,当然是想到写一个程序来完成了。

完成

思路

按道理来说,对于这种使命一般都是直接写一个 python 脚本就能够了,可是我的预期功用远不止按 Exif 信息加水印这一个,所以我终究仍是决定写一个 APP。

可是,运用安卓手机处理大量的专业相机导出的延时相片显然是不太便利的,所以我的原方案是运用 Compose-jb 编写跨渠道程序。

然而,搜索了一圈没有找到合适的跨渠道图画处理库,自己写轮子是不可能的。

不得已,只能暂时只写安卓端了。

对了,本文只介绍如安在安卓端读取图片的 Exif 信息以及给图片增加文字水印。

Exif 简介

Exif,全称 Exchangeable image file format 是一种文件格局规范标准。

用于给相机、手机、扫描仪等设备处理图画后,为图画增加特定元数据信息。

Exif开端由日本电子工业发展协会在1996年制定,版别为1.0。 1998年,升级到2.1版,增加了对音频文件的支撑。2002年3月,发表了2.2版。

一般来说 Exif 会包含以下信息:

  • 相机信息:包含相机类型,拍照时的光圈、快门速度、感光度(ISO)、焦距等信息
  • 图画信息:像素尺寸、分辨率、色彩装备等
  • 日期和时刻信息
  • 地理位置信息
  • 缩略图
  • 阐明信息
  • 版权信息

别的因为 EXif 数据头为 0xFFFF ,后两个字节表明的是 Exif 信息的总长度,所以 Exif 最多只能存储 64 KB 的信息。

咱们需求关心的是有关时刻的数据。

Exif中对时刻的界说有以下几个字段:

  • DateTime 创立图画时的时刻日期,一般用于表明该文件终究被修改时的日期,格局 YYYY:MM:DD HH:MM:SS
  • DateTimeOriginal 生成原始图画数据时的时刻日期,一般用于表明该图画首次创立的日期,格局 YYYY:MM:DD HH:MM:SS
  • DateTimeDigitized 图画贮存时的时刻日期,假如相机在捕获图画的同时将其贮存,则该值与 DateTimeOriginal 共同,格局 YYYY:MM:DD HH:MM:SS

上面三个标签的最小时刻单位都是秒,不过还有三个标签用于符号上述三个标签的亚秒级数据:

  • SubsecTime
  • SubsecTimeOriginal
  • SubsecTimeDigitized

例如,假如 DateTime2022:10:12 16:30:00SubsecTime123 则实践时刻为 2022:10:12 16:30:00.123

需求留意的是,亚秒级标签的数据长度不是固定的,所以说详细时刻精度将由厂商自己确认。

别的,还有三个标签:

  • OffsetTime
  • OffsetTimeOriginal
  • OffsetTimeDigitized

别离表明上述三个标签相对于UTC(国际和谐时)、夏令时的偏移量,其长度为 7 字节(包含终止符),表明偏移的时数和分钟数,例如 +01:00-01:00

需求留意的是,这三个偏移量标签是在 Exif 版别 2.31 中才被加入。

别的还需求留意的是, Exif 标准并没有清晰每个字段对应的详细应该是拍照时的哪个时刻点。

举个比如,假如某张相片运用的是长曝光拍照(例如曝光15s),那么这些标签表明的应该是开端曝光的时刻仍是曝光完毕亦或是曝光过程中的任意时刻点?

一张典型的图画的 Exif 信息如下(运用 Windows 自带文件特点检查):

在安卓中实现读取Exif获取照片拍摄日期后以水印文字形式添加到照片上

在安卓中读取 Exif 信息

Google 在 AndroidX 中供给了一个 exifinterface 库,假如咱们想要读取 Exif 信息的话能够直接运用这个库。

增加依赖:implementation "androidx.exifinterface:exifinterface:1.3.4"

初始化 exifinterfaceval exifInterface = ExifInterface()

ExifInterface 能够接受四种类型的图画数据传入:

  • File
  • String
  • InputStream
  • FileDescriptor

其间,类型为 String 的结构办法接纳的是文件名(途径)。

因为安卓贮存权限的收紧,咱们不太好直接传入 File 或 String,可是咱们能够传入运用 SAF(Storage Access Framework, 贮存拜访结构) 拿到的 Uri 生成的 InputStream:

var input: InputStream? = null
try {
    input = contentResolver.openInputStream(uri) ?: return dateTime
    val exifInterface = ExifInterface(input)
    // ……
} catch (tr: Throwable) {
} finally {
    try {
        input?.close()
    } catch (tr: Throwable) {
    }
}

创立完成 ExifInterface 后,咱们能够通过 exifInterface.getAttribute(tag) 读取特定的标签数据。

而这些标签现已在 ExifInterface 中界说了:

在安卓中实现读取Exif获取照片拍摄日期后以水印文字形式添加到照片上

例如咱们要获取 DateTime 数据的话就能够运用:

dateTime = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)

其实 ExifInterface 中有一个 getDateTime() 办法用于返回计算好的标准时刻戳,可是它被符号了仅供同一个库的代码调用,也便是说咱们没法调用:

在安卓中实现读取Exif获取照片拍摄日期后以水印文字形式添加到照片上

可是咱们能够检查它的源码,看看它都做了什么:

private static Long parseDateTime(@Nullable String dateTimeString, @Nullable String subSecs,
        @Nullable String offsetString) {
    if (dateTimeString == null || !NON_ZERO_TIME_PATTERN.matcher(dateTimeString).matches()) {
        return null;
    }
    ParsePosition pos = new ParsePosition(0);
    try {
        // The exif field is in local time. Parsing it as if it is UTC will yield time
        // since 1/1/1970 local time
        Date dateTime = sFormatterPrimary.parse(dateTimeString, pos);
        if (dateTime == null) {
            dateTime = sFormatterSecondary.parse(dateTimeString, pos);
            if (dateTime == null) {
                return null;
            }
        }
        long msecs = dateTime.getTime();
        if (offsetString != null) {
            String sign = offsetString.substring(0, 1);
            int hour = Integer.parseInt(offsetString.substring(1, 3));
            int min = Integer.parseInt(offsetString.substring(4, 6));
            if (("+".equals(sign) || "-".equals(sign))
                    && ":".equals(offsetString.substring(3, 4))
                    && hour <= 14 /* max UTC hour value */) {
                msecs += (hour * 60 + min) * 60 * 1000 * ("-".equals(sign) ? 1 : -1);
            }
        }
        if (subSecs != null) {
            msecs += parseSubSeconds(subSecs);
        }
        return msecs;
    } catch (IllegalArgumentException e) {
        return null;
    }
}

简略说便是接纳传入 DateTimeSubsecTimeOffsetTime ,然后解析 DateTime 文本后,处理偏移量,终究加上亚秒级数据得到终究的 Long 类型的标准时刻戳。

不过我觉得咱们的这个程序不需求这么精密,直接运用 DateTime 的数据就够了,不过后边能够把这个办法 copy 过来,仍是解析一下,防止有些设备记载的拍照时刻时区不对。

compose 运用 SAF 挑选文件

了解了怎样在安卓中读取 EXif 以及咱们需求的是什么 EXif 信息后,咱们来写一个 demo 试试吧!

不过在这之前咱们需求先处理如安在 Compose 中挑选文件。

一般来说,咱们想要运用 SAF 挑选文件都会用到 startActicityForResult 。可是,显然的,在 Compose 中运用这个 API 非常不便利,因为这个 API 的回调办法都在 Activity 中,而非 Compose 中。

而且,早在几年前,startActicityForResult 就被符号为了 Deprecated 弃用了,取而代之的是 AndroidX 中的 Activity Result API ,走运的是,Compose 供给了原生的 Activity Result API 支撑:rememberLauncherForActivityResult

咱们只需求运用 rememberLauncherForActivityResult 创立一个 Launcher 后,在需求的地方调用即可:

// 界说 Launcher
val choosePictureLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.GetContent()
) { uri: Uri? ->
    if (uri != null) {
        // 已挑选图片,在这儿做挑选后的处理
        // ……
    }
}
// ……
// 在需求的地方调用这个 Launcher (例如点击按钮回调)
choosePictureLauncher.launch("image/*")

rememberLauncherForActivityResult 接纳两个参数:

contract 表明需求获取的数据类型,API 预设了几个常用的 contract,例如:

在安卓中实现读取Exif获取照片拍摄日期后以水印文字形式添加到照片上

当然,你也能够彻底自界说自己的 contract。

还有一个参数 onResult 表明数据回调,在返回数据后将调用这个回调。

关于 Activity Result API 更多信息请自行查阅 再见!onActivityResult!你好,Activity Results API! 。

写一个demo测试获取 Exif

现在,一切需求的准备工作都完成了,让咱们开端写一个简略的 demo 试试吧!

@Composable
fun MainScreen() {
    val contentResolver = LocalContext.current.contentResolver
    var imageUri: Uri? by remember { mutableStateOf(null) }
    var dateTime: String by remember { mutableStateOf("") }
    val choosePictureLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        if (uri != null) {
            dateTime = readDateTimeFromExif(uri, contentResolver).toString()
            imageUri = uri
        }
    }
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Image(
            painter = rememberAsyncImagePainter(model = imageUri),
            contentDescription = null,
            modifier = Modifier.size(500.dp),
            contentScale = ContentScale.Inside
        )
        Text(text = dateTime)
        Button(onClick = {
            choosePictureLauncher.launch("image/*")
        }) {
            Text(text = "点击挑选图片")
        }
    }
}
fun readDateTimeFromExif(uri: Uri, contentResolver: ContentResolver): String? {
    var dateTime: String? = null
    var input: InputStream? = null
    try {
        input = contentResolver.openInputStream(uri) ?: return dateTime
        val exifInterface = ExifInterface(input)
        dateTime = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)
    } catch (tr: Throwable) {
        Log.e(TAG, "readExif: ", tr)
    } finally {
        try {
            input?.close()
        } catch (tr: Throwable) {
            Log.e(TAG, "readExif: ", tr)
        }
    }
    return dateTime
}

运转作用如下:

在安卓中实现读取Exif获取照片拍摄日期后以水印文字形式添加到照片上

对了,在上面的界面中显现图片用到了 Coil 库。

给图片增加文字水印

在上面咱们现已完成了获取图片的 Exif 信息,那么下一步应该是给图片加上 Exif 读取到的日期时刻文字。

这个作用要怎样完成呢?

其实说起来也非常简略,咱们只需求将 Bitmap 生成 Canvas ,然后在 Canvas 中 drawText 即可。

不过需求留意的是,这儿的 Canvas 是安卓原生的 Canvas ,不是 Compose 中的 Canvas 哦。其实也能够运用 Compose 的 Canvas 来增加文字,可是,Compose 的 Canvas 没有供给制作文字的办法,终究也仍是需求在原生 Canvas 中制作。

import android.graphics.Canvas
// ……
val canvas = Canvas(bitmap)
val paint = Paint().apply {
    color = Color.LTGRAY
    textSize = 100f
}
canvas.drawText(
    "你好,方程",
    50f,
    canvas.height.toFloat() - 50f,
    paint
)

drawText 的第一个参数是需求制作的文本信息;

第二个参数是制作文字的 X 轴坐标;

第三个参数是制作文字的 Y 轴坐标,上面填写的是距离画布底部 50 个像素;

终究一个参数是画笔,咱们对制作内容的装备都写在了画笔中。

能够看到,运用 Canvas 制作文字非常简略,那么问题来了,怎样把 Uri 资源指向的图片转成 Bitmap?

调查 Bitmap 办法,会发现其间有一个 BitmapFactory.decodeStream() 办法,它接纳一个输入流(InputStream)来生成 Bitmap。

输入流?那上面咱们实例化 ExifInterface 不便是运用的输入流实例化的吗?所以咱们直接改一下就能够了。

var resultBitmap: Bitmap? = null
var input: InputStream? = null
try {
    input = contentResolver.openInputStream(uri) ?: return 
    resultBitmap = BitmapFactory.decodeStream(input)
    // ……
} catch (tr: Throwable) {
    Log.e(TAG, "readExif: ", tr)
} finally {
    try {
        input?.close()
    } catch (tr: Throwable) {
        Log.e(TAG, "readExif: ", tr)
    }
}

留意咱们这儿生成 bitmap 的办法: resultBitmap = BitmapFactory.decodeStream(input) ,假如直接将这个 bitmap 传给 Canvas 然后增加文字将会报错:

java.lang.IllegalStateException: Immutable bitmap passed to Canvas constructor
    at android.graphics.Canvas.<init>(Canvas.java:117)
    ……

从错误信息中也能很清楚的看到犯错原因:生成的这个 Bitmap 是不可变的,而结构 Canvas 需求的是可变 Bitmap,所以咱们应该这样写:

resultBitmap = BitmapFactory.decodeStream(input).copy(Bitmap.Config.ARGB_8888, true)

复制从输入流中生成的 Bitmap, 并将 isMutable(copy 的第二个参数) 设置为 true。

对了,copy 的第一个参数是色彩装备信息,一般运用 Bitmap.Config.ARGB_8888 即可。

再来一个小demo看看

现在,咱们现已知道怎么给图画增加水印文字,赶紧再写一个 demo 试试看吧!

@Composable
fun MainScreen() {
    val contentResolver = LocalContext.current.contentResolver
    var bitmap: Bitmap? by remember { mutableStateOf(null) }
    // var dateTime: String by remember { mutableStateOf("") }
    val choosePictureLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        if (uri != null) {
            val dateTime = readDateTimeFromExif(uri, contentResolver).toString()
            bitmap = addDateTime(uri, contentResolver, dateTime)
        }
    }
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Image(
            painter = rememberAsyncImagePainter(model = bitmap),
            contentDescription = null,
            modifier = Modifier.size(500.dp),
            contentScale = ContentScale.Inside
        )
        // Text(text = dateTime)
        Button(onClick = {
            choosePictureLauncher.launch("image/*")
        }) {
            Text(text = "点击挑选图片")
        }
    }
}
fun readDateTimeFromExif(uri: Uri, contentResolver: ContentResolver): String? {
    var dateTime: String? = null
    var input: InputStream? = null
    try {
        input = contentResolver.openInputStream(uri) ?: return dateTime
        val exifInterface = ExifInterface(input)
        dateTime = exifInterface.getAttribute(ExifInterface.TAG_DATETIME)
    } catch (tr: Throwable) {
        Log.e(TAG, "readExif: ", tr)
    } finally {
        try {
            input?.close()
        } catch (tr: Throwable) {
            Log.e(TAG, "readExif: ", tr)
        }
    }
    return dateTime
}
fun addDateTime(uri: Uri, contentResolver: ContentResolver, dateTime: String): Bitmap? {
    var resultBitmap: Bitmap? = null
    var input: InputStream? = null
    try {
        input = contentResolver.openInputStream(uri) ?: return resultBitmap
        resultBitmap = BitmapFactory.decodeStream(input).copy(Bitmap.Config.ARGB_8888, true)
        val canvas = Canvas(resultBitmap)
        val paint = Paint().apply {
            color = Color.LTGRAY
            textSize = 100f
        }
        canvas.drawText(
            dateTime,
            50f,
            canvas.height.toFloat() - 50f,
            paint
        )
    } catch (tr: Throwable) {
        Log.e(TAG, "readExif: ", tr)
    } finally {
        try {
            input?.close()
        } catch (tr: Throwable) {
            Log.e(TAG, "readExif: ", tr)
        }
    }
    return resultBitmap
}

上面的 demo 运转作用如下:

在安卓中实现读取Exif获取照片拍摄日期后以水印文字形式添加到照片上

能够看到,从 EXif 中读取到的时刻现已被增加到了图片左下角。

将 Bitmap 保存至文件

上面现已完成了读取 Exif 信息以及将 Exif 信息写入图片文字水印,下一步,当然是导出这个图片了。

/**
 * 
 * @author equationl
 * 
 * 将 bitmap 保存为图片文件(开启紧缩保存为jpg,否则为png)
 * 
 * @param bitmap bitmap
 * @param fileName 保存文件名(不含扩展名)
 * @param savePath 保存途径
 * @param isReduce 是否紧缩
 * @param quality 图片质量
 *
 * @return File 返回保存的文件
 */
@Throws(Exception::class)
fun saveBitmap2File(
    bitmap: Bitmap,
    fileName: String,
    savePath: File?,
    isReduce: Boolean,
    quality: Int
): File {
    val f: File
    val imgFormat: Bitmap.CompressFormat
    if (isReduce) {
        f = File(savePath, "$fileName.jpg")
        imgFormat = Bitmap.CompressFormat.JPEG
    } else {
        f = File(savePath, "$fileName.png")
        imgFormat = Bitmap.CompressFormat.PNG
    }
    if (!f.createNewFile()) {
        Log.w(TAG, "file " + f + "has already exist")
    }
    val outputStream = FileOutputStream(f)
    // 将bitmap写入输出流
    if (!bitmap.compress(imgFormat, quality, outputStream)) {
        Log.e(TAG, "saveBitmap2File: write bitmap to file fail!")
        if (isReduce) {
            throw CompressToJpegException("Export picture to jpg fail, Try not using compress picture or reduce picture size!")
        }
        else {
            throw Exception("saveBitmap2File: write bitmap to file fail!")
        }
    }
    try {
        outputStream.flush()
        outputStream.close()
    } catch (e: Exception) {
        Log.e(TAG, "saveBitmap2File: ", e)
    }
    return f
}

代码很简略,中心便是调用 bitmap 的 compress 办法,将 bitmap 写入输出流。

总结

现在这篇文章的篇幅现已不少了,就先提到这儿吧。

后边还没有提到的功用还剩余怎么批量导入\导出和处理图片,别的,根据我的设想,将一切图片复制到手机上来处理显然是不合理的,所以应该改为监听外部设备插入(U盘、读卡器等)后,自动读取其间指定图片并进行处理。

还有,正如我所说的,假如仅仅是增加日期水印显然不适合于专门做一个 APP,我的主意是增加水印仅仅其间一个小功用,完整的功用应该是支撑直接将相片合成视频,并应该支撑进行简略的修改。

不过这些都是后话了。