背景
在小米的面试中,最后一轮被问到了一个场景。即关于在 WebView 下开发一个用户上传头像的场景的完整流程。但是当时回答的好多细节都没有回答上来。
原因
- 没有使用过 WebView;
- 文件上传的功能做的不多;
- 文件上传的前后端实现都是用的插件,导致实现原理不清楚;
目标
- 理清使用 WebView 上传头像的业务逻辑;
- 理清技术细节;
- 实现 Demo;
分析
关于 WebView
什么是 WebView
Android WebView is a system for the Android operating system
(OS) that allows Android apps to display content from the web directly inside an application.
There are two ways to view web content on an Android device: though a traditional web browser or through an Android application that includes WebView in the layout. If a developer wants to add browser functionality to an application, she can include the WebView library and create an instance of a WebView class; this essentially embeds a browser within the app to do things like render web pages and execute JavaScript. WebView is powerful because it not only provides the app with an embedded browser, it also allows the developer's app to interact with web pages and other web apps.
简而言之,对于 Android 来说,WebView 就是一个内置浏览器组件,开发者可以调用该组件来在 App 中显示网页。
业务逻辑
- 用户打开个人资料页面,先默认显示灰色头像,即头像的
img
标签的src
属性为data:image/*;base64,**
; - 前端向后端请求个人资料,后端鉴权通过后,将个人资料返回给前端;
- 如果个人资料中头像属性为空,则对头像不做任何操作;如果头像属性不为空,则将网页中头像的
img
标签的src
属性的值修改为响应的头像属性; - 用户点击头像,弹出对话框,包含拍照和选择相册两个选项;
- 用户点击选择相册以后,弹出相册选择界面;
- 只允许用户选择图片类型的文件;
- (可选)用户选择好以后,弹出裁剪界面;
- 操作完成后,压缩图片,并通过 form data 上传到后端服务器,前端显示操作中提示;
- 后端接收到表单以后,计算图片的 hash 值,在数据库中查询是否已经有存在相同头像,如果已经有相同头像,则在数据库中复制原有图片地址;如果没有,则把文件保存在被 nginx 反向代理的本地文件夹后,再将地址和 hash 值保存在数据库;
- 后端保存成功后,给前端返回一个状态值为 200 的响应,并将头像的完整地址作为响应的属性;
- 前端接收到响应以后,将网页中头像的
img
标签的src
属性的值修改为响应的头像属性;
潜在问题
- WebView 开发。没有接触过;
- 文件命名规则。为了避免文件重名和恶意代码注入,需要使用一种生成唯一值的规则,作为文件的命名规范,还不能太长。之前使用过时间戳作为规则;
- 服务器头像文件夹结构。为了避免降低文件查找的性能,需要建立不同层级的文件夹,规则可以按照年月日建立不同的文件夹保存文件;
开发步骤
既然 WebView 也是浏览器,那么应该按照最小化问题的原则,一步一步实现最终的目标。因此,决定按照以下步骤实现功能。
- 搭建后端服务器,然后使用 Postman 之类的工具测试;
- 电脑浏览器环境下,实现前端功能目标;
- 环境切换为真正的 WebView 环境,实现功能目标;
开发
后端搭建
关键点
- [x] 后端支持提供静态文件;
- [x] 接收 form data 类型表单,并保存成文件;
- [x] 按照规则重命名;
- [ ] 计算 hash 编码;
效果图
代码
const path = require('path')
const express = require('express')
const formidable = require('formidable')
const cors = require('cors')
const app = express()
app.use(cors())
app.use('/static', express.static(path.join(__dirname, 'public')))
app.post('/api/v1/avatar', (req, res) => {
if (req.url === '/api/v1/avatar' && req.method.toLowerCase() === 'post') {
const form = new formidable.IncomingForm()
form.uploadDir = './public/avatars'
form.keepExtensions = true
form.parse(req, (err, fields, files) => {
if (err) {
console.error(err)
return res.status(500).json({
message: '服务器发生错误!'
})
}
const filename = path.basename(files['avatar']['path'])
return res.json({
success: true,
path: '/static/avatars/' + filename
})
})
}
})
app.listen(3000, () => console.log('Avatar back end service starts!'))
电脑浏览器环境
关键点
- [x] 文件上传 HTML5 API;
- [x] 验证文件类型和大小;
- [x] 隐藏掉原生
<input type="file">
元素,点击头像即可上传文件;
效果图
代码
import { Component } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { Uploader } from './uploader.service';
@Component({
selector: 'app-root',
template: `
<div style="text-align:center">
<div style="margin:50px 0">
<label for="avatar"><img width="300" alt="avatar" class="img-thumbnail" [src]="avatarSrc"></label>
<input type="file" id="avatar" name="avatar" accept="image/*"
(change)="onChangeAvatar($event)" placeholder="更换头像" style="visibility:hidden">
</div>
</div>
`
})
export class AppComponent {
private rootEndPoint: string = 'http://localhost:3000'
public avatarSrc = this.sanitizer.bypassSecurityTrustUrl('data:image/svg+xml;base64,\
PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgM\
jUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMT\
IzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzM\
wMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiI\
C8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wx\
MS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg==');
constructor (
private sanitizer: DomSanitizer,
private uploader: Uploader
) {}
private getFormValue (file: any): FormData {
const formData = new FormData();
formData.append('avatar', file);
return formData;
}
public onChangeAvatar (event: any) {
if (event.target.files.length === 0) {
return;
}
const file = event.target.files[0];
// 检查文件格式和大小是否满足要求
if (file.size > 1024 * 1024) {
window.alert('文件格式不规范!')
return;
}
this.uploader
.upload(this.getFormValue(file))
.subscribe(
path => this.avatarSrc = this.sanitizer.bypassSecurityTrustUrl(this.rootEndPoint + path),
errorMessage => console.log(errorMessage)
);
}
}
IOS WebView 环境
开发过程
- 使用 Xcode 建立一个简单工程;
- 将 WKWebView 控件拖入;
- 使用代码加载 url;
- 修改配置,允许 http 协议传输;
然后运行以后,一切正常,点击头像也能够调用相册。但是,噩梦就来了,无法正常选择图片并上传,找到了很多解决办法,但是因为对 swift 语言不熟悉,暂时没有办法测试。