修复 iOS 网页在拍照预览时图片被旋转的问题

前几天用 uni-app 写了个上传图片的小案例(编译到 H5 端),当在 iPhone 上拍照上传时,发现在页面上预览的图片是发生了旋转的。

图片的 Exif 信息

Exif 全称 Exchangeable image file format,中文叫可交换图像文件格式,Exif 中存储了图片的方向信息。

EXIF Orientation Value Row #0 is: Column #0 is:
1 Top Left side
2 Top Right side
3 Bottom Right side
4 Bottom Left side
5 Left side Top
6 Right side Top
7 Right side Bottom
8 Left side Bottom

看不懂没关系,网上找了张示例图(如有侵权,请联系删之),请对号入座:

获取图片的 Orientation 信息

网上都在推荐 Exif.js 这个库,有兴趣的可以自行研究,我没用过,就不多说了。

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
/**
* 根据File对象获取图片的方向信息
* @param {File} file
* @param {Function} callback
*/
getOrientation: function(file, callback) {
var reader = new FileReader();
reader.onload = function(e) {
var view = new DataView(e.target.result);
if (view.getUint16(0, false) != 0xFFD8)
{
return callback(-2);
}
var length = view.byteLength, offset = 2;
while (offset < length)
{
if (view.getUint16(offset+2, false) <= 8) return callback(-1);
var marker = view.getUint16(offset, false);
offset += 2;
if (marker == 0xFFE1)
{
if (view.getUint32(offset += 2, false) != 0x45786966)
{
return callback(-1);
}

var little = view.getUint16(offset += 6, false) == 0x4949;
offset += view.getUint32(offset + 4, little);
var tags = view.getUint16(offset, little);
offset += 2;
for (var i = 0; i < tags; i++)
{
if (view.getUint16(offset + (i * 12), little) == 0x0112)
{
return callback(view.getUint16(offset + (i * 12) + 8, little));
}
}
}
else if ((marker & 0xFF00) != 0xFF00)
{
break;
}
else
{
offset += view.getUint16(offset, false);
}
}
return callback(-1);
};
reader.readAsArrayBuffer(file);
}

如果是采用 <input type="file" capture="camera" accept="image/png, image/jpeg"> 来拍照,返回的就是一个 FileList 对象,里面储存的每一个 File 对象代表用户选择的一个图片,可以直接调用上面 getOrientation 方法获取图片的方向信息

但是 uni-app 的 uni.chooseImage 接口在 H5 端返回的是一个 blob:http://xxxxxx/xxxxx 格式的路径信息,并不是一个 File 对象,所以我们需要做一些额外处理:

1、将路径转成 Blob 对象

1
2
3
4
5
6
7
8
const xhr  = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.responseType = "blob";
xhr.onload = function() {
const blobObject = this.response;
// ...
};
xhr.send();

2、将 Blob 转成 File

1
const file = new File([blobObject], 'photo.png', {type: blobObject.type});

3、调用 getOrientation,然后做相应的旋转处理

修复预览旋转

由于我的项目是基于 uni-app 进行构建的,所以这里主要是针对 uni-app 下编译的 H5 页面进行说明。

完整示例代码:

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
<template>
<view class="container">
<view class="content">
<view class="photo" @click="onTokePhotoTapped">
<image src="/static/placeholder.png"></image>
<image :src="faceImage" mode="aspectFill" :style="fixOrientationStyle"></image>
</view>
<view class="field-item">
<view class="field-item-title">工号</view>
<input class="field-item-input" placeholder="请输入工号" maxlength="30" v-model="jobNumber" />
</view>
<view class="submit-button" @click="onSubmitTapped">提交</view>
</view>
</view>
</template>

<script>
export default {
data() {
return {
jobNumber: '',
faceImage: '',
fixOrientationStyle: '', // 修正图片方向
}
},

components: { PlaceholderImage },

onLoad() {

},

methods: {

onTokePhotoTapped: function() {
uni.chooseImage({
count: 1,
success: (res) => {
// this.faceImage = res.tempFilePaths[0];
/**
1、URL => Blob => File
2、获取方向
*/
const url = res.tempFilePaths[0];
const self = this;
let xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.responseType = "blob";
xhr.onload = function() {
self.faceImage = url;
self.fixOrientationStyle = '';
if (this.status == 200) {
const blob = this.response;
const file = new File([blob], 'photo.png', {type: blob.type});
self.getOrientation(file, (orientation) => {
switch(orientation){
case 6://需要顺时针(向左)90度旋转
self.fixOrientationStyle = 'transform: rotate(90deg);';
break;
case 8://需要逆时针(向右)90度旋转
self.fixOrientationStyle = 'transform: rotate(-90deg);';
break;
case 3://需要180度旋转
self.fixOrientationStyle = 'transform: rotate(180deg);';
break;
}
});
}
};
xhr.send();
}
});
},

onSubmitTapped: function() {
const number = this.jobNumber.replace(/(^\s*)|(\s*$)/g, '');
if (!number.length) {
uni.showToast({ icon: 'none', title: '请输入工号' });
return;
}
if (!this.faceImage.length) {
uni.showToast({ icon: 'none', title: '请选择图片' });
return;
}
uni.showLoading({ mask: true, title: '正在保存...' });
// TODO: 这里就是图片上传的代码, 直接调用相应接口即可
// 这里上传的图片还是旋转的,需要后台将图片方向修正过来
},

/**
* 获取图片旋转方向

* @param {Object} file File对象
* @param {Object} callback 回调
*/
getOrientation: function(file, callback) {
var reader = new FileReader();
reader.onload = function(e) {

var view = new DataView(e.target.result);
if (view.getUint16(0, false) != 0xFFD8)
{
return callback(-2);
}
var length = view.byteLength, offset = 2;
while (offset < length)
{
if (view.getUint16(offset+2, false) <= 8) return callback(-1);
var marker = view.getUint16(offset, false);
offset += 2;
if (marker == 0xFFE1)
{
if (view.getUint32(offset += 2, false) != 0x45786966)
{
return callback(-1);
}

var little = view.getUint16(offset += 6, false) == 0x4949;
offset += view.getUint32(offset + 4, little);
var tags = view.getUint16(offset, little);
offset += 2;
for (var i = 0; i < tags; i++)
{
if (view.getUint16(offset + (i * 12), little) == 0x0112)
{
return callback(view.getUint16(offset + (i * 12) + 8, little));
}
}
}
else if ((marker & 0xFF00) != 0xFF00)
{
break;
}
else
{
offset += view.getUint16(offset, false);
}
}
return callback(-1);
};
reader.readAsArrayBuffer(file);
}
}
}
</script>

<style>
page {
background-color: #EFEFF4;
font-size: 30rpx;
}
.container {
width: 100%;
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.content {
width: 600rpx;
margin-top: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.photo {
width: 400rpx;
height: 400rpx;
border-radius: 20rpx;
background-color: white;
overflow: hidden;
position: relative;
}
.photo image {
width: 100%;
height: 100%;
}
.photo image:last-child {
position: absolute;
top: 0;
left: 0;
}
.field-item {
width: calc(100% - 40rpx);
height: 88rpx;
padding: 0 20rpx;
margin: 30rpx 0 50rpx;
border-radius: 10rpx;
overflow: hidden;
background-color: white;
display: flex;
align-items: center;
}
.field-item-title {
width: 100rpx;
}
.field-item-input {
width: calc(100% - 100rpx);
height: 100%;
}
.submit-button {
width: 100%;
height: 80rpx;
border-radius: 20rpx;
background-color: #007AFF;
color: white;
display: flex;
align-items: center;
justify-content: center;
}
</style>

参考

CSS设置宽高比

1
2
3
4
5
<view class="scale-box">
<view class="scale-box-content">
<!-- content -->
</view>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.scale-box {
width: 100%;
height: 0;
padding-bottom: 100%;
position: relative;
}
.scale-box-content {
width: 100%;
height: 100%;
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

通过设置 scale-box 的 padding-bottom 来控制宽高比例,width 和 padding-botton 都为 100% 则表示正方形,宽度 100%、padding-bottom 50% 则表示宽高比为 2:1

iOS 验证码输入框

特性

  • 支持自定义验证码长度(默认长度为6)
  • 支持切换键盘类型,适应纯数字、数字+字母等组合(默认为系统数字键盘)
  • 验证码输入完毕后自动收起键盘并通过block回调调用者

效果图

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
NS_AVAILABLE_IOS(9_0)
@interface VCVerifyCodeInputView : UIView

/// 验证码长度,默认`6`
@property (nonatomic, assign) NSInteger length;
/// 键盘类型,默认`UIKeyboardTypeNumberPad`
@property (nonatomic, assign) UIKeyboardType keyboardType;
/// 验证码输入完毕后的回调
@property (nonatomic, copy) void(^didInputCompletionHandler)(NSString *verifycode);
/// 文本框高亮颜色,默认(30,144,255)
@property (nonatomic, strong) UIColor *focusColor;

/// 获取用户输入的验证码
@property (nonatomic, copy, readonly) NSString *verifycode;

@end
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
@interface VCVerifyCodeInputView ()<UITextFieldDelegate>

@end

@implementation VCVerifyCodeInputView
{
UIStackView *stackView;
UITextField *textField;
}

#pragma mark - Lifecycle

- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self setup];
}
return self;
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder {
if (self = [super initWithCoder:aDecoder]) {
[self setup];
}
return self;
}

- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self name:UITextFieldTextDidChangeNotification object:textField];
}

#pragma mark - <UITextFieldDelegate>

- (void)textFieldDidBeginEditing:(UITextField *)textField {
[self updateInputIndicator];
}

- (void)textFieldDidEndEditing:(UITextField *)textField {
for (UIView *view in stackView.arrangedSubviews) {
view.layer.borderColor = [[UIColor blackColor] CGColor];
}

if (textField.text.length == self.length && self.didInputCompletionHandler) {
self.didInputCompletionHandler(textField.text);
}
}

- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string {
if (range.length == 0) { // Add.
if (textField.text.length == self.length) {
return NO;
}
NSString *regexp = @"^[A-Za-z0-9]{1}$";
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"SELF MATCHES %@", regexp];
return [predicate evaluateWithObject:string];
}
return YES;
}

#pragma mark - Actions

- (void)textFieldTextDidChangeNotificationAction:(NSNotification *)sender {
NSString *text = textField.text;
[self updateInputIndicator];
if (textField.isEditing && text.length == self.length) {
// 收起键盘并回调block
[textField resignFirstResponder];
}
}

#pragma mark - Private

- (void)setup {
stackView = [[UIStackView alloc] init];
stackView.axis = UILayoutConstraintAxisHorizontal;
stackView.distribution = UIStackViewDistributionFillEqually;
stackView.spacing = 10.0;
[self addSubviewToFill:stackView];

textField = [[UITextField alloc] init];
textField.borderStyle = UITextBorderStyleNone;
textField.backgroundColor = [UIColor clearColor];
textField.keyboardType = UIKeyboardTypeNumberPad;
textField.autocorrectionType = UITextAutocorrectionTypeNo; // 禁用自动联想功能
textField.tintColor = [UIColor clearColor]; // 清除光标颜色
textField.textColor = [UIColor clearColor]; // 清除文字颜色
textField.delegate = self;
[self addSubviewToFill:textField];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(textFieldTextDidChangeNotificationAction:) name:UITextFieldTextDidChangeNotification object:textField];

[self setLength:6];
self.focusColor = [UIColor colorWithRed:30/255.0 green:144/255.0 blue:255/255.0 alpha:1.0];
}

/// 更新输入指示器状态
- (void)updateInputIndicator {
NSString *text = textField.text;
for (NSInteger idx=0; idx<self.length; idx++) {
UILabel *label = (UILabel *)stackView.arrangedSubviews[idx];
if (text.length && idx < text.length) {
label.text = [NSString stringWithFormat:@"%C", [text characterAtIndex:idx]];
} else {
label.text = nil;
}
if (idx == text.length) {
label.layer.borderColor = [self.focusColor CGColor];
} else {
label.layer.borderColor = [UIColor blackColor].CGColor;
}
}
}

- (void)addSubviewToFill:(UIView *)subview {
[self addSubview:subview];
subview.translatesAutoresizingMaskIntoConstraints = NO;
[subview.topAnchor constraintEqualToAnchor:self.topAnchor].active = YES;
[subview.bottomAnchor constraintEqualToAnchor:self.bottomAnchor].active = YES;
[subview.leadingAnchor constraintEqualToAnchor:self.leadingAnchor].active = YES;
[subview.trailingAnchor constraintEqualToAnchor:self.trailingAnchor].active = YES;
}

#pragma mark - setter & getter

- (void)setLength:(NSInteger)length {
[stackView.arrangedSubviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
for (NSInteger idx=0; idx<length; idx++) {
UILabel *label = [[UILabel alloc] init];
label.textAlignment = NSTextAlignmentCenter;
label.backgroundColor = [UIColor clearColor];
label.layer.borderWidth = 1.0;
label.layer.borderColor = [[UIColor blackColor] CGColor];
label.layer.cornerRadius = 5.0;
label.layer.masksToBounds = YES;
[stackView addArrangedSubview:label];
}
}

- (NSInteger)length {
return stackView.arrangedSubviews.count;
}

- (void)setKeyboardType:(UIKeyboardType)keyboardType {
textField.keyboardType = keyboardType;
}

- (UIKeyboardType)keyboardType {
return textField.keyboardType;
}

- (NSString *)verifycode {
return textField.text;
}

@end

使用示例

1
2
3
4
5
6
7
8
VCVerifyCodeInputView *verifyCodeView = [[VCVerifyCodeInputView alloc] init];
verifyCodeView.frame = CGRectMake(20.0, 100.0, 340.0, 40.0);
verifyCodeView.length = 8;
verifyCodeView.focusColor = [UIColor redColor];
[verifyCodeView setDidInputCompletionHandler:^(NSString *verifycode) {
NSLog(@"输入的验证码是: %@", verifycode);
}];
[self.view addSubview:verifyCodeView];

通过 DOMSubtreeModified 事件接收 DOM 节点变化的回调

需求场景:通过 WKWebView 加载第三方网页,其中有一个录制人脸视频的功能,为了用户体验需要默认调用前摄像头,然而第三方的网页我们无法调整,只能通过注入 JS 来修改 input 标签,将 capture 属性改成 user 即可调用前摄像头。

网页内容是动态生成的,并不是写死的模板,所以需要监听 body 子节点发生改变的事件,每当 body 内容发生变更时,就去找出调用摄像头的 input 标签,找到之后动态修改它的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function findVideoInput() {
var inputs = document.getElementsByTagName('input');
for (var i = 0; i < inputs.length; i++) {
var input = inputs[i];
var type = input.getAttribute('type').toString().toLowerCase();
var accept = input.getAttribute('accept');
if (type == 'file' && /^video/i.test(accept)) {
document.querySelector('body').removeEventListener('DOMSubtreeModified', findVideoInput, false);
input.setAttribute('capture', 'user');
}
}
}
document.querySelector('body').addEventListener('DOMSubtreeModified', findVideoInput, false);
setTimeout(findVideoInput, 0);

虽然 DOMSubtreeModified 已经声明为 Deprecated 了,但是目前来说还是可以用的;建议使用 MutationObserver 来代替 DOMSubtreeModified。

适配iOS13

虽然苹果在今天推送了 iOS13.1.1,但还是简单记录下适配 iOS13 的过程吧。

KVC禁止访问私有属性

在 Xcode11 中苹果禁止了开发者通过 KVC 方式获取私有属性,如果你在代码中通过该方式访问了不该访问的东西,App 则会闪退并在控制台中可以看到如下错误信息:

1
Terminating app due to uncaught exception 'NSGenericException', reason: 'Access to UISearchBar's _searchField ivar is prohibited. This is an application bug'

简单明了,就是不允许你访问了!!!

  • 获取 UISearchBar 的 UITextField

在 iOS13 中,苹果提供了 searchTextField 属性来获取对应的 UITextField,无需通过 KVC 获取。但是我们需要兼容旧系统,所以我的解决方案是给 iOS13 以下的系统动态添加 searchTextField 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
UITextField* UISearchBar_searchTextField(id self, SEL _cmd)
{
if (@available(iOS 13.0, *)) {
return [(UISearchBar *)self searchTextField];
}
return [self valueForKey:@"_searchField"];
}

@implementation UISearchBar (iOS13)

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if (@available(iOS 13.0, *)) {
// do nothing.
} else {
// 动态添加 `-searchTextField` 方法, 兼容iOS13以下版本
class_addMethod(self, @selector(searchTextField), (IMP)UISearchBar_searchTextField, "@@:");
}
});
}

@end

  • 导航栏透明度

通过访问导航栏私有视图 _backgroundEffectView 并设置其透明度来达到导航栏透明的效果,现在已经不需要这么做了。

下面给出核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (void)setNavigationBarBackgroundAlpha:(CGFloat)alpha {
UIView *barBackgroundView = self.navigationBar.subviews.firstObject;
if (@available(iOS 13.0, *)) {
barBackgroundView.alpha = alpha;
return;
}
// 处理底部分割线
UIView *shadowView = [barBackgroundView valueForKey:@"_shadowView"];
shadowView.hidden = (alpha < 1.0);
// 处理背景
if (self.navigationBar.isTranslucent) {
if (@available(iOS 10.0, *)) {
UIView *backgroundEffectView = [barBackgroundView valueForKey:@"_backgroundEffectView"];
backgroundEffectView.alpha = alpha;
} else {
UIView *adaptiveBackdrop = [barBackgroundView valueForKey:@"_adaptiveBackdrop"];
adaptiveBackdrop.alpha = alpha;
}
} else {
barBackgroundView.alpha = alpha;
}
}

完整代码请点击 NavigationBarTranslucent

目前我的项目只有这两个地方访问了私有属性。

UISegmentedControl

在 iOS13 中,苹果对 UISegmentedControl 的样式做了大改动,改成了白底黑字的方块。如果你在之前的版本中,通过 tintColor 设置了其颜色,那么在 iOS13 中是无效的,iOS13 提供了 selectedSegmentTintColor 属性来设置颜色。

幸好产品没说一定要改成以前的边框样式,不然我真的是欲哭无泪了…

模态窗口的默认样式调整

在 iOS13 中,通过 presentViewController:animated:completion: 弹出来的控制器,modalPresentationStyle 属性默认是 UIModalPresentationPageSheet,而之前的系统版本默认的是 UIModalPresentationFullScreen

不仅将默认值改为 UIModalPresentationPageSheet,还对 UIModalPresentationPageSheet 的样式也进行了修改,将 iPad 中的样式引入到了 iPhone 中,并且自带下滑关闭手势,可以通过将 modalInPresentation 设置为 YES 关闭下滑手势。

针对这个改变,你可以手动指定 modalPresentationStyle 的值为 UIModalPresentationFullScreen 来恢复以前的样式。

如果想懒的话,也可以通过 Category 来解决(个人不推荐,自己决定):

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
52
53
#import <objc/runtime.h>

@interface UIViewController (iOS13ResetModalStyle)

@end

@implementation UIViewController (iOS13ResetModalStyle)

static const char kIsSetedModalPresentationStyleKey = '\0';

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if (@available(iOS 13.0, *)) {
NSArray *sels = @[
NSStringFromSelector(@selector(setModalPresentationStyle:)),
NSStringFromSelector(@selector(presentViewController:animated:completion:)),
];
for (NSString *name in sels) {
Method originalMethod = class_getInstanceMethod(self, NSSelectorFromString(name));
Method swizlledMethod = class_getInstanceMethod(self, NSSelectorFromString([@"xp_" stringByAppendingString:name]));
method_exchangeImplementations(originalMethod, swizlledMethod);
}
}
});
}

- (void)xp_setModalPresentationStyle:(UIModalPresentationStyle)modalPresentationStyle {
objc_setAssociatedObject(self, &kIsSetedModalPresentationStyleKey, @(YES), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[self xp_setModalPresentationStyle:modalPresentationStyle];
}

- (void)xp_presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion {
if (@available(iOS 13.0, *)) {
switch (viewControllerToPresent.modalPresentationStyle) {
case UIModalPresentationPageSheet:
case UIModalPresentationNone:
case UIModalPresentationAutomatic:
{
BOOL isSetedModalPresentationStyle = [objc_getAssociatedObject(viewControllerToPresent, &kIsSetedModalPresentationStyleKey) boolValue];
if (!isSetedModalPresentationStyle) {
viewControllerToPresent.modalPresentationStyle = UIModalPresentationFullScreen;
}
}
break;
default:
break;
}
}
[self xp_presentViewController:viewControllerToPresent animated:flag completion:completion];
}

@end

项目中只有登录页面、UIAlertController、UIImagePickerController等几个控制器是通过 presentViewController:animated:completion: 方式弹出来的,由于 UIAlertController 的特殊性不会被影响,实际受影响的只有登录页面和从系统相册选择相片这两个功能;由于页面少,且产品觉得在不影响使用的情况下就不用修改了,所以上面代码虽然撸了,但是在项目中是被注释掉的。

Dark Mode

适配可以参看 UIView 和 UIViewController 的 overrideUserInterfaceStyle 属性。

项目并不打算适配暗黑模式,但是如果在 iPhone 中开启了 Dark Mode,将会导致 App 的部分视图变黑,UI很难看,所以这里需要关闭 Dark Mode 功能,告诉系统,我的 App 不支持暗黑模式,你别给我变黑了…

需要在 Info.plist 中加入 UIUserInterfaceStyle 这个key并指定样式为 LightDark

1
2
<key>UIUserInterfaceStyle</key>
<string>Light</string>

通过将样式指定为 Light 后,App 将不会受暗黑模式的影响了。

PS: 公司项目在 Info.plist 中添加 UIUserInterfaceStyle 并已成功上架!!!

UISearchDisplayController

UISearchDisplayController 在 iOS8 的时候就被废弃了,虽然在之后的版本中也一直可以使用,但是在最新的 iOS13 中,UISearchDisplayController 终于寿终正寝了,如果你在 iOS13 中继续使用它,那么你将收到如下的Crash信息:

1
Terminating app due to uncaught exception 'NSGenericException', reason: 'UISearchDisplayController is no longer supported when linking against this version of iOS. Please migrate your application to UISearchController.'

还是乖乖使用 UISearchController 吧。

蓝牙

在以前,App 可以直接使用蓝牙功能,不会出现权限的提示弹窗,在 iOS13 中,想使用蓝牙,则需要申请权限才行了,和相机、定位一样。

Sign With Apple

Authenticating Users with Sign in with Apple
WWDC2019

导航栏样式的定制

一般项目中都会有自定义导航栏的需求(修改标题文字样式、UIBarButtonItem样式、返回按钮等),在 iOS13 之前都是通过 UIAppearance 协议获取到一个全局的外观代理对象,然后通过该代理进行样式自定义,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@implementation UINavigationController

+ (void)initialize {
UIImage *backImage = [[UIImage imageNamed:@"icon-back"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal];
UINavigationBar *navigationBar = [UINavigationBar appearance];
navigationBar.translucent = YES;
navigationBar.titleTextAttributes = @{NSForegroundColorAttributeName: [UIColor whiteColor]};
navigationBar.backIndicatorImage = backImage;
navigationBar.backIndicatorTransitionMaskImage = backImage;

NSDictionary *attributes = @{
NSFontAttributeName: [UIFont systemFontOfSize:15.0],
NSForegroundColorAttributeName: [UIColor whiteColor]
};
UIBarButtonItem *barButtonItem = [UIBarButtonItem appearanceWhenContainedInInstancesOfClasses:@[[UINavigationBar class]]];
[barButtonItem setTitleTextAttributes:attributes forState:UIControlStateNormal];
[barButtonItem setTitleTextAttributes:attributes forState:UIControlStateHighlighted];
}

@end

在 iOS13 中,苹果新增了几个类:UINavigationBarAppearanceUIBarAppearanceUIBarButtonItemAppearanceUIBarButtonItemStateAppearance,通过这几个类,可以定制导航栏的标题、背景、返回按钮(参看UINavigationBarAppearance类所提供的接口),以及UIBarButtonItem在normalhighlighteddisabledfocused几种状态下的样式(参看UIBarButtonItemAppearance类所提供的接口)。

苹果已经在 UINavigationBar 中提供了相关的接口来访问这些类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
Fallback Behavior:
1) Appearance objects are used in whole – that is, all values will be sourced entirely from an instance of UINavigationBarAppearance defined by one of these named properties (standardAppearance, compactAppearance, scrollEdgeAppearance) on either UINavigationBar (self) or UINavigationItem (self.topItem).
2) The navigation bar will always attempt to use the most relevant appearance instances first, before falling back to less relevant ones. The fallback logic is:
AtScrollEdge: self.topItem.scrollEdgeAppearance => self.scrollEdgeAppearance => self.topItem.standardAppearance => self.standardAppearance
CompactSize: self.topItem.compactAppearance => self.compactAppearance => self.topItem.standardAppearance => self.standardAppearance
NormalSize: self.topItem.standardAppearance => self.standardAppearance
*/

/// Describes the appearance attributes for the navigation bar to use when it is displayed with its standard height.
@property (nonatomic, readwrite, copy) UINavigationBarAppearance *standardAppearance UI_APPEARANCE_SELECTOR API_AVAILABLE(ios(13.0), tvos(13.0));
/// Describes the appearance attributes for the navigation bar to use when it is displayed with its compact height. If not set, the standardAppearance will be used instead.
@property (nonatomic, readwrite, copy, nullable) UINavigationBarAppearance *compactAppearance UI_APPEARANCE_SELECTOR API_AVAILABLE(ios(13.0));
/// Describes the appearance attributes for the navigation bar to use when an associated UIScrollView has reached the edge abutting the bar (the top edge for the navigation bar). If not set, a modified standardAppearance will be used instead.
@property (nonatomic, readwrite, copy, nullable) UINavigationBarAppearance *scrollEdgeAppearance UI_APPEARANCE_SELECTOR API_AVAILABLE(ios(13.0));

基本用法如下:

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
@implementation UINavigationBar

+ (void)initialize {
if (@available(iOS 13.0, *)) {
UINavigationBarAppearance *standardAppearance = [[UINavigationBarAppearance alloc] init];
standardAppearance.titleTextAttributes = @{NSForegroundColorAttributeName: [UIColor redColor]};
standardAppearance.backgroundColor = [UIColor orangeColor];
// 设置返回按钮(可以通过standardAppearance.backButtonAppearance进一步定制)
UIImage *backImage = [[UIImage imageNamed:@"icon-back"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal];
[standardAppearance setBackIndicatorImage:backImage transitionMaskImage:backImage];
// 设置UIBarButtonItem样式
NSDictionary *attributes = @{
NSFontAttributeName: [UIFont systemFontOfSize:15.0],
NSForegroundColorAttributeName: [UIColor whiteColor]
};
UIBarButtonItemAppearance *buttonAppearance = standardAppearance.buttonAppearance;
buttonAppearance.normal.titleTextAttributes = attributes;
buttonAppearance.highlighted.titleTextAttributes = attributes;
// 记得覆盖原来的样式
[[UINavigationBar appearance] setStandardAppearance:standardAppearance];
} else {
// 之前的做法
}
}

@end

参考:

macOS中搭建PHP+MySQL环境

请先安装 HomeBrew 工具

安装PHP

macOS自带了PHP,不过自带的PHP版本都是比较老的,我们可以通过brew工具来安装PHP并且不会与系统自带的PHP产生冲突。

  • 安装brew提供的最新版PHP(未必是PHP最新版本,只能是brew提供的最新版本)

    1
    brew install php
  • 开启PHP

1
sudo vi /etc/apache2/httpd.conf

打开 LoadModule php7_module libexec/apache2/libphp7.so 功能(去掉前面的#号)

  • apachectl命令的使用

启动Apache

1
sudo apachectl start

重启Apache

1
sudo apachectl restart

关闭Apache

1
sudo apachectl stop

  • 站点根目录

默认位置:/Library/WebServer/Documents

安装MySQL

安装

1
brew install mysql

启动MySQL

1
mysql.server start

重启MySQL

1
mysql.server restart

关闭MySQL

1
mysql.server stop

安装phpMyAdmin(目前下载的是4.9.0.1版本)

  • 从这里下载 phpMyAdmin,解压并重命名为phpmyadmin
  • 将phpmyadmin文件夹拷贝到 /Library/WebServer/Documents

允许空密码登录

MySQL默认是空密码的,而phpMyAdmin默认是不允许空密码登录的。

方案一:

  • 修改 phpmyadmin/libraries/config.default.php 文件,将 $cfg['Servers'][$i]['AllowNoPassword'] 的值改为 true

方案二:(推荐)

  • 将 phpmyadmin/config.sample.inc.php 复制一份并重命名为 phpmyadmin/config.inc.php
  • $cfg['Servers'][$i]['AllowNoPassword'] 的值改为 true

登录报错

  • mysqli_real_connect(): (HY000/2002): No such file or directory

    • 解决办法是将 $cfg['Servers'][$i]['host'] 的值改成 127.0.0.1;
  • mysqli_real_connect(): The server requested authentication method unknown to the client [caching_sha2_password]

    • 命令行登录MySQL并修改用户密码验证方式
      1
      2
      3
      mysql -uroot -p -h 127.0.0.1
      ALTER USER 你的用户名@localhost IDENTIFIED WITH mysql_native_password BY '你的密码';
      FLUSH PRIVILEGES;

MySQL8.0.13 默认的身份验证方式是:caching_sha2_password,PHP的mysqli扩展不支持新的 caching_sha2_password 身份验证功能,需要将验证方式更改为 mysql_native_password

iOS 逆向之 Cycript 篇

Cycript 是由 saurik 推出的一款脚本语言,可以看作是 Objective-C 和 JavaScript 的结合物,可通过 Cydia 安装。

注入进程

一般都是使用 cycript 来进行一些代码的测试,所以需要先把 cycript 注入到目标应用的进程中,通过如下方式注入:

1
cycript -p pid/进程名称

比如我们想注入到微信的进程中,可以先通过 ps 命令查找到微信的进程信息,然后再注入:

1
2
3
4
5
iPhone:~ root# ps -e | grep WeChat
4778 ?? 0:08.66 /var/containers/Bundle/Application/D2F238D4-3D14-402A-B3A8-826D9FCD39C6/WeChat.app/WeChat
5259 ttys001 0:00.01 grep WeChat
iPhone:~ root# cycript -p 4778
cy#

当终端出现 cy# 时表示注入成功,除了 cycript -p 4778 外还可以 cycript -p WeChat 这样子进行注入。

调试代码

当终端出现 cy# 提示符时,说明我们已经成功将 cycript 注入到目标应用进程当中了,这个时候我们可以输入 OC 代码进行调试,比如打印 keyWindow

1
2
cy# [[UIApplication sharedApplication] keyWindow]
#"<iConsoleWindow: 0x1102c6100; baseClass = UIWindow; frame = (0 0; 320 568); gestureRecognizers = <NSArray: 0x1102c8160>; layer = <UIWindowLayer: 0x1102cd330>>"

你会发现这和我们平时写的 OC 代码没什么两样,没错,cycript 就是能够执行你编写的 OC 代码。

当我们知道一个对象的内存地址时,可以通过 # 操作符来获取这个对象,如打印根控制器:

1
2
cy# [#0x1102c6100 rootViewController]
#"<MMUINavigationController: 0x10d001200>"

怎么样,是不是很兴奋很激动,蠢蠢欲试呢。

choose 命令

如果你知道一个类对象存在于当前进程中,却不知道它的内存地址,那就不能通过 # 操作符来获取到这个对象了,但是,cycript 为我们提供了一个 choose 命令,用法如下:

1
choose(ClassName)

类名不需要引号

1
2
3
4
cy# choose(MMUINavigationController)
[#"<MMUINavigationController: 0x10d001200>",#"<MMUINavigationController: 0x10e0b7800>"]
cy# choose(UIButton)
[#"<FixTitleColorButton: 0x1115734d0; baseClass = UIButton; frame = (170 18; 130 47); clipsToBounds = YES; opaque = NO; autoresize = LM; layer = <CALayer: 0x111573320>>",#"<UIButton: 0x111575aa0; frame = (234 20; 86 49); opaque = NO; autoresize = LM; layer = <CALayer: 0x1115724a0>>",#"<FixTitleColorButton: 0x1105d1700; baseClass = UIButton; frame = (20 18; 130 47); clipsToBounds = YES; opaque = NO; autoresize = RM; layer = <CALayer: 0x1105d19e0>>"]

我们只需要为 choose 提供一个类名即可,Cycript 就会为我们在当前进程内存中找出该类的实例对象供我们使用,很方便是不是?

模块封装

如果我们在调试时经常用到某些代码,那每次都重复敲写,是不是特麻烦呢,吃力不讨好还效率低下。那我们能不能将这些代码封装起来进行复用呢,答案是当然可以。

我们可以将一些常用的功能代码封装到一个 .cy 结尾的文件中(假设叫 test.cy),

1
2
3
4
5
6
7
8
9
10
11
(function(exports) {
AppId = NSBundle.mainBundle.bundleIdentifier;

RootVC = function() {
return [UIApplication sharedApplication].keyWindow.rootViewController;
};

exports.nothing = function() {};

// ...
})(exports);

然后将该脚本文件拷贝到 iOS 的 /usr/lib/cycript0.9 目录下,注入到目标进程,然后可以通过 @import 模块名 命令导入。

1
2
3
4
5
6
7
cy# @import test
{}
cy# AppId
@"com.tencent.xin"
cy# RootVC()
#"<MMUINavigationController: 0x10d001200>"
cy# test.nothing()

注意 nothing 方法的调用需要加上模块名

通过这样的方式,我们可以封装一些常用的功能,方便调试。

如果懂 JavaScript 的朋友,肯定会惊讶这不就是 JavaScript 的模块语法吗,是的,所以我说 cycript 是 Objective-C 和 JavaScript 结合物嘛。

推荐一下小码哥写的脚本 mjcript

iOS 逆向之 Theos 篇

埋坑!!!

Homebrew

Homebrew 是 mac 平台的软件包管理器,它允许我们通过 brew install xxx 的方式安装软件。

安装 Homebrew

官网 上有详细的说明,可以自行查阅。

1
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

Homebrew 默认会安装在 /usr/local/Homebrew 目录。

使用 Homebrew

这里只介绍常用的命令,更多用法或详细用法请使用 brew --help 查看。

通过 Homebrew 安装的软件包会默认安装在 /usr/local/Cellar 目录下,并会将其软连接到 /usr/local/bin/ 目录。

  • 搜索软件

    1
    brew search xxx
  • 安装软件

    1
    brew install xxx
  • 卸载软件

    1
    brew uninstall xxx
  • 更新软件

    1
    brew upgrade xxx
  • 查看软件包信息

    1
    brew info xxx
  • 列出所有已安装的软件

    1
    brew list
  • 更新 Homebrew

    1
    brew update

更换源

当我们执行 brew install xxx 或者 brew update 命令时,终端会一直卡在 Updating Homebrew 的地方,这是因为 Homebrew 自带的镜像源被墙,访问不了,此时需要更换成国内的镜像源。

下面以 清华大学开源软件镜像站 为例:

1
2
3
4
5
6
7
cd "$(brew --repo)"
git remote set-url origin https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/brew.git

cd "$(brew --repo)/Library/Taps/homebrew/homebrew-core"
git remote set-url origin https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/homebrew-core.git

brew update

可以恢复原来的镜像:

1
2
3
4
5
6
7
cd "$(brew --repo)"
git remote set-url origin https://github.com/Homebrew/brew.git

cd "$(brew --repo)/Library/Taps/homebrew/homebrew-core"
git remote set-url origin https://github.com/Homebrew/homebrew-core

brew update

iOS 砸壳教程

iOS 从 App Store 下载的应用都是加过壳的(只有从 App Store 下载的才是加壳的,系统应用和其他渠道下载的都是未加壳的),要想对应用进行逆向,首先得把这层壳去掉,俗称脱壳砸壳

工具

  • OpenSSH
  • Cycript
  • dumpdecrypted
  • 已越狱的 iOS 设备

OpenSSH

通过 Cydia 安装,我们最常用的只有两个命令,sshscp,前者用于远程登录,后者用于远程拷贝文件。

  • ssh

格式:

1
ssh user@ip

如用户名为 root,IP 为 192.168.1.2,则:ssh root@192.168.1.2

  • scp
    格式:
    1
    scp [-P port] /path/source-file user@ip:/path/target-directory

如:将 macOS 桌面的 test.txt 拷贝到 iPhone 的 /usr/local 目录下

1
scp ~/Desktop/test.txt root@192.168.1.2:/usr/local/

当然,也可以将 iPhone 中的文件拷贝出来

1
scp root@192.168.1.2:/usr/local/test.txt ~/Desktop/

usbmuxd 工具使我们能够通过 USB 线 ssh 到 iOS 中,极大地增加了 ssh 连接的速度,强烈推荐。可以在 https://cgit.sukimashita.com/usbmuxd.git/ 下载到,推荐下载 1.0.8 版本。

Cycript

是由 saurik 推出的一款脚本语言,可以说是 JavaScript + Objective-C 的结合物。功能强大,具体用法自行查阅吧。

这里推荐一下 MJ 推出的脚本 mjcript,可以更加方便调试。

dumpdecrypted

dumpdecrypted 是个开源工具,可在 GitHub 上找到。

  • dumpdecrypted 源码下载回来
  • 通过 make 命令编译生成 dumpdecrypted.dylib

后面会通过 dumpdecrypted.dylib 动态库进行砸壳。

砸壳

192.168.100.100 为我手机当前的 IP 地址

下面以 VB酒庄 这个 App 为例进行演示。

1、进行操作前,我们先通过 OpenSSH 连接到 iPhone。

1
ssh root@192.168.100.100

iPhone 默认有两个账户,rootmobile,默认密码都是 alpine。建议首次登录后,通过 passwd 命令修改这两个用户的默认密码。

2、找到应用的可执行文件路径(Mach-O)

1
ps -e | grep VBemall

执行上述命令后,可以看到 App 的 MachO 文件所在路径为: /var/containers/Bundle/Application/14E5846E-130A-4E7A-AF30-033EB6041A9D/VBemall.app/VBemall

3、获取 Documents 目录

  • 把 cycript 注入到应用进程
    1
    cycript -p VBemall

如果终端中出现 cy# 则说明注入成功

  • 执行 [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0] 获取 Documents 目录路径

control + D 可以退出 Cycript,如果执行 exit(0) 则会退出 Cycript 并关闭 App

4、将 dumpdecrypted.dylib 拷贝到 Documents 目录下

scp 源文件 root@ip:目标路径

1
scp dumpdecrypted.dylib root@192.168.100.100:/var/mobile/Containers/Data/Application/210D1E28-3BD0-43E7-9FEC-AA311AD734F4/Documents/

5、 进入 Documents 目录开始砸壳

  • 进入 Documents 目录

    1
    cd /var/mobile/Containers/Data/Application/210D1E28-3BD0-43E7-9FEC-AA311AD734F4/Documents
  • 通过 DYLD_INSERT_LIBRARIES=dumpdecrypted.dylib /path/executable 命令进行砸壳

    1
    DYLD_INSERT_LIBRARIES=dumpdecrypted.dylib /var/containers/Bundle/Application/14E5846E-130A-4E7A-AF30-033EB6041A9D/VBemall.app/VBemall

如果砸壳成功,会在当前目录(dumpdecrypted.dylib 所处目录)生成 VBemall.decrypted 文件,该文件就是砸壳后的 MachO 文件,可以通过 class-dump 导出头文件或者通过 IDA、Hopper 查看汇编代码。

如果在执行砸壳命令时提示 Killed: 9 的错误信息,需要通过 su mobile 切换到 mobile 用户后再次砸壳。具体可以参看 Issues

6、将砸壳后的文件从 iPhone 中拷贝到桌面

1
scp root@192.168.100.100:/var/mobile/Containers/Data/Application/210D1E28-3BD0-43E7-9FEC-AA311AD734F4/Documents/VBemall.decrypted ~/Desktop/