项目有个web页面卡片类型UI,卡片有不同宽高大小。现在想在卡片上增加一个封面边框,设计给出的切图
,在不同卡片宽高时候,需要展示示意图如
,要求原切图右上角区域维持不变,其他可以适应宽高拉伸。
首先得选择,自然是点九图(NinePatch)来实现需求。点九图是android系统中特有的图片格式,包含有定义可拉伸区域的信息,用于做局部拉伸。iOS在处理这种图片,也是非常方便的,有相关的系统函数可以做处理,
- (UIImage *)resizableImageWithCapInsets:(UIEdgeInsets)capInsets resizingMode:(UIImageResizingMode)resizingMode;
该函数返回一张被拉伸(压缩)之后的image图片,在UIImageView上展示即是拉伸(压缩)之后的效果。
函数需要传入两个参数,capInsets
,resizingMode
,
capInsets: UIEdgeInsets{CGFloat top, left, bottom, right}, 定义了受保护区域,除去受保护区域,剩下则是可拉伸区域;
resizingMode: 图片拉伸模式,两种取值,UIImageResizingModeTile平铺和UIImageResizingModeStretch拉伸;
两个参数更具体的说明和影响效果,可以参考文章,文章针对不同取值有详细的demo和说明,
iOS图片拉伸(resizableImage) https://www.jianshu.com/p/84848c1b2d47
我们更关注的是capInsets
,定义了图片的受保护区域,用一张图来示例,如下图,top、left、bottom、right指定的四个绿色边角是受保护区域,不可拉伸;而中间的蓝色区域则是可以拉伸的。
由于不同的切图,其受保护区域(可拉伸区域)不同,调用函数resizableImageWithCapInsets
就需要视觉或者开发同学给出不同的capInsets
值,对于硬编码来说是很不方便的。那么,有没有一种自动确定capInsets
的方法呢?
有的,我们从点九图制作生成说起。
官方文档
Create resizable bitmaps (9-patch files) https://developer.android.com/studio/write/draw9patch
在android studio里面制作一张点九图(.9.png)。该点九图有上下左右四个边有一条1像素的黑线,用于标注拉伸区域和显示内容区域,例如
1号黑色条位置向下覆盖的区域表示图片横向拉伸时,只拉伸该区域; 2号黑色条位置向右覆盖的区域表示图片纵向拉伸时,只拉伸该区域; 3号黑色条位置向左覆盖的区域表示图片纵向显示内容的区域(在手机上主要是文字区域); 4号黑色条位置向上覆盖的区域表示图片横向显示内容的区域(在手机上主要是文字区域);
然而,包含4个黑边的.9.png图片,并不会用于真正的图片展示,真正用于手机展示的图片,需要使用工具来对.9.png做处理之后生成新的点九图,具体的说步骤为:
设计师或者产品给出原始切图top1.png;
使用android studio制作包含4个黑边点九图top1.9.png;
使用android sdk 目录下的 aapt
工具将点九图转化为png图片 top1_out.png;
aapt
工具是android sdk目录下,可以在Android Studio Preferences | Languages & Frameworks | Android SDK
找到sdk location,如果没有sdk,则需要手动安装android sdk,然后找到location,aapt
在我机器参考目录为~/Library/Android/sdk/build-tools/34.0.0,执行命令如下:
./aapt s -i top1.9.png -o top1_out.png
本地使用该 top1_out.png图片,或者将图片上传至网络cdn,拿到图片url;
这里第3步,aapt
会把4个黑边的点九图信息,写入到结果png图片中的chunkdata数据中,并且去掉4个1像素的黑边,这样得到一张可用于手机展示的点九图片。其关键信息都在写在png的点九chunkdata里面,那么我们怎么获取图片的点九图信息呢?
我们从PNG文件格式着手。
PNG文件格式是有标准规范的,
PNG Specification http://www.libpng.org/pub/png/spec/1.2/PNG-Structure.html
PNG格式文件由一个8字节的PNG文件标识(file signature or file header)域和3个以上的后续数据块(chunk)如IHDR、IDAT、IEND等组成。
PNG文件标识 | 数据块 | …… | 数据块 |
---|---|---|---|
0x 89 50 4E 47 0D 0A 1A 0A |
Length | 4 bytes | 指定本数据块中Chunk Data的长度 |
Chunk Type | 4 bytes | 数据块类型码,由ASCII字母组成的”数据块符号” |
Chunk Data | Length bytes | 数据 |
CRC | 4 bytes | 循环冗余码 |
长度/数字都是网络字节序, All integers that require more than one byte must be in network byte order
如下图是一个实际png文件hex格式展示:
我们知道aapt
会把点九NinePatch信息写入到PNG的chunk中,那么怎么知道其chunk的类型以及数据结构呢?
aapt
处理点九图相关代码在tools/aapt/Images.cpp,以及从android源码中,对应点九图NinePatch解析代码以及头文件定义,
ResourceTypes.h https://android.googlesource.com/platform/frameworks/base/+/56a2301/include/androidfw/ResourceTypes.h
*
* The PNG chunk type is "npTc".
*/
struct Res_png_9patch
{
Res_png_9patch() : wasDeserialized(false), xDivs(NULL),
yDivs(NULL), colors(NULL) { }
int8_t wasDeserialized;
int8_t numXDivs;
int8_t numYDivs;
int8_t numColors;
// These tell where the next section of a patch starts.
// For example, the first patch includes the pixels from
// 0 to xDivs[0]-1 and the second patch includes the pixels
// from xDivs[0] to xDivs[1]-1.
// Note: allocation/free of these pointers is left to the caller.
int32_t* xDivs;
int32_t* yDivs;
int32_t paddingLeft, paddingRight;
int32_t paddingTop, paddingBottom;
可以还原出数据块类型码是npTc
,对应的数据结构,网上已经有文章总结,我就直接引用了NinePatch数据结构:
变量 | 长度:byte | 说明 |
---|---|---|
wasDeserialized | 1 | 无意义,非0即可 |
numXDivs | 1 | 上方黑点标记的数量,即可以多段标记,xDivs数组的数量 |
numYDivs | 1 | 左方黑点标记的数量,即可以多段标记,yDivs数组的数量 |
numColors | 1 | 颜色数量 |
xDivsOffset | 4 | xDivs 内存起始偏移,方便直接定位到 xDivs |
yDivsOffset | 4 | yDivs 内存起始偏移,方便直接定位到 yDivs |
paddingLeft | 4 | 右方和下方的黑线标记,padding |
paddingRight | 4 | 右方和下方的黑线标记,padding |
paddingTop | 4 | 右方和下方的黑线标记,padding |
paddingBottom | 4 | 右方和下方的黑线标记,padding |
colorOffset | 4 | Colors 内存起始偏移,方便直接定位到 Colors |
xDivs | numXDivs*4 | 上方黑点标记数组,表示横向拉伸区域 |
yDivs | numYDivs*4 | 左方黑点标记数组,表示纵向拉伸区域 |
Colors | numColors*4 | Sample |
这里,包含可拉伸区域的数组xDivs和yDivs,用于指定如何将图像分割成多个部分进行拉伸缩放,
xDivs描述了拉伸区域水平方向的起始位置和结束位置 yDivs描述了拉伸区域垂直方向的起始位置和结束位置
更具体和详细的字段定义和理解,仍然参考文章
NinePatch数据结构 https://zhuanlan.zhihu.com/p/595445856
到此,我们就可以实现解析点九图PNG的编码;
//
// PNGNinePatch.h
// podDemo
//
//
#ifndef PNGNinePatch_h
#define PNGNinePatch_h
NS_ASSUME_NONNULL_BEGIN
@interface PNGNinePatch : NSObject
@property (nonatomic, assign) int32_t width;
@property (nonatomic, assign) int32_t height;
@property (nonatomic, assign) int8_t numXDivs;
@property (nonatomic, assign) int8_t numYDivs;
@property (nonatomic, assign) int8_t numColors;
@property (nonatomic, assign) int32_t paddingLeft;
@property (nonatomic, assign) int32_t paddingRight;
@property (nonatomic, assign) int32_t paddingTop;
@property (nonatomic, assign) int32_t paddingBottom;
@property (nonatomic, strong) NSArray<NSNumber *> *xDivsArray;
@property (nonatomic, strong) NSArray<NSNumber *> *yDivsArray;
+ (nullable instancetype)ninePatchWithPNGFileData:(NSData *)data;
/// 获取点九图bitmap中的可拉伸区域,如果返回UIEdgeInsetsZero,则表示没有可以拉伸的区域
/// 点九图可能包含多个不连续的可拉伸区域,本函数只取第一个
- (UIEdgeInsets)resizableCapInsets;
@end
NS_ASSUME_NONNULL_END
#endif /* PNGNinePatch_h */
// PNGNinePatch.m
// podDemo_Example
//
// Created by asterpang on 2023/7/20.
// Copyright © 2023 asterpang. All rights reserved.
//
#import <Foundation/Foundation.h>
#import "PNGNinePatch.h"
static char bytes[8] = {0};
@implementation PNGNinePatch
// https://dev.exiv2.org/projects/exiv2/wiki/The_Metadata_in_PNG_files
// https://android.googlesource.com/platform/frameworks/base/+/56a2301c7a1169a0692cadaeb48b9a6385d700f5/include/androidfw/ResourceTypes.h
+ (instancetype)ninePatchWithPNGFileData:(NSData *)data
{
if(data.length < 32) {
return nil;
}
int index = 0;
// 先判断是否png图片
if ([[self class] readInt32:data fromIndex:&index] != 0x89504e47 || [[self class] readInt32:data fromIndex:&index] != 0x0D0A1A0A) {
// 不是png图片,不再处理
return nil;
}
// NinePatch的chunk type标记
// The PNG chunk type is "npTc"
const char npTc[4] = {'n', 'p', 'T', 'c'};
BOOL hasNinePatchChunk = NO;
int32_t chunk_length = 0;
while (YES) {
if(index >= data.length - 8) {
break;
}
// 获取chunk长度
chunk_length = [[self class] readInt32:data fromIndex:&index];
// 获取chunk type标记
[data getBytes:bytes range:NSMakeRange(index, 4)];
index += 4;
if (memcmp(bytes, npTc, 4) == 0) {
// 表示读取到了NinePatch信息,index之后的数据是chunk data
hasNinePatchChunk = YES;
break;
}
// 跳过本chunk(数据长度 chunk_length + CRC 4bytes)
index += chunk_length + 4;
}
PNGNinePatch *ninePatch = nil;
if(hasNinePatchChunk && chunk_length > 0 && data.length > index + chunk_length) {
ninePatch = PNGNinePatch.new;
int8_t wasDeserialized = [[self class] readInt8:data fromIndex:&index];
if(wasDeserialized == 0) {
// nothing to do
}
ninePatch.numXDivs = [[self class] readInt8:data fromIndex:&index];
ninePatch.numYDivs = [[self class] readInt8:data fromIndex:&index];
ninePatch.numColors = [[self class] readInt8:data fromIndex:&index];
// skip xDivsOffset/yDivsOffset
index += 4 + 4;
ninePatch.paddingLeft = [[self class] readInt32:data fromIndex:&index];
ninePatch.paddingRight = [[self class] readInt32:data fromIndex:&index];
ninePatch.paddingTop = [[self class] readInt32:data fromIndex:&index];
ninePatch.paddingBottom = [[self class] readInt32:data fromIndex:&index];
// skip colorOffset
index += 4;
// now xDivs,即点九图上方黑点标记数组,横向可拉伸区域
NSMutableArray<NSNumber *> *xDivsArray = NSMutableArray.new;
for(int count = 0; count < ninePatch.numXDivs; count++) {
[data getBytes:bytes range:NSMakeRange(index, 4)];
index += 4;
int32_t x = ntohl( *(int32_t *)bytes);
[xDivsArray addObject:@(x)];
}
// now yDivs,即点九图左边黑点标记数组,纵向可拉伸区域
NSMutableArray<NSNumber *> *yDivsArray = NSMutableArray.new;
for(int count = 0; count < ninePatch.numYDivs; count++) {
[data getBytes:bytes range:NSMakeRange(index, 4)];
index += 4;
int32_t y = ntohl(*(int32_t *)bytes);
[yDivsArray addObject:@(y)];
}
ninePatch.xDivsArray = xDivsArray;
ninePatch.yDivsArray = yDivsArray;
}
return ninePatch;
}
- (UIEdgeInsets)resizableCapInsetsWithImageSize:(CGSize)imageSize
{
if(self.xDivsArray.count < 2 || self.yDivsArray.count < 2) {
return UIEdgeInsetsZero;
}
// 可以是多段分割,指定拉伸/压缩,不过我们约定需求没那么复杂,只需要拉伸第一段区域
// 如需多段处理,则更该代码
int32_t xStart = self.xDivsArray[0].intValue;
int32_t xEnd = self.xDivsArray[1].intValue;
int32_t yStart = self.yDivsArray[0].intValue;
int32_t yEnd = self.yDivsArray[1].intValue;
if(xEnd < xStart || yEnd < yStart) {
return UIEdgeInsetsZero;
}
UIEdgeInsets insets;
insets.top = yStart;
insets.left = xStart;
insets.bottom = imageSize.height - yEnd;
insets.right = imageSize.width - xEnd;
if(insets.bottom < 0 || insets.right < 0) {
return UIEdgeInsetsZero;
}
return insets;
}
+ (int8_t)readInt8:(NSData *)data fromIndex:(int *)index
{
[data getBytes:bytes range:NSMakeRange(*index, 1)];
*index += 1;
return (int8_t)bytes[0];
}
+ (int32_t)readInt32:(NSData *)data fromIndex:(int *)index
{
[data getBytes:bytes range:NSMakeRange(*index, 4)];
*index += 4;
return ntohl(*(int32_t *)bytes);
}
@end
使用上也比较简单,
PNGNinePatch *ninePatch = [PNGNinePatch ninePatchWithPNGFileData:imageFileData];
UIEdgeInsets insets = [ninePatch resizableCapInsets];
image = [image resizableImageWithCapInsets:insets resizingMode:UIImageResizingModeStretch];