# 第10次课 讲稿

> 课程：小程序开发
> 主题：文件操作与Canvas绘图
> 教材：《微信小程序开发实战（第2版）》第4章
> 时长：3学时（理论 1 + 实践 2）
> 重点：文件上传下载、Canvas绑定、绘图API
> 难点：Canvas坐标系、绘图状态管理

---

## 一、开场（0:00）

今天两个主题：文件操作和 Canvas 绘图。看起来不相干，其实有一条暗线——**它们都是"小程序里少数需要你自己管理资源"的地方**。

先说文件。小程序里的文件路径分两种：临时文件和本地缓存文件。选完图片拿到的是临时路径，**微信随时可能把它清掉**。想长期保留必须主动保存。这个区别不搞清楚，就会出现"昨天还能看，今天图没了"的诡异 bug。

再说 Canvas。Canvas 和我们前面学的一切都不一样：前面是"改数据，框架帮你画"，Canvas 是"你自己一笔一笔画"。它是命令式的，画错了不会有人提醒你，只会得到一张空白或者奇怪的图。

---

## 二、明确本课目标（约 5:00）

把目标写在白板上或直接翻到课件第 2 页，逐条念一遍：

- 掌握文件上传与下载API
- 理解Canvas组件的使用方式
- 掌握Canvas 2D绑定的基本绘图
- 能够使用Canvas绘制图形与文字
- 案例：案例4-3 头像上传下载、案例4-4 模拟时钟

本次课 3 学时的安排是：**理论讲解 1 学时，上机实践 2 学时**——文件与 Canvas 讲解 1 学时，案例 4-3/4-4 实操 2 学时。

---

## 三、知识点讲解

### 幻灯片 3：文件操作API

**【8:00 讲解】**

**wx.chooseMessageFile**

从聊天选择文件

**wx.uploadFile**

上传文件到服务器

**wx.downloadFile**

下载文件资源

**wx.openDocument**

打开文档(pdf/doc/xls等)

- wx.getFileInfo / wx.saveFile 文件管理

### 幻灯片 4：Canvas组件使用

**【13:00 讲解】**

- <canvas type="2d" id="myCanvas"></canvas>
- wx.createSelectorQuery().select("#myCanvas").fields()
- 获取Canvas上下文: const ctx = canvas.getContext("2d")
- Canvas坐标系: 左上角为原点(0,0)
- 注意: Canvas需要在onReady后获取节点

### 幻灯片 5：Canvas绘图API

**【18:00 讲解】**

**fillRect / strokeRect**

绘制矩形

**arc**

绘制圆弧/圆形

**fillText / strokeText**

绘制文字

**drawImage**

绘制图片

- fillStyle / strokeStyle / lineWidth 样式设置
- beginPath / closePath / moveTo / lineTo 路径操作

### 幻灯片 6：案例4-3 头像上传下载 / 案例4-4 模拟时钟

**【23:00 讲解】**

**案例4-3 头像上传下载**

chooseMedia 选图 → uploadFile 上传 → downloadFile 下载回显

**案例4-4 模拟时钟**

Canvas 2D 绘制表盘与三根指针，setInterval 每秒重绘

- 绘制要点：translate 把原点移到圆心，rotate 按角度旋转，每帧先 clearRect 清屏
- 角度换算：秒针 6°/秒，分针 6°/分，时针 30°/时 再加 0.5°/分
- 综合运用文件 API 与 Canvas 完成两个教材案例

---

## 四、难点突破：用类比讲清楚

下面这几个点是学生最容易卡住的地方，抽象讲不通就换成类比。

### 临时文件 vs 本地文件

临时文件像餐厅的托盘，你端着吃完饭就得还回去；本地文件像自己的饭盒，能带走。chooseMedia 给你的是托盘，要留下得用 saveFile 倒进饭盒。

### Canvas 坐标系

和数学课上的坐标系不一样：原点在**左上角**，x 向右增大，y 向**下**增大。这是所有屏幕绘图的惯例，第一次画图时把这个记牢，能少调半小时。

### translate 与 rotate

画时钟指针时，与其算三角函数算指针端点在哪，不如换个思路：**把画布本身转过去**。translate 把原点搬到表盘圆心，rotate 把整张纸转到指针角度，然后直接画一条竖线——这就是指针。画完 restore 把纸转回来。

### save / restore

像整理房间前先拍张照。save 记下当前的画布状态（位置、旋转、颜色），一通操作之后 restore 把状态还原，不影响下一次绘制。忘了这一步，第二根指针就会画歪。

---

## 五、代码演示环节

以下每一段都建议**现场敲**，不要直接贴完整代码。边敲边问"下一行该写什么"，让学生跟上节奏。

### 演示 1：文件操作API

**文件：** `file-api.js`　**预期效果：** 文件操作流程

```js
// 选择聊天文件
wx.chooseMessageFile({
count: 1,
type: 'file',
success(res) {
const file = res.tempFiles[0]
console.log(file.path, file.size, file.name)
}
})

// 上传文件到服务器
wx.uploadFile({
url: 'https://api.example.com/upload',
filePath: tempFilePath,
name: 'file',
formData: {
userId: '12345'
},
success(res) {
const data = JSON.parse(res.data)
console.log('上传成功', data.url)
}
})

// 下载文件
wx.downloadFile({
url: 'https://example.com/file.pdf',
success(res) {
const filePath = res.tempFilePath
// 打开文档预览
wx.openDocument({
filePath: filePath,
fileType: 'pdf'
})
}
})

// ⚠️ 注意事项
// 1. 上传下载域名需在后台配置
// 2. 临时文件路径有效期到小程序退出
// 3. 上传返回的 res.data 是字符串需 JSON.parse
```

**讲解口径：**

文件操作三大 API：**wx.chooseMessageFile** 从聊天记录选择文件，返回临时文件路径、大小和名称；**wx.uploadFile** 将本地文件上传到服务器，url 为上传地址，name 为文件对应的 key，formData 可附加其他参数，注意返回的 res.data 是字符串需要 **JSON.parse**；**wx.downloadFile** 下载文件到临时路径，配合 **wx.openDocument** 预览文档。上传下载的域名都需在后台配置。

### 演示 2：Canvas组件使用

**文件：** `canvas-demo.wxml`　**预期效果：** Canvas 坐标系

```html
<!-- Canvas 组件（新版 type="2d"） -->
<canvas type="2d" id="myCanvas"
style="width: 300px; height: 300px;">
</canvas>

// 获取 Canvas 上下文（新版写法）
Page({
async onReady() {
// 通过 id 获取 canvas 节点
const query = wx.createSelectorQuery()
query.select('#myCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node
const ctx = canvas.getContext('2d')

// 设置画布分辨率（适配高清屏）
const dpr = wx.getSystemInfoSync().pixelRatio
canvas.width = res[0].width * dpr
canvas.height = res[0].height * dpr
ctx.scale(dpr, dpr)

// 开始绘图...
this.ctx = ctx
this.canvas = canvas
})
}
})

// 📐 Canvas 坐标系
// 原点(0,0)在左上角
// x轴向右增大，y轴向下增大
// 单位为逻辑像素（px）
// 需要乘以 dpr 适配高清屏
```

**讲解口径：**

新版 Canvas 使用 **type="2d"** 和 **id** 属性。在 **onReady** 生命周期中通过 **createSelectorQuery** 获取 canvas 节点，再调用 **getContext('2d')** 获取绘图上下文。重要步骤：获取设备像素比 **dpr**，将画布实际尺寸乘以 dpr 并 scale，否则在高清屏上会模糊。Canvas 坐标系原点在左上角，x 轴向右，y 轴向下。

### 演示 3：Canvas绘图API

**文件：** `canvas-draw.js`　**预期效果：** Canvas 绘图效果

```js
// 获取上下文后开始绘图
const ctx = this.ctx

// 绘制矩形
ctx.fillStyle = '#07c160'
ctx.fillRect(10, 10, 100, 50)

// 绘制圆形
ctx.beginPath()
ctx.arc(150, 80, 30, 0, 2 * Math.PI)
ctx.fillStyle = '#ff6b6b'
ctx.fill()

// 绘制文字
ctx.font = '20px sans-serif'
ctx.fillStyle = '#333'
ctx.fillText('Hello 小程序', 10, 130)

// 绘制图片
const img = canvas.createImage()
img.src = 'https://example.com/logo.png'
img.onload = () => {
ctx.drawImage(img, 10, 150, 80, 80)
}

// 绘制线条/路径
ctx.beginPath()
ctx.moveTo(120, 150)
ctx.lineTo(200, 200)
ctx.strokeStyle = '#2196f3'
ctx.lineWidth = 2
ctx.stroke()

// 保存为图片
wx.canvasToTempFilePath({
canvas: canvas,
success(res) {
const tempFilePath = res.tempFilePath
// 可保存到相册或上传
}
})
```

**讲解口径：**

Canvas 绘图核心 API：**fillRect** 绘制填充矩形，参数为 x、y、宽、高；**arc** 绘制圆弧，参数为圆心x、y、半径、起始角、结束角，画完整圆用 **2 * Math.PI**；**fillText** 绘制文字，需先设置 font 和 fillStyle；**drawImage** 绘制图片，需先通过 **createImage** 加载；**beginPath + moveTo + lineTo + stroke** 绘制线条。绘制完成后用 **canvasToTempFilePath** 导出为图片。

### 演示 4：案例4-3头像上传+案例4-4模拟时钟

**文件：** `upload-avatar.js`　**预期效果：** 头像上传 & 模拟时钟

```js
// 案例4-3：头像上传 upload-avatar.js
Page({
data: { avatarUrl: '' },

chooseAvatar() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sizeType: ['compressed'],
success: (res) => {
const tempPath = res.tempFiles[0].tempFilePath
this.setData({ avatarUrl: tempPath })
this.uploadAvatar(tempPath)
}
})
},

uploadAvatar(filePath) {
wx.uploadFile({
url: 'https://api.example.com/upload/avatar',
filePath: filePath,
name: 'avatar',
success(res) {
const data = JSON.parse(res.data)
wx.showToast({ title: '上传成功' })
}
})
}
})

// 案例4-4：模拟时钟 clock.js
Page({
async onReady() {
const query = wx.createSelectorQuery()
query.select('#clockCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const dpr = wx.getSystemInfoSync().pixelRatio
canvas.width = res[0].width * dpr
canvas.height = res[0].height * dpr
ctx.scale(dpr, dpr)

// 定时绘制时钟
this.timer = setInterval(() => {
this.drawClock(ctx, 150)
}, 1000)
})
},

drawClock(ctx, r) {
ctx.clearRect(0, 0, 300, 300)
const now = new Date()
const h = now.getHours() % 12
const m = now.getMinutes()
const s = now.getSeconds()
// 绘制表盘、刻度、指针...
this.drawDial(ctx, r)  // 表盘
this.drawHand(ctx, r, h*30+m*0.5, r*0.5, 6)  // 时针
this.drawHand(ctx, r, m*6, r*0.7, 4)  // 分针
this.drawHand(ctx, r, s*6, r*0.85, 2)  // 秒针
},

onUnload() { clearInterval(this.timer) }
})
```

**讲解口径：**

案例4-3头像上传：用 **chooseMedia** 选择图片，获取临时路径后 **setData** 更新显示，再调用 **uploadFile** 上传到服务器。案例4-4模拟时钟：在 **onReady** 中获取 Canvas 上下文，用 **setInterval** 每秒重绘：先 **clearRect** 清空画布，再根据当前时间计算时针、分针、秒针角度，用 **beginPath + moveTo + lineTo + stroke** 绘制指针。页面卸载时 **clearInterval** 清除定时器。

---

## 六、常见错误与排查

上机环节学生一定会遇到下面这些问题，提前讲一遍能省掉大量重复答疑。

| # | 现象 | 原因与解决 |
|:-:|------|------------|
| 1 | Canvas 拿不到节点 | type="2d" 的 canvas 必须在 onReady 之后用 SelectorQuery 获取，写在 onLoad 里必然拿到 null。 |
| 2 | 画出来的图很模糊 | 需要按 dpr 缩放：canvas.width = rect.width * dpr，再 ctx.scale(dpr, dpr)。这一步几乎每个人第一次都会漏。 |
| 3 | 时钟指针越画越多 | 每帧开头忘了 clearRect，旧的指针没擦掉，画一分钟就成了一朵花。 |
| 4 | uploadFile 后端收不到文件 | name 字段要和后端约定的表单字段名一致，filePath 必须是本地路径而非网络地址。 |
| 5 | setInterval 没清掉 | 时钟案例里 onUnload 必须 clearInterval，否则离开页面定时器还在跑，返回时会叠加成两个。 |

---

## 七、上机实践指引

本次课对应教材案例 2 个，源码已放在站点 `code/session10/` 下：

- **【案例4-3】头像上传下载** — 含 知识储备、案例实现、服务器端 共 3 个工程
- **【案例4-4】模拟时钟** — 含 知识储备、案例实现 共 2 个工程

建议顺序：先带学生跑一遍「知识储备」里的最小示例，确认每个知识点都能单独跑通，再进「案例实现」做完整案例。直接上完整案例，出错了不知道错在哪。

---

## 八、本课小结

回到课件小结页，逐条回顾：

- 文件操作API
- Canvas组件使用
- Canvas绘图API
- 案例4-3头像上传+案例4-4模拟时钟

今天的内容偏"手艺活"，理论不多但坑不少。案例4-3 练文件的完整链路（选—传—下—显），案例4-4 练 Canvas 的坐标变换。

模拟时钟这个案例我特别推荐认真做，因为它逼着你想清楚坐标变换。做完之后再看那些海报生成、图片水印的需求，你会发现思路都是一样的。

---

## 九、布置作业

三档任务，基础必做，进阶建议做，挑战选做。

### 【基础】完成头像上传下载

按照案例4-3，实现头像选择、上传、下载的完整流程

### 【进阶】完成模拟时钟

按照案例4-4，使用Canvas 2D绘制模拟时钟，实现实时走针效果

### 【挑战】Canvas画板

实现一个Canvas画板，支持手指绘制、颜色选择、线宽调节、清除画布、保存图片

---

> 配套资源：课件 `slides-v2/session10.pptx`　课堂视频 `videos-output/session10.mp4`　扩展阅读 `docs/第10次课_扩展阅读.md`
