14.序列化和文件的输入/输出 保存对象

14.1 保存对象状态

你已经奏出完美的乐章,现在会想把它储存起来。你可以抓个文房四宝把它记下来,但也可以按下储存按钮(或按下File菜单上的Save)。然后你帮文件命名,并希望这个文件不会让屏幕变成蓝色的画面。 

储存状态的选择有很多种,这可能要看你会如何使用储存下来的状态而决定。我们会在这一章讨论下面两种选项:

如果只有自己写的Java程序会用到这些数据:

① 用序列化(serialization)。
将被序列化的对象写到文件中。然后就可以让你的程序去文件中读取序列化的对象并把它们展开回到活生生的状态。

如果数据需要被其他程序引用:

② 写一个纯文本文件。用其他程序可以解析的特殊字符写到文件中。例如写成用tab字符来分隔的档案以便让电子表格或数据库应用程序能够应用。

当然还有其他的选择。你可以将数据存进任何格式中。举例来说,你可以把数据用字节而不是字符来写入,或者你也可以将数据写成Java的primitive主数据类型,有一些方法可以提供int、long、boolean等的写入功能。但不管用什么方法,基本所需的输入/输出技巧都一样:把数据写到某处,这可能是个磁盘上的文件,或者是来自网络上的串流。读取数据的方向则刚好相反。当然此处所讨论的部分不涉及使用数据库的情况。

存储状态

假设你有个程序,是个幻想冒险游戏,要过很多关才能完成。在游戏进行的过程中,游戏的人物会累积经验值、宝物、体力等。你不会想让游戏每次重新启动时都得要从头来过—这样根本没人玩。因此你需要一种方法来保存人物的状态,并且在重新开启时能够将状态回复到上次存储时的原状。因为你是程序员,所以你的工作是要让存储与恢复尽可能的简单容易。

① 选项一

把3种序列化的人物对象写入文件中。

创建一个文件,让序列化的3种对象写到此文件中。这文件在你以文本文件形式阅读时是无意义的:
“isr GameCharacter
“oge8iTpowerLjava/lang/
String;[weaponst[Ljava/lang/
String;xp&tlfur[Ljava.lang.String;-“vA应(Gxptbowtswordtdustsq~》tTrolluq~tbare handstbig axsq-xtMagicianuq-tspe llstinvisibility

② 选项二

写入纯文本文件

创建文件,写入3行文字,每个人物一行,以逗点来分开属性:
80,EIf,bow, sword,dust
800,Troll,bare hands,big ax
180,Magician,spells,invisibility

14.2 写入文件的序列化对象

将序列化对象写入文件

下面是将对象序列化(存储)的方法步骤:

1.创建出FileOutputStream 

FileOutputStream fileStream = new FileOutputStream("MyGame.eser");

2.创建ObjectOutputStream

ObjectOutputStream os = new ObjectOutputStream(fileStream);

3.写入对象

os.writeObject(characterOne);
os.writeObject(characterTwo);
os.writeObject(characterThree);

4.关闭ObjectOutputStream

os.close();

14.3 输入/输出串流

Java的输入/输出API带有连接类型的串流,它代表来源与目的地之间的连接,连接串流将串流与其他串流连接起来。

一般来说,串流要两两连接才能作出有意义的事情——其中一个表示连接,另一个则是要被调用方法的。为何要两个?因为连接的串流通常都是很低层的。以FileOutputStream为例,它有可以写入字节的方法。但我们通常不会直接写字节,而是以对象层次的观点来写入,所以需要高层的连接串流。

那又为何不以单一的串流来执行呢?这就要考虑到良好的面向对象设计了。每个类只要做好一件事。FileOutputStream把字节写入文件。ObjectOutputStream把对象转换成可以写入串流的数据。当我们调用ObjectOutputStream的writeObject时,对象会被打成串流送到FileOutputStream来写入文件。

这样就可以通过不同的组合来达到最大的适应性!如果只有一种串流类的话,你只好祈祷API的设计人已经想好所有可能的排列组合。但通过链接的方式,你可以自由地安排串流的组合与去向。

将串流(stream)连接起来代表来源与目的地(文件或网络端口)的连接。串流必须要连接到某处才能算是个串流。

14.4 对象序列化

对象被序列化的时候发生了什么事?

1.在堆上的对象

在堆上的对象有状态——实例变量的值。这些值让同一类的不同实例有不同的意义

2.被序列化的对象

序列化的对象保存了实例变量的值,因此之后可以在堆上带回一模一样的实例

对象的状态是什么?有什么需要保存?

存储primitive主数据类型值37和70是很简单的。但如果对象有引用到其他对象的实例变量时要怎么办?如果这些对象还带有其他对象又该如何?对象基本上有哪些部分是独特的?有哪些东西需要被带回来才能让对象回到和存储时完全相同的状态?当然它会有不同的内存位置,但这无关紧要。我们在乎的是堆上是否有与存储时一模一样的对象状态。

当对象被序列化时,被该对象引用的实例变量也会被序列化。且所有被引用的对象也会被序列化··最棒的是,这些操作都是自动进行的!

序列化程序会将对象版图上的所有东西存储起来。被对象的实例变量所引用的所有对象都会被序列化。

Kennel对象带有对Dog数组对象的引用。Dog[]中有两个Dog对象的引用。每个Dog对象带有String和Collar对象的引用。String对象维护字符的集合,而Collar对象持有一个int。
当保存kennel对象时,所有的对象都会保存! 

14.5 实现Serializable接口

如果要让类能够被序列化,就实现Serializable

Serializable接口又被称为marker或tag类的标记用接口,因为此接口并没有任何方法需要实现的。它的唯一目的就是声明有实现它的类是可以被序列化的。也就是说,此类型的对象可以通过序列化的机制来存储。如果某类是可序列化的,则它的子类也自动地可以序列化(接口的本意就是如此)。

import java.io.*;

public class Box implements Serializable {


    private int width;
    private int height;

    public void setWidth(int w) {
        width = w;
    }

    public void setHeight(int h) {
        height = h;
    }

    public static void main(String[] args) {
        Box myBox = new Box();
        myBox.setWidth(50);
        myBox.setHeight(20);

        try {
            FileOutputStream fs = new FileOutputStream("foo.ser");
            ObjectOutputStream os = new ObjectOutputStream(fs);
            os.writeObject(myBox);
            os.close();
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

序列化时全有或全无的 

整个对象版图都必须正确地序列化,不外就得全部失败如果Duck对象不能序列化,Pond对象就不能被序列化。

14.6 使用瞬时变量

如果某实例变量不能或不应该被序列化,就把它标记为transient(瞬时)的

如果你需要序列化程序能够跳过某个实例变量,就把它标记成transient的变量

import java.io.*;

public class Chat implements Serializable {
    transient  String currentID;
    
    String userName;
    
    //还有更多程序代码……
}

如果你有无法序列化的变量不能被存储,可以用transient这个关键词把它标记出来,序列化程序会把它跳过。

为什么有些变量不能被序列化?可能是设计者忘记实现Serializable。或者动态数据只可以在执行时求出而不能或不必存储。虽然Java函数库中大部分的类可以被序列化,你还是无法将网络联机之类的东西保存下来。它得要在执行期当场创建才有意义。一旦程序关闭之后,联机本身就不再有用,下次执行时需要重新创建出来。 

14.7 对象解序列化

解序列化(Deserialization):还原对象

将对象序列化整件事情的重点在于你可以在事后,在不同的Java虚拟机执行期(甚至不是同一个Java虚拟机),把对象恢复到存储时的状态。解序列化有点像是序列化的反向操作。

1.创建FileInputStream

FileInputStream fileStream = new FileInputStream("Mygame.ser");

2.创建ObjectInputStream

ObjectInputStream os = new ObjectInputStream(fileStream);

3.读取对象

Object one = os.readObject();
Object two = os.readObject();
Object three = os.readObject();

4.转换对象类型

GameCharacter elf = (GameCharacter) one;
GameCharacter troll = (GameCharacter) two;
GameCharacter magician = (GameCharacter) three;

5.关闭ObjectInputStream 

os.close();

解序列化的时候发生了什么事?

当对象被解序列化时,Java虚拟机会通过尝试在堆上创建新的对象,让它维持与被序列化时有相同的状态来恢复对象的原状。但这当然不包括transient的变量,它们不是null(对对象引用而言)不然就是使用primitive主数据类型的默认值。

1.对象从stream中读出来。

2.Java虚拟机通过存储的信息判断出对象的class类型。

3.Java虚拟机尝试寻找和加载对象的类。如果Java虚拟机找不到或无法加载该类,则Java虚拟机会抛出例外。

4.新的对象会被配置在堆上,但构造函数不会执行!很明显的,这样会把对象的状态抹去又变成全新的,而这不是我们想要的结果。我们需要的是对象回到存储时的状态。

5.如果对象在继承树上有个不可序列化的祖先类,则该不可序列化类以及在它之上的类的构造函数(就算是可序列化也一样)就会执行。一旦构造函数连锁启动之后将无法停止。也就是说,从第一个不可序列化的父类开始,全部都会重新初始状态。

6.对象的实例变量会被还原成序列化时点的状态值。transient变量会被赋值null的对象引用或primitive主数据类型的默认为0、false等值。

存储与恢复游戏人物

import java.io.*;

public class GameSaverTest {
    public static void main(String[] args) {
        GameCharacter one = new GameCharacter(50, "Elf", new String[] {"bow", "sword", "dust"});
        GameCharacter two = new GameCharacter(200, "Troll", new String[] {"bare hands", "big ax"});
        GameCharacter three = new GameCharacter(120, "Magician", new String[] {"spell", "invisibility"});

        // 假设此处有改变人物状态的程序代码

        try {
            ObjectOutputStream os = new ObjectOutputStream(new FileOutputStream("Game.ser"));
            os.writeObject(one);
            os.writeObject(two);
            os.writeObject(three);
            os.close();
        } catch (IOException ex) {
            ex.printStackTrace();
        }
        one = null;
        two = null;
        three = null;

        try {
            ObjectInputStream is = new ObjectInputStream(new FileInputStream("Game.ser"));
            GameCharacter oneRestore = (GameCharacter) is.readObject();
            GameCharacter twoRestore = (GameCharacter) is.readObject();
            GameCharacter threeRestore = (GameCharacter) is.readObject();

            System.out.println("One's type: " + oneRestore.getType());
            System.out.println("Two's type: " + twoRestore.getType());
            System.out.println("Three's type: " + threeRestore.getType());
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}
import java.io.*;

public class GameCharacter implements Serializable {
    int power;
    String type;
    String[] weapons;

    public GameCharacter(int p, String t, String[] w) {
        power = p;
        type = t;
        weapons = w;
    }

    public int getPower() {
        return power;
    }

    public String getType() {
        return type;
    }

    public String getWeapons() {
        String weaponList = "";

        for (int i = 0; i < weapons.length; i++) {
            weaponList += weapons[i] + " ";
        }
        return  weaponList;
    }
}

14.8 写入文本文件

将字符串写入文本文件

通过序列化来存储对象是Java程序在来回执行间存储和恢复数据最简单的方式。但有时你还得把数据存储到单纯的文本文件中。假设你的Java程序必须把数据写到文本文件中以让其他可能是非Java的程序读取。例如你的servlet(在Web服务器上执行的Java程序)会读取用户在网页上输入的数据,并将它写入文本文件以让网站管理人能够用电子表格来分析数据。

写入文本数据(字符串)与写入对象是很类似的,你可以使用FileWrite来代替FileOutputStream(当然不会把它链接到ObjectOutputStream上)。

写序列化的对象:

ObjectOutputStream.writeObject(someObject);

写字符串:

fileWriter.write("My first String to save");
import java.io.*;

public class WriteAFile {
    public static void main(String[] args) {
        
        try {
            FileWriter writer = new FileWriter("Foo.txt");
            
            writer.write("hello foo!");
            
            writer.close();
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

14.9 java.io.File

File这个类代表磁盘上的文件,但并不是文件中的内容。啥?你可以把File对象想象成文件的路径,而不是文件本身。例如File并没有读写文件的方法。关于File有个很有用的功能就是它提供一种比使用字符串文件名来表示文件更安全的方式。举例来说,在构造函数中取用字符串文件名的类也可以用File对象来代替该参数,以便检查路径是否合法等,然后再把对象传给FileWriter或FileInputStream。

File对象代表磁盘上的文件或目录的路径名称,如:
/Users/Kathy/Data/GameFile.txt
但它并不能读取或代表文件中的数据。

你可以对File对象做的事情:

1.创建出代表现存盘文件的File对象。

File f = new File("MyCode.txt");

2.建立新的目录。

File dir = new File("Chapter7");
dir.mkdir();

3.列出目录下的内容。

if (dir.isDirectory()) {
    String[] dirContents = dir.list();
    for (int i = 0; i < dirContents.length; i++) {
        System.out.println(dirContents);
    }
}

4.取得文件或目录的绝对路径。

System.out.println(dir.getAbsolutePath());

5.删除文件或目录(成功会返回true)。

boolean isDeleted = f.delete();

缓冲区的奥妙之处

没有缓冲区,就好像逛超市没有推车一样。你只能一次拿一项东西结账。 

缓冲区的奥妙之处在于使用缓冲区比没有使用缓冲区的效率更好。你也可以直接使用FileWriter,调用它的write()来写文件,但它每次都会直接写下去。
你应该不会喜欢这种方式额外的成本,因为每趟磁盘操作都比内存操作要花费更多时间。通过BufferedWriter和FileWriter的链接,BufferedWriter可以暂存一堆数据,然后到满的时候再实际写入磁盘,这样就可以减少对磁盘操作的次数。
如果你想要强制缓冲区立即写入,只要调用writer.flush()这个方法就可以要求缓冲区马上把内容写下去

14.10 读取文本文件

读取文本文件

从文本文件读数据是很简单的,但是这次我们会使用File对象来表示文件,以FileReader来执行实际的读取,并用BufferedReader来让读取更有效率。

读取是以while循环来逐行进行,一直到readLine()的结果为null为止。这是最常见的读取数据方式(几乎非序列化对象都是这样的):以while循环(实际上应该称为while循环测试)来读取,读到没有东西可以读的时候停止(通过读取结果为null来判断)。

import java.io.*;

public class ReadAFile {
    public static void main(String[] args) {

        try {
            File myFile = new File("MyTest.txt");
            FileReader fileReader = new FileReader(myFile);

            BufferedReader reader = new BufferedReader(fileReader);

            String line = null;

            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
            reader.close();
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

14.11 拆分字符串

用String的split()解析

要如何分开问题和答案?

当你读取文件时,问题和答案是合并在同一行,以“/”字符来分开的。

String的split()可以把字符串拆开。

split()可以将字符串拆开成String的数组。

Version ID:序列化的识别

版本控制很重要!
如果你将对象序列化,则必须要有该类才能还原和使用该对象。OK,这是废话。但若你同时又修改了类会发生什么事?假设你尝试要把Dog对象带回来,而某个非transient的变量却已经从double被改成String。这样会很严重地违反Java的类型安全性。其实不只是修改会伤害兼容性,想想下列的情况:

会损害解序列化的修改:
删除实例变量。
改变实例变量的类型。
将非瞬时的实例变量改为瞬时的。
改变类的继承层次。
将类从可序列化改成不可序列化。
将实例变量改成静态的。

通常不会有事的修改:

加入新的实例变量(还原时会使用默认值)A
在继承层次中加入新的类。
从继承层次中删除类。
不会影响解序列化程序设定变量值的存取层次修改。
将实例变量从瞬时改成非瞬时(会使用默认值)。

使用serialVersionUID

每当对象被序列化的同时,该对象(以及所有在其版图上的对象)都会被“盖”上一个类的版本识别ID。这个ID被称为serialVersionUID,它是根据类的结构信息计算出来的。在对象被解序列化时,如果在对象被序列化之后类有了不同的serialVersionUID,则还原操作会失败!但你还可以有控制权。

如果你认为类有可能会演化,就把版本识别ID放在类中。

当Java尝试要还原对象时,它会比对对象与Java虚拟机上的类的serialVersionUID。例如,如果Dog实例是以23这个ID来序列化的(实际的ID长得多),当Java虚拟机要还原Dog对象时,它会先比对Dog对象和Dog类的serialVersionUID。如果版本不相符,Java虚拟机就会在还原过程中抛出异常。

因此,解决方案就是把serialVersionUID放在class中,让类在演化的过程中还维持相同的ID。
这只会在你有很小心地维护类的变动时才办得到!也就是说你得要对带回旧对象的任何问题负起全责。

若想知道某个类的serialVersionUID,则可以使用JavaDevelopment Kit里面所带的serialver工具来查询。 

14.12 程序料理

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/124846.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

域名解析DNS:如何查询txt类型的解析记录

前言 略 查询txt类型的解析记录 使用 nslookup 命令查询。 示例&#xff1a; cmd> nslookup -qttxt _acme-challenge.mydomain.com 服务器: UnKnown Address: fe80::1非权威应答: _acme-challenge.mydomain.com text "_unitrust-dcv2311071423492fmnwb1w…

07 # 手写 find 方法

find 的使用 find() 方法返回数组中满足提供的测试函数的第一个元素的值。否则返回 undefined。 ele&#xff1a;表示数组中的每一个元素index&#xff1a;表示数据中元素的索引array&#xff1a;表示数组 <script>var arr [1, 3, 5, 7, 9];var result arr.find(fun…

第七讲:利用类事件改变对象的属性(上)

《VBA中类的解读及应用》教程【10165646】是我推出的第五套教程&#xff0c;目前已经是第一版修订了。这套教程定位于最高级&#xff0c;是学完初级&#xff0c;中级后的教程。 类&#xff0c;是非常抽象的&#xff0c;更具研究的价值。随着我们学习、应用VBA的深入&#xff0…

矿泉水除溴酸盐、矿泉水除溴化物的技术

我们常饮用的各品牌的矿泉水&#xff0c;实际在生产过程当中也涉及到了相当复杂的处理工艺的&#xff0c;今天为大家分享的是关于矿泉水中溴酸盐、溴化物的知识点&#xff0c;以及矿泉水中为什么要除溴酸盐&#xff1f;原理是什么&#xff0c;那么又是什么样的技术能真正从根本…

基于情感分析+聚类分析+LDA主题分析对服装产品类的消费者评论分析(文末送书)

&#x1f935;‍♂️ 个人主页&#xff1a;艾派森的个人主页 ✍&#x1f3fb;作者简介&#xff1a;Python学习者 &#x1f40b; 希望大家多多支持&#xff0c;我们一起进步&#xff01;&#x1f604; 如果文章对你有帮助的话&#xff0c; 欢迎评论 &#x1f4ac;点赞&#x1f4…

华为防火墙基本原理工作方法总结

防火墙只会对tcp首包syn建立会话表&#xff0c;其它丢掉&#xff0c;如synack&#xff0c;ack udp直接建立会话表 icmp只对首包请求包建立会话表&#xff0c;其它包&#xff0c;如应答的不会建立直接丢掉 防火墙状态查看&#xff1a; rule name trust_untrust source-zone tru…

Qlik Sense : Fetching data with Qlik Web Connectors

目录 Connecting to data sources Opening a connector Connecting to a data source Authenticating the connector Defining table parameters Using standard mode or legacy mode Standard mode Connector overview Using multi-line input parameters to fetch da…

解析虚拟文件系统的调用

Linux 可以支持多达数十种不同的文件系统。它们的实现各不相同&#xff0c;因此 Linux 内核向用户空间提供了虚拟文件系统这个统一的接口&#xff0c;来对文件系统进行操作。它提供了常见的文件系统对象模型&#xff0c;例如 inode、directory entry、mount 等&#xff0c;以及…

【Linux】 reboot 命令使用

reboot 命令用于用来重新启动计算机。 语法 reboot [参数] 命令选项及作用 执行令 man --reboot 执行命令结果 参数 -n : 在重开机前不做将记忆体资料写回硬盘的动作-w : 并不会真的重开机&#xff0c;只是把记录写到 /var/log/wtmp 档案里-d : 不把记录写到 /var/log…

全志A40i应用笔记 | 3种常见的网卡软件问题以及排查思路

在飞凌嵌入式OKA40i-C开发板上虽然只有一个网口&#xff0c;但全志A40i-H处理器本身是有两个网络控制器的&#xff0c;因此在飞凌嵌入式提供的产品资料中提供了双网口解决方案。有的工程师小伙伴在开发过程中会遇见一些网卡的设计问题&#xff0c;今天小编为大家分享3种在使用O…

基于Java+SpringBoot+Mybaties-plus+Vue+ElementUI 失物招领小程序 设计与实现

一.项目介绍 失物招领小程序 用户登录、忘记密码、退出系统 发布失物 和 发布招领 查看我发布的失物和招领信息 失捡物品模块可以查看和搜索所有用户发布的信息。 二.环境需要 1.运行环境&#xff1a;java jdk1.8 2.ide环境&#xff1a;IDEA、Eclipse、Myeclipse都可以&#…

【紫光同创国产FPGA教程】【PGC1/2KG第七章】7.数字钟实验例程

本原创教程由深圳市小眼睛科技有限公司创作&#xff0c;版权归本公司所有&#xff0c;如需转载&#xff0c;需授权并注明出处 适用于板卡型号&#xff1a; 紫光同创PGC1/2KG开发平台&#xff08;盘古1K/2K&#xff09; 一&#xff1a;盘古1K/2K开发板&#xff08;紫光同创PGC…

k8s configMap挂载(项目配置文件放到configMap中,不同环境不同配置)

背景说明 项目对接配置文件加密&#xff0c;比如数据库密码、redis密码等。但是密文只能放到指定的配置文件中(important.properties)&#xff0c;该配置文件又不能接收环境变量&#xff0c;所以就很难区分不同环境的不同配置&#xff08;不同环境的数据库密码、redis密码一般…

ECA-Net(Efficient Channel Attention Network)

ECA-Net&#xff08;Efficient Channel Attention Network&#xff09;是一种用于计算机视觉任务的注意力模型&#xff0c;旨在增强神经网络对图像特征的建模能力。本文详细介绍ECA-Net注意力模型的结构设计&#xff0c;包括其背景、动机、组成部分以及工作原理。ECA-Net模块的…

LoRaWAN物联网架构

与其他网关一样&#xff0c;LoRaWAN网关也需要在规定的工作频率上工作。在特定国家部署网关时&#xff0c;必须要遵循LoRa联盟的区域参数。不过&#xff0c;它是没有通用频率的&#xff0c;每个国家对使用非授权MHZ频段都有不同的法律规定。例如&#xff0c;中国的LoRaWAN频段是…

react-native 0.63 适配 Xcode 15 iOS 17.0+

iOS 17.0 Simulator(21A328)下载失败 App Store 更新到 Xcode15 后&#xff0c;无法运行模拟器和真机。需要下载iOS 17对应的模拟器。Xcode中更新非常容易中断失败&#xff0c;可以在官网单独下载iOS 17模拟器文件&#xff0c;例如&#xff1a;iOS_17.0.1_Simulator_Runtime.d…

开放智慧,助力学习——电大搜题,打开学无止境的新篇章

随着信息技术的迅猛发展&#xff0c;学习已经不再受时间和空间的限制。电大搜题微信公众号为广播电视大学和河南开放大学的学子们带来了便利和智慧&#xff0c;让学习变得更加高效和愉快。 电大搜题微信公众号作为一款专为电大学生而设计的学习助手&#xff0c;是学习中不可或…

红队专题-从零开始VC++C/S远程控制软件RAT-MFC-远程控制软件总结

红队专题 招募六边形战士队员[30]远控班第一期课程与远控总结 招募六边形战士队员 一起学习 代码审计、安全开发、web攻防、逆向等。。。 私信联系 [30]远控班第一期课程与远控总结 一.Bug修复(1)生成路径(2)显示系统版本号二.内存泄露(1)如何检查内存泄露 #define CRTDBG_…

【广州华锐互动】VR虚拟仿真技术为航测实践教学提供了哪些帮助?

在过去的几十年里&#xff0c;航空测量技术发展迅速&#xff0c;为我们提供了前所未有的地理信息获取手段。然而&#xff0c;这个领域的发展并未停止&#xff0c;最新的技术进步——虚拟现实(VR)——正在为航测实践教学开启新的篇章。 VR虚拟现实技术能够创建和体验三维虚拟环境…