语音导游
先了解你将做出什么
应用介绍
本教程从一张景点地图开始,逐步做出一个可以查看地点介绍、朗读文字、拍照保存景点图片的语音导游。最后再加入 CloudDB,让多个人可以共同向地图添加地点。
教程分为“地图首页、地点详情页、照片画廊挑战、地图众包第一部分、地图众包第二部分”五个主章节,并保留细分步骤,方便按“下一步”逐页完成。
- 起始工程:语音导游.aia
- 完整工程:语音导游完整版.aia

地图首页
1. 了解本章目标
你将和伙伴一起制作一张本地景点地图。建议两人结对编程,每五分钟交换一次驾驶员和领航员角色;用 AI2 Companion 连接手机,边做边测试。

2. 添加地图组件
在设计器中,在说明标签下方加入一个 Map1 地图组件,再把四个 Marker 标记拖到地图上。每个标记代表一个景点。

3. 让地图填满屏幕
选中 Map1,将高度和宽度都设置为“充满父容器”(Fill Parent),让地图占满可用空间。

4. 设置地图中心
在 CenterFromString 属性中输入目标城市的纬度和经度,中间用逗号分隔。地图中心会移动到这个位置。

5. 重命名四个标记
按照景点工作表,把四个标记重命名为景点名称。组件名不能包含空格;需要传给详情页的景点名称,可以在程序块中使用带空格的文字。

6. 设置标记基本属性
为每个标记设置 Title、Description、Latitude 和 Longitude。标题是短名称,描述用于地点详情页和语音播报。

7. 调整地图缩放
拖动设计器中的地图,检查四个标记是否都能看到;再调整 Map1.缩放等级。如果看不到标记,先把缩放级别降到 10,再逐步增加 1~2 级。

8. 测试标记短按
用 AI2 Companion 点击每个标记,确认短按会显示该标记的标题和简短描述。

9. 打开积木编辑器
切换到积木编辑器,选中一个标记,从它的抽屉中拖出 当 Marker.被长按时 事件。长按事件用于打开完整的地点详情页。

10. 查看地点详情页
工程已经准备了第二个屏幕 LocationScreen。打开它,再切换到设计器,先观察这个屏幕已有的界面布局。

11. 认识地点屏幕布局
和伙伴一起辨认地点屏幕中的标签、图片、朗读器和返回按钮,想一想每个组件将显示哪一类数据。

12. 准备打开详情页
回到 Screen1,切换到积木编辑器,继续完善刚才的 Marker.被长按时 事件。
13. 使用“打开另一个屏幕”
在长按事件中加入 打开另一屏幕并传值,屏幕名填 LocationScreen,这样长按标记时就会打开地点详情页。

14. 传入景点名称
把当前标记的景点名称作为 startValue。传给详情页的文字可以包含空格,因为它会显示给用户;组件名本身仍不能包含空格。

15. 为其他标记重复代码
为另外三个标记分别加入 LongClick 事件,打开同一个 LocationScreen,并传入各自的景点名称。
16. 测试地图首页
在 AI2 Companion 中确认:短按标记会显示简介,长按标记会打开地点详情页;四个标记都应能进入正确的地点。

地点详情页
1. 回到 LocationScreen
打开 LocationScreen,准备编写屏幕初始化代码。

2. 上传景点图片
在媒体资源面板点击“上传文件”,上传景点图片。后面 Pictures 列表中的文字必须与这里的文件名完全一致。

3. 认识变量
变量是给数据起名字的容器。初始化变量后,可以读取它的值,也可以在程序运行时修改它。

4. 创建三个列表变量
建立 Locations、Descriptions 和 Pictures 三个全局变量,分别保存景点名称、详细描述和图片文件名。

5. 创建三个等长列表
从“列表”抽屉拖出三个 make a list,每个列表放入四个元素,再分别赋给三个全局变量。三个列表必须保持相同顺序。

6. 填写 Locations 列表
向 Locations 列表加入四个景点名称。它们必须与 Screen1 长按事件中传入的 startValue 完全一致。

7. 填写 Descriptions 列表
向 Descriptions 列表加入四段较完整的景点介绍,并保持它们与 Locations 列表一一对应。

8. 填写 Pictures 列表
向 Pictures 列表加入上传图片的准确文件名,顺序要与前两个列表相同。

9. 创建 locationIndex
建立全局变量 locationIndex,初始值设为 0。它表示当前正在显示第几个地点。

10. 在屏幕初始化时设置索引
在 LocationScreen.初始化 事件中,根据从 Screen1 传入的 startValue 设置 locationIndex。

11. 找到地点在列表中的位置
使用“列表”抽屉里的 index in list,在 Locations 列表中查找传入的景点名称。列表下标从 1 开始。

12. 保存找到的索引
将 index in list 的结果赋给 locationIndex。这样后面的标签和图片就能从同一个位置取数据。

13. 设置地点名称标签
使用 选择列表项 从 Locations 中取出 locationIndex 对应的项目,赋给 LocationLabel.文本。

14. 复制地点标签程序块
复制刚才的 设 LocationLabel.文本 程序块,准备复用相同的索引逻辑。

15. 设置详细描述标签
把复制出的组件改为 DescriptionLabel,把数据列表改为 Descriptions,并将它放到 LocationScreen.初始化 中。

16. 设置景点图片
加入 设 Image1.图片,使用 选择列表项 从 Pictures 列表取出当前地点的文件名,放在描述标签设置之后。

17. 加入文本朗读器
把 TextToSpeech(文本朗读器)组件加入地点屏幕。在初始化事件中,将当前描述标签的文字传给 TextToSpeech.念读。

18. 编写返回按钮
完成 BackButton.被点击时 事件,调用 关闭屏幕 返回地图首页。

19. 测试地点详情页
长按每个地图标记,确认详情页显示正确的名称、描述和图片,并且朗读正确的描述。点击“返回地图”后应能回到首页。

照片画廊挑战
1. 查看 GalleryScreen
GalleryScreen 允许用户拍摄景点照片并把照片加入画廊。先打开这个屏幕,再切换到设计器。

2. 认识画廊组件
和伙伴一起辨认画廊中的图片、相机、上一张、下一张和返回按钮,先猜测它们各自的用途。

3. 在地点页加入入口
回到 LocationScreen,准备让用户从地点详情页打开照片画廊。
4. 显示 GalleryButton
在组件列表中选中 GalleryButton,勾选 Visible,让按钮出现在地点详情页。

5. 打开 GalleryScreen
在积木编辑器中加入 当 GalleryButton.被点击时,调用 打开另一屏幕 打开 GalleryScreen。

6. 编写相机按钮事件
回到 GalleryScreen,加入 当 CameraButton.被点击时 事件。

7. 拍摄照片
在相机按钮事件中调用 Camera.拍照。

8. 接收拍摄结果
加入 当 Camera.拍摄完成时 事件。照片拍摄完成后,image 参数就是新图片的文件名或路径。

9. 显示新照片
将 image 参数连接到 设 Image1.图片,让画廊中的图片立即显示刚拍摄的照片。

10. 测试拍照功能
点击相机按钮拍一张照片,确认画廊中的图片会改变。

11. 认识 TinyDB
TinyDB(微数据库)可以保存持久数据:关闭应用后再次打开,数据仍然存在。它主要使用 StoreValue 保存数据、GetValue 读取数据。

12. 理解 StoreValue
StoreValue 有两个关键参数:tag 像变量名,valueToStore 是要保存的值。相同 tag 再次保存时会替换原来的值。

13. 理解 GetValue
GetValue 根据 tag 读取以前保存的数据。如果 tag 不存在,就返回 valueIfTagNotThere 提供的默认值。

14. 创建 photoList
建立 photoList 保存照片文件名,再建立 currentIndex 保存当前显示照片的下标。

15. 把照片加入列表
在 Camera.拍摄完成时 中把新照片加入 photoList,不要只更新图片组件,否则下一次拍照会丢失前一张照片。

16. 更新 currentIndex
拍完照片后,把 currentIndex 设置为 length(photoList),让刚加入的照片成为当前显示照片。

17. 把列表保存到 TinyDB
调用 TinyDB1.存储值,用固定 tag 保存整个 photoList。把它放在更新 currentIndex 之后。

18. 初始化画廊屏幕
在 GalleryScreen.初始化 中把 currentIndex 设为 0,然后从 TinyDB 读取以前保存的 photoList。

19. 读取 photoList
使用 TinyDB1.获取值 读取照片列表;如果没有保存过,默认返回空列表。

20. 检查列表是否为空
使用 if-then 判断 photoList 是否为空。如果列表不为空,就把 currentIndex 指向第一张照片,索引为 1。

21. 显示当前照片
使用 选择列表项 取出 photoList 中 currentIndex 对应的文件名,赋给 Image1.图片。

22. 设计上一张和下一张
上一张按钮让 currentIndex 减 1,下一张按钮让它加 1;每次改变后都要重新设置 Image1.图片。
23. 编写上一张按钮
在 PreviousButton.被点击时 中先判断 currentIndex > 1,只有不在第一张时才允许继续向前切换。

24. 减少当前索引
把 currentIndex 减 1,再从 photoList 取出新的照片。

25. 显示上一张照片
将新的 currentIndex 传给 选择列表项,把取出的照片设置给 Image1.图片。

26. 编写下一张按钮
在 NextButton.被点击时 中判断当前索引是否小于列表长度;如果还有下一张,就把 currentIndex 加 1 并更新图片。

27. 编写画廊返回按钮
BackButton.被点击时 与地点详情页相同,调用 关闭屏幕 返回 LocationScreen。

28. 测试照片画廊
打开 GalleryScreen,拍摄几张照片,用“上一张”和“下一张”浏览它们;关闭应用后重新打开,确认照片仍然保留。
地图众包:第一部分
1. 另存为众包工程
在 App Inventor 的“文件”菜单选择“另存为”,将项目另存为 MaldenTourGuide_Crowdsource,不要直接破坏前面完成的个人导游工程。

2. 与伙伴共享工程
把众包工程分享给伙伴。两个人后面会使用同一个 CloudDB Token 读取和写入共同的标记列表。
3. 添加众包组件
在地图上方加入 AddMarkerButton 按钮,再加入 Notifier1 和 CloudDB1。按钮用于开始添加地点,Notifier 用于询问用户选择,CloudDB 用于共享数据。

4. 准备 CloudDB Token
CloudDB Token 必须和伙伴完全一致。实际使用时请使用你们自己的共享 Token,不要把个人密钥公开到教程或代码仓库。
5. 填写 CloudDB Token
选中 CloudDB1,把双方约定的 Token 粘贴到 Token 属性中。

6. 开始添加标记
在 当 AddMarkerButton.被点击时 中加入 Notifier1.显示选择对话框,让用户选择“使用我的位置”或“在地图上指定位置”。

7. 设置选择对话框文字
对话框参数建议设为:消息“要添加当前位置,还是在地图上选择位置?”,标题“添加标记”,按钮分别为“我的位置”和“在地图上指定”。

8. 接收用户选择
加入 Notifier1.选择完成时(choice) 事件。先放入 if-then,判断用户点击的是哪一个按钮。

9. 检查 GPS 是否有效
用户位置有时会返回纬度、经度都是 0。在“我的位置”分支中再加入一个 if-then,专门检查这个错误情况。

10. 组合 GPS 判断条件
从 Logic 抽屉加入 and 和两个 =,分别比较 Map1.用户纬度 = 0、Map1.用户经度 = 0。

11. 读取当前位置
使用 Map1.用户纬度 和 Map1.用户经度 获取设备当前位置,并将两个值放入列表,作为详情页的 startValue。
12. 提示 GPS 错误
当位置无效时,用 Notifier 显示“当前位置没有获取成功,请重试;也可以选择在地图上指定位置”,按钮文字为“确定”。

13. 打开 AddMarkerScreen
在 GPS 有效的「否则」分支中,调用 打开另一屏幕并传值,屏幕名填 AddMarkerScreen,并传入纬度、经度列表。

14. 处理“在地图上指定”
在 AfterChoosing 中加入第二个 if-then,判断用户选择了“在地图上指定”。这个分支要等待用户点击地图。
15. 创建 addingMarker 状态
建立全局变量 addingMarker = false,选择“在地图上指定”时将它设为 true,用来告诉地图点击事件当前处于添加模式。

16. 编写地图点击事件
加入 当 Map1.位置点被点击。只有 addingMarker = true 时,才在点击位置创建新标记。

17. 保存点击坐标
把点击得到的纬度和经度放入列表,再把这个列表作为 startValue 传给 AddMarkerScreen。

18. 关闭添加模式
打开添加屏幕后把 addingMarker 恢复为 false,避免用户下一次普通点击地图时重复打开添加页面。
19. 设计 AddMarkerScreen
在 AddMarkerScreen 中准备纬度标签、经度标签、标题输入框、描述输入框、提交按钮、返回按钮和 Notifier。

20. 创建共享数据变量
建立 markerInfo 保存一个新标记的资料,再建立 MarkerTag 保存 CloudDB 中标记列表所使用的 tag。

21. 初始化添加标记屏幕
加入 AddMarkerScreen.初始化,读取从 Screen1 传来的纬度和经度列表。

22. 显示纬度和经度
使用 选择列表项,从 startValue 的第 1 项取纬度、第 2 项取经度,分别设置两个标签。

23. 获取现有标记列表
在添加屏幕初始化时调用 CloudDB1.获取值,读取 MarkerTag 对应的共享标记列表;如果没有列表,就使用空列表。

24. 开始检查提交内容
在 SubmitButton.被点击时 中加入「如果…否则」判断。先验证用户输入,再决定显示错误还是写入 CloudDB。

25. 判断输入是否有效
无效情况包括:纬度和经度都是 0、标题为空或描述为空。用 or 组合这些条件,放进 if 插槽。

26. 提示无效输入
如果输入无效,用 Notifier 提示用户补充标题和描述,或重新获取位置。

27. 组装新标记资料
在有效的 else 分支中,把标题和描述输入框的文字追加到 markerInfo;纬度和经度已经在前面保存。

28. 写入 CloudDB
将 markerInfo 追加到共享标记列表,再用 CloudDB1.存储值 保存更新后的完整列表。

29. 关闭添加屏幕
提交成功后调用 关闭屏幕 返回 Screen1;BackButton.被点击时 也调用同一个关闭操作。

地图众包:第二部分
1. 打开伙伴共享的工程
等待伙伴分享 MaldenTourGuide_Crowdsource,从 App Inventor 项目列表打开它。

2. 再次核对 CloudDB Token
确认双方 CloudDB1.访问令牌 完全一致。即使项目已经共享,CloudDB 仍然需要相同的 Token 才能访问同一份数据。
3. 初始化 MarkerList
在 Screen1 的积木编辑器中建立全局变量 MarkerList,用于保存 CloudDB 返回的全部标记。

4. 初始化 MarkerTag
建立 MarkerTag,并让它与 AddMarkerScreen 中使用的 tag 完全一致。

5. 初始化屏幕状态
在 Screen1.初始化 中初始化 addingMarker 和 MarkerList,然后调用 CloudDB1.获取值 获取最新的标记列表。

6. 接收 CloudDB 返回值
加入 CloudDB1.已获得值时(tag, value),先判断返回的 tag 是否等于 MarkerTag,避免把其他云数据误当成标记列表。

7. 监听云端数据变化
加入 CloudDB1.数据改变时(tag, value)。当其他用户添加标记时,这个事件会触发,地图也需要更新。

8. 创建 UpdateMarkers 过程
GotValue 和 DataChanged 要做同一件事,因此新建带一个输入参数 value 的过程 UpdateMarkers,把重复逻辑集中到这里。

9. 创建局部变量 markersData
在 UpdateMarkers 中初始化局部变量 markersData,它保存传入的标记列表。局部变量只在这个过程里使用。

10. 读取过程参数
把过程参数 value 赋给 markersData,后面的循环都使用这个局部列表。
11. 遍历全部标记
加入 for each item in list,把变量 item 改名为 marker,列表设置为 markersData。

12. 添加 newMarker 局部变量
在循环内部建立局部变量 newMarker,用它接收 Map1.创建标记 返回的新标记对象。

13. 创建地图标记对象
从 Map1 抽屉拖出 Map1.创建标记,为每一条云端数据创建一个地图标记。

14. 设置新标记的经纬度
每个 marker 是一个列表,纬度在第 1 项、经度在第 2 项。用 选择列表项 取出这两个值,作为 CreateMarker 的坐标参数。

15. 设置标记标题
使用 Any Component 的属性设置块,把新标记的 Title 设置为 marker 列表中的标题项。

16. 设置标记描述
用同样的方法设置 Description。注意:描述是 marker 列表的第 4 项,不要误取标题所在的位置。

17. 打开标记信息框
把新标记的 Infobox 属性设为 true,用户点击地图标记时才能看到标题和描述。

18. 保存新标记对象
把创建完成的 newMarker 追加到全局 MarkerList,让程序知道当前地图已经有哪些标记。

19. 创建 MarkerExists? 过程
如果应用已经打开过,云端列表中的旧标记可能被重复加入地图。新建带返回值的过程 MarkerExists?(marker),返回 true 或 false。

20. 初始化 found
在 MarkerExists? 中建立返回值局部变量 found,初始值设为 false。

21. 添加返回值 do 块
使用带返回值的 do 块,最后返回 found。普通的局部变量块不能直接返回结果,要选带返回值的版本。

22. 遍历已有标记
在 MarkerExists? 中加入 for each item in list 和 if-then,遍历全局 MarkerList。

23. 比较纬度和经度
用 and 连接两个 = 判断:输入标记的纬度必须和列表项目的纬度相同,输入标记的经度也必须相同。

24. 读取输入标记纬度
使用 Any Component 的 Latitude 属性读取输入标记的纬度,再与列表项目第 1 项比较。

25. 比较列表中的纬度
列表中每个标记的纬度位于第 1 项,将它与输入标记纬度比较。

26. 比较经度
输入标记的经度要和列表项目第 2 项比较,把这个判断放入 and 的另一侧。
27. 找到重复标记
当纬度和经度都相同时,把 found 设为 true,循环结束后返回它。
28. 只创建不存在的标记
回到 UpdateMarkers,用 not MarkerExists?(marker) 作为条件。只有标记不存在时,才执行创建新标记的代码。

29. 把判断插入更新过程
将“只创建不存在的标记”的 if 放入 UpdateMarkers 的循环中,确保每条云端数据都会经过检查。

30. 在两个事件中调用过程
在 CloudDB1.已获得值时 和 CloudDB1.数据改变时 中都调用 UpdateMarkers(value),这样初次打开和实时变化都能更新地图。

31. 测试多人添加地点
在地图上点击“添加标记”,可以使用当前位置,也可以在地图上指定位置;填写标题和描述后提交。关闭并重新打开应用,之前的标记仍应存在,伙伴设备也应看到相同标记。

排错与扩展
1. 地图没有显示
检查 Map1.中心坐标字串、ZoomLevel 和网络权限;先用固定坐标确认地图组件本身能显示,再处理用户位置。
2. 朗读器没有声音
确认手机音量和系统文本朗读服务已开启。即使朗读失败,也要保留描述标签,让用户能阅读文字。
3. 图片不显示
检查媒体资源中的实际文件名,以及 Pictures 列表中的文字是否逐字符一致;文件名大小写也要保持一致。
4. CloudDB 出现重复标记
确认 MarkerExists? 同时比较纬度和经度,并且 UpdateMarkers 在创建前调用了 not MarkerExists?。
5. 保护共享数据
不要把个人 CloudDB Token 写入公开教程或提交到公共仓库。正式项目应使用自己的 Token,并只与需要协作的人共享。
卡在哪一步?直接查操作
不用退出当前教程。速查会在新页面打开,查完回来继续刚才的进度。