2023年2月

本人在做一个新闻内容的模块的时候,发现如果内容在4K以上的字符串会出错,得到的内容会是乱码(也就是被自动截断),如果小于4K,那么就不会有问题。

原来采用了Varchar2的类型来存储,但发现后修改为Clob类型的也出现同样的问题,而且发现日志的错误是:System.Data.OracleClient.OracleException: ORA-01461: can bind a LONG value only for insert into a LONG column。

由于Clob类型是可以存放很大类型的文本数据的,不会是数据库字段容量不够,因此检查插入和更新的C#代码,发现原来的代码是这样的:


代码


public

bool
Insert(Hashtable recordField,
string
targetTable, DbTransaction trans)
{

bool
result
=

false
;

string
fields
=

""
;
//
字段名



string
vals
=

""
;
//
字段值



if
( recordField
==

null

||
recordField.Count
<

1
)
{

return
result;
}

List

<
OracleParameter
>
paramList
=

new
List
<
OracleParameter
>
();
IEnumerator eKeys

=
recordField.Keys.GetEnumerator();


while
( eKeys.MoveNext() )
{

string
field
=
eKeys.Current.ToString();
fields

+=
field
+

"
,
"
;

if
(
!
string
.IsNullOrEmpty(seqField)
&&

!
string
.IsNullOrEmpty(seqName)

&&
(field.ToUpper()
==
seqField.ToUpper()))
{
vals

+=

string
.Format(
"
{0}.NextVal,
"
, seqName);
}

else

{
vals

+=

string
.Format(
"
:{0},
"
, field);

object
val
=
recordField[eKeys.Current.ToString()];
paramList.Add(

new
OracleParameter(
"
:
"

+
field, val));
}
}

fields

=
fields.Trim(
'
,
'
);
//
除去前后的逗号


vals
=
vals.Trim(
'
,
'
);
//
除去前后的逗号



string
sql
=

string
.Format(
"
INSERT INTO {0} ({1}) VALUES ({2})
"
, targetTable, fields, vals);

Database db

=
DatabaseFactory.CreateDatabase();
DbCommand command

=
db.GetSqlStringCommand(sql);
command.Parameters.AddRange(paramList.ToArray());


if
( trans
!=

null
)
{
result

=
db.ExecuteNonQuery(command, trans)
>

0
;
}

else

{
result

=
db.ExecuteNonQuery(command)
>

0
;
}


return
result;
}

重要的地方就是我使用了该行代码:

paramList.Add(
new
OracleParameter(
"
:
"

+
field, val));

猜想可能是由于这行代码的问题导致,因此修改参数化的Oracle参数变量代码为另外一个种指定对象类型的方式:

OracleParameter a
=

new
OracleParameter(
"
:
"

+
field, OracleType.Clob, val.ToString().Length);
a.Value

=
val;
paramList.Add(a);

验证通过,发现再长的内容,写入也是正常的,不会出错和出现的截断乱码。由于第一种忽略了Oracle参数类型,就是为了适应各种类型对象的参数化构造,实现统一添加参数化内容的,由于超长的字符内容会出现问题,因此只好修改基类操作的添加参数代码,添加一个条件分支作为处理。调整后的插入代码如下(更新代码类似操作):


代码


public

bool
Insert(Hashtable recordField,
string
targetTable, DbTransaction trans)
{

bool
result
=

false
;

string
fields
=

""
;
//
字段名



string
vals
=

""
;
//
字段值



if
( recordField
==

null

||
recordField.Count
<

1
)
{

return
result;
}

List

<
OracleParameter
>
paramList
=

new
List
<
OracleParameter
>
();
IEnumerator eKeys

=
recordField.Keys.GetEnumerator();


while
( eKeys.MoveNext() )
{

string
field
=
eKeys.Current.ToString();
fields

+=
field
+

"
,
"
;

if
(
!
string
.IsNullOrEmpty(seqField)
&&

!
string
.IsNullOrEmpty(seqName)

&&
(field.ToUpper()
==
seqField.ToUpper()))
{
vals

+=

string
.Format(
"
{0}.NextVal,
"
, seqName);
}

else

{
vals

+=

string
.Format(
"
:{0},
"
, field);

object
val
=
recordField[eKeys.Current.ToString()];


if
(val.ToString().Length
>=

4000
)
{
OracleParameter a

=

new
OracleParameter(
"
:
"

+
field, OracleType.Clob, val.ToString().Length);
a.Value

=
val;
paramList.Add(a);
}

else

{
paramList.Add(

new
OracleParameter(
"
:
"

+
field, val));
}
}
}

fields

=
fields.Trim(
'
,
'
);
//
除去前后的逗号


vals
=
vals.Trim(
'
,
'
);
//
除去前后的逗号



string
sql
=

string
.Format(
"
INSERT INTO {0} ({1}) VALUES ({2})
"
, targetTable, fields, vals);

Database db

=
DatabaseFactory.CreateDatabase();
DbCommand command

=
db.GetSqlStringCommand(sql);
command.Parameters.AddRange(paramList.ToArray());


if
( trans
!=

null
)
{
result

=
db.ExecuteNonQuery(command, trans)
>

0
;
}

else

{
result

=
db.ExecuteNonQuery(command)
>

0
;
}


return
result;
}

这样,就可以在使用代码生成工具Database2Sharp(http://www.iqidi.com/database2sharp.htm)生成的Oracle代码中,不需要改变任何地方,只需要调整BaseDAL的基类中Insert和Update中的部分内容,就可以了。

不知阁下是否都听说过赶集网,我想对大多数人来说,应该不会太陌生,有时无聊之时,还是可以去逛逛,了解社会百态,熟悉人间风情,品味生活精彩,呵呵。

赶集网基本是按照全国城市分类的,每一个城市是相同界面,不同内容。你可以在不同城市中切换,以便关注该城市的各种信息。


对应每个城市,赶集网又有不同的分类,基本上涵盖了生活的方方面面。


进入一个特定的分类,你可以看到相关的用户文章,有些事经纪人发的,有些是普通老百姓发的,各取所需,各观所好。



好了,说到这里,请不要以为我是给赶集网做广告,呵呵,肯定不是。

我是先解剖赶集网的内容结构,为做内容采集做准备,下面先Show一下我做的赶集网采集程序先,先有一个感性的认识,也为下面的代码找一个实在的宿主,而并非纯理论的研究,哈哈。

下面分析赶集网的内容获取及程序的工作方式:

首先第一步,我们要拿到全国省市的的划分名称,这部可以去国家统计局那里找找,哈哈,我是说真的哦。

赶集网每个城市,对应一个编号,如北京对应bj,广州对应gz, 你从上面的城市划分的源码中可以找到:
<dd>
<a
href

="
http://bj.ganji.com/

"
class

="
redLink

">

北京
</a>
</dd>,这里面的内容就包含了bj的内容,后面加上ganji.com就是北京赶集网的链接地址了,花点功夫把它找出来吧。

第二个是网站内容的分类,我查过不同城市的分类好像是一样的,因此只需要获取一个城市的分类就可以了,其他的就一样。

把分类的内容保存成html,然后放到VS格式化一通,得到了内容如下所示:

下面你根据内容,编写一个正则表达式来把分类提取出来,就可以了。献上拙例,供参考。

首先我们把分类分级,一级分类是房产、二手物品、招聘等大类,二级分类表示大分类(如房产)下面的小分类,如出租房、二手房等内容类别。

下面代码是大类的获取:


代码


private

void
GetCatetory(
object
obj)

{

string
mainUrl
=

"
http://gz.ganji.com
"
;

string
DataRegex
=

"
<dt><a\\s*?href=\
"
(
?<
value
>
.
*?
)\
"
\\s*?target=\
"
_blank\
"
>(?<key>.*?)&raquo;</a></dt>
"
;

string
itemString
=

""
;

itemString
=
CSocket.GetHtmlByUrl(mainUrl);


Database db
=
DatabaseFactory.CreateDatabase();

DbCommand command
=

null
;

if
(
!
string
.IsNullOrEmpty(itemString))

{

Regex re
=

new
Regex(DataRegex, RegexOptions.IgnoreCase
|
RegexOptions.Multiline
|
RegexOptions.IgnorePatternWhitespace);

Match mc
=
re.Match(itemString);

if
(mc.Success)

{

MatchCollection mcs
=
re.Matches(itemString);

foreach
(Match me
in
mcs)

{

string
strKey
=
me.Groups[
"
key
"
].Value;

string
strValue
=
me.Groups[
"
value
"
].Value;

try


{

string
sql
=

string
.Format(
"
insert into GanjiCategory(CategoryName,CategoryUrl) values('{0}','{1}{2}')
"
,

strKey, mainUrl, strValue);

command
=
db.GetSqlStringCommand(sql);

db.ExecuteNonQuery(command);


string
tips
=

string
.Format(
"
正在处理 {0}
"
, strKey);

CallCtrlWithThreadSafety.SetText
<
Label
>
(
this
.lblSchoolTips, tips,
this
);

}

catch
(Exception ex)

{

LogHelper.Error(ex);

}

}

}

}

}

下面代码是小分类的获取:



代码


private

void
GetItemName(
object
obj)

{

string
mainUrl
=

"
http://gz.ganji.com
"
;

Database db
=
DatabaseFactory.CreateDatabase();

DbCommand command
=

null
;


string
content
=
CSocket.GetHtmlByUrl(mainUrl);

try


{

#region
获得各项列表字符串


List
<
string
>
itemHtmlList
=

new
List
<
string
>
();

string
itemRegex
=

"
<dl\\s*?class=\
"
list_.
*?
\
"
>\\s*(.*?)\\s*</dl>
"
;

Regex re
=

new
Regex(itemRegex, RegexOptions.IgnoreCase
|
RegexOptions.Singleline
|
RegexOptions.IgnorePatternWhitespace);

Match mc
=
re.Match(content);

if
(mc.Success)

{

MatchCollection mcs
=
re.Matches(content);

foreach
(Match me
in
mcs)

{

string
strValue
=
me.Groups[
1
].Value;

itemHtmlList.Add(strValue);

}

}

#endregion



#region
对每项内容进行解析


foreach
(
string
itemString
in
itemHtmlList)

{

string
cateDataRegex
=

"
<dt><a\\s*?href=\
"
(
?<
value
>
.
*?
)\
"
\\s*?target=\
"
_blank\
"
>(?<key>.*?)&raquo;</a></dt>
"
;

string
itemNameRegex
=

"
<dd>\\s*<a\\s*href=\
"
(
?<
value
>
.
*?
)\
"
\\s*target=\
"
_blank\
"
>(?<key>.*?)</a>\\((?<id>.*?)\\)</dd>
"
;


re
=

new
Regex(cateDataRegex, RegexOptions.IgnoreCase
|
RegexOptions.Singleline
|
RegexOptions.IgnorePatternWhitespace);

mc
=
re.Match(itemString);


string
categoryName
=

""
;

if
(mc.Success)

{

Match me
=
re.Matches(itemString)[
0
];

categoryName
=
me.Groups[
"
key
"
].Value;

}


re
=

new
Regex(itemNameRegex, RegexOptions.IgnoreCase
|
RegexOptions.Multiline
|
RegexOptions.IgnorePatternWhitespace);

mc
=
re.Match(itemString);

if
(mc.Success)

{

MatchCollection mcs
=
re.Matches(itemString);

foreach
(Match me
in
mcs)

{

string
strKey
=
CText.GetTxtFromHtml(me.Groups[
"
key
"
].Value);

string
strValue
=
me.Groups[
"
value
"
].Value;


try


{

//
保存内容代码




string
tips
=

string
.Format(
"
正在处理 {0}
"
, strKey);

CallCtrlWithThreadSafety.SetText
<
Label
>
(
this
.lblSchoolTips, tips,
this
);

}

catch
(Exception ex)

{

LogHelper.Error(ex);

}

}

}

}

#endregion


}

catch
(Exception ex)

{

LogHelper.Error(ex);

}

}

完成上面两步后,我们就可以继续第三部,采集赶集网的内容等信息了。

在前面介绍过Socket编程的文章中,有一篇是《
Socket开发探秘--基类及公共类的定义
》,其中介绍了一个独立线程处理类,专门在一个独立的线程中处理Socket的数据包的。摘录前面的内容介绍一下:


5
、ThreadHandler,数据独立线程处理类

对每个不同类型的数据(不同的协议类型),可以用独立的线程进行处理,这里封装了一个基类,用于进行数据独立线程的处理


上面的工作原理是这样的,每次收到数据后,系统把数据扔给独立线程处理类,处理类放到一个队列Queue的列表中,每次从中弹出一个来处理,根据不同的协议头,分派到不同的线程来处理,这样可以提高响应速度,防止线程之间的阻塞,能够充分利用系统的资源。

其实我们还可以把这个思想应用到日常的Winform开发中,有时候我们可能在处理一些比较费时的操作,可能是需要做一部分显示一部分,类似日常生活中的项目周报、月周报的场景,因为不可能等一个几年的项目完成后,你才告诉老板你的工作情况吧。

借鉴Socket的数据处理方式,我在Winform程序中运用了这种数据处理方式,如我在采集赶集网的数据的时候,可以把采集到的部分数据扔给系统中的数据独立处理线程,让他们爱怎么显示就怎么显示,程序不中断,继续乐此不彼的去采集内容去,然后继续这样做(每采集一部分仍出去一部分),直到采集完毕。




代码


public

class
ThreadHandler
<
T
>

{

///

<summary>


///
处理数据线程

///

</summary>


Thread _Handlehread
=

null
;

private

string
_ThreadName
=

""
;

private
Fifo
<
T
>
_DataFifo
=

new
Fifo
<
T
>
();


///

<summary>


///
线程名字

///

</summary>



public

string
ThreadName
{

get
{
return
_ThreadName; }

set
{ _ThreadName
=
value; }
}


///

<summary>


///
接收处理数据

///

</summary>


///

<param name="data"></param>



public

virtual

void
AppendData(T data)
{

if
(data
!=

null
)
_DataFifo.Append(data);
}


///

<summary>


///
数据处理

///

</summary>



protected

virtual

void
DataThreadHandle()
{

try

{

while
(
true
)
{
T data

=
_DataFifo.Pop();
DataHandle(data);
}
}

catch
(Exception ex)
{
LogHelper.Error(ex);
}
}


///

<summary>


///
数据处理

///

</summary>


///

<param name="data"></param>



public

virtual

void
DataHandle(T data)
{
}



///

<summary>


///
开始数据处理线程

///

</summary>



public

virtual

void
StartHandleThread()
{

if
(_Handlehread
==

null
)
{
_Handlehread

=

new
Thread(
new
ThreadStart(DataThreadHandle));
_Handlehread.IsBackground

=

true
;
_Handlehread.Start();
}
LogHelper.Info(

string
.Format(
"
[ThreadHandler] 线程->{0}启动。。。。。。
"
, _ThreadName));
}

上面的是独立线程处理的基类,下面我们用一个子类继承他,方便代码逻辑的剥离封装:

在下面的代码中,我根据不同的Table表内容类型,放到不同的函数中进行处理,以便实现不同的显示方式。


代码



public

class
TestDataHandleThread : ThreadHandler
<
PreData
>

{

public
TestDataHandleThread()
{

base
.ThreadName
=

"
测试数据操作处理线程
"
;
}


public

override

void
DataHandle(PreData data)
{

try

{

if
(data.Key
==
KeyType.PostAticle)
{

if
(
!
string
.IsNullOrEmpty(data.Content.TableName))
{
ThreadPool.QueueUserWorkItem(

new
WaitCallback(Portal.gc.MainDialog.DisplayForm), data.Content);
}
}

else

if
(data.Key
==
KeyType.ContactInfo)
{

if
(
!
string
.IsNullOrEmpty(data.Content.TableName))
{
ThreadPool.QueueUserWorkItem(

new
WaitCallback(Portal.gc.MainDialog.DisplayContactForm), data.Content);
}
}
}

catch
(Exception ex)
{
LogHelper.Error(

"
[TestDataHandleThread] 测试数据操作处理线程异常:{0}
"

+
ex.ToString());
}
}
}

下面代码是表的不同类型的枚举类和预处理数据格式定义。




代码


public

enum
KeyType{PostAticle, ContactInfo};


///

<summary>


///
预处理的数据

///

</summary>



public

class
PreData
{

private
KeyType key;

private
DataTable content;


public
KeyType Key
{

get
{
return
key; }

set
{ key
=
value; }
}


public
DataTable Content
{

get
{
return
content; }

set
{ content
=
value; }
}


public
PreData(KeyType key, DataTable data)
{

this
.key
=
key;

this
.content
=
data;
}
}

在实际的赶集网采集程序中,我需要每采集一个链接的内容后,就处理并显示,因此示例代码如下所示:




代码


///

<summary>


///
获取网站发布内容,并添加到线程进行处理

///

</summary>


///

<param name="itemDict"></param>


///

<param name="regexDict"></param>



private

void
GetContent(Dictionary
<
string
,
string
>
itemDict)
{

foreach
(
string
key
in
itemDict.Keys)
{
DataTable dt

=

new
DataTable(key);


//
标题解析,省略N行代码

//
内容解析,省略N+N行代码


//
添加到线程进行处理


Portal.gc.MainDialog.AddData(
new
PreData(KeyType.PostAticle, dt));
}
}




代码


///

<summary>


///
添加消息数据,根据不同的消息类型分派到不同的线程处理

///

</summary>


///

<param name="data">
消息数据
</param>



public

void
AddData(PreData data)
{
_testDataThread.AppendData(data);
}


///

<summary>


///
采用多线程方式显示内容数据

///

</summary>


///

<param name="data"></param>



public

void
DisplayForm(
object
table)
{
DataTable data

=
table
as
DataTable;
FrmContent content

=
FindDocument(data.TableName)
as
FrmContent;

if
(content
==

null
)
{
content

=

new
FrmContent();
content.TabText

=
data.TableName;
content.Text

=
data.TableName;
}


this
.Invoke(
new
MethodInvoker(
delegate
()
{
content.BindData(data, data.TableName);
content.Show(

this
.dockPanel);
}));
}

好了,思路是思路,程序是程序,两者结合就是实践的证明,采集大量的网站连接的时候,在也不会出现主界面停顿或者假死的情况了。下面是我闲暇时间的练笔之作, 贴图以证方案之可行。



在采集的时候,整个程序再也不会出现假死的情况,你还可以去处理其他工作的。另外,由于涉及了线程的处理工作,你还需要定时检测处理线程,如果线程有问题,还需要重启线程就可以了,这部分是属于线程检查优化的部分,不再介绍。

前几天,有一位园友写了一篇不错的文章《
WinForm 清空界面控件值的小技巧
》,文章里面介绍了怎么清空界面各个控件值的一个好技巧,这个方法确实是不错的,在繁杂的界面控件值清理中,可谓省时省力。

本人在开发Winform程序中,也有一个类似的小技巧,不是清空控件值,而是赋值,给复选框赋值和获取值的小技巧,分享讨论一下。

应用场景是这样的,如果你有一些需要使用复选框来呈现内容的时候,如下面两图所示:

以上的切除部分的内容,是采用在GroupBox中放置多个CheckBox的方式;其实这个部分也可以使用Winform控件种的CheckedListBox控件来呈现内容。如下所示。

不管采用那种控件,我们都会涉及到为它赋值的麻烦,我这里封装了一个函数,可以很简单的给控件 赋值,大致代码如下。

CheckBoxListUtil.SetCheck(
this
.groupRemove, info.切除程度);

那么取控件的内容代码是如何的呢,代码如下:

info.切除程度
=
CheckBoxListUtil.GetCheckedItems(
this
.groupRemove);

赋值和取值通过封装函数调用,都非常简单,也可以重复利用,封装方法函数如下所示。



代码


public

class
CheckBoxListUtil
{

///

<summary>


///
如果值列表中有的,根据内容勾选GroupBox里面的成员.

///

</summary>


///

<param name="group">
包含CheckBox控件组的GroupBox控件
</param>


///

<param name="valueList">
逗号分隔的值列表
</param>



public

static

void
SetCheck(GroupBox group,
string
valueList)
{

string
[] strtemp
=
valueList.Split(
'
,
'
);

foreach
(
string
str
in
strtemp)
{

foreach
(Control control
in
group.Controls)
{
CheckBox chk

=
control
as
CheckBox;

if
(chk
!=

null

&&
chk.Text
==
str)
{
chk.Checked

=

true
;
}
}
}
}


///

<summary>


///
获取GroupBox控件成员勾选的值

///

</summary>


///

<param name="group">
包含CheckBox控件组的GroupBox控件
</param>


///

<returns>
返回逗号分隔的值列表
</returns>



public

static

string
GetCheckedItems(GroupBox group)
{

string
resultList
=

""
;

foreach
(Control control
in
group.Controls)
{
CheckBox chk

=
control
as
CheckBox;

if
(chk
!=

null

&&
chk.Checked)
{
resultList

+=

string
.Format(
"
{0},
"
, chk.Text);
}
}

return
resultList.Trim(
'
,
'
);
}


///

<summary>


///
如果值列表中有的,根据内容勾选CheckedListBox的成员.

///

</summary>


///

<param name="cblItems">
CheckedListBox控件
</param>


///

<param name="valueList">
逗号分隔的值列表
</param>



public

static

void
SetCheck(CheckedListBox cblItems,
string
valueList)
{

string
[] strtemp
=
valueList.Split(
'
,
'
);

foreach
(
string
str
in
strtemp)
{

for
(
int
i
=

0
; i
<
cblItems.Items.Count; i
++
)
{

if
(cblItems.GetItemText(cblItems.Items[i])
==
str)
{
cblItems.SetItemChecked(i,

true
);
}
}
}
}


///

<summary>


///
获取CheckedListBox控件成员勾选的值

///

</summary>


///

<param name="cblItems">
CheckedListBox控件
</param>


///

<returns>
返回逗号分隔的值列表
</returns>



public

static

string
GetCheckedItems(CheckedListBox cblItems)
{

string
resultList
=

""
;

for
(
int
i
=

0
; i
<
cblItems.CheckedItems.Count; i
++
)
{

if
(cblItems.GetItemChecked(i))
{
resultList

+=

string
.Format(
"
{0},
"
, cblItems.GetItemText(cblItems.Items[i]));
}
}

return
resultList.Trim(
'
,
'
);
}

}

以上代码分为两部分, 其一是对GroupBox的控件组进行操作,第二是对CheckedListBox控件进行操作。

这样在做复选框的时候,就比较方便一点,如我采用第一种GroupBox控件组方式,根据内容勾选的界面如下所示。

应用上面的辅助类函数,如果你是采用GroupBox方案,你就可以随便拖几个CheckBox控件进去就可以了,也犯不着给他取个有意义的名字,因为不管它是张三还是李四,只要它的父亲是GroupBox就没有问题了。

我们先看看GMap.NET的定义:

GMap.NET是一个强大、免费、跨平台、开源的.NET控件,它在Windows Forms 和WPF环境中能够通过Google, Yahoo!, Bing, OpenStreetMap, ArcGIS, Pergo, SigPac等实现寻找路径、地理编码以及地图展示功能,并支持缓存和运行在Mobile环境中。

GMap.NET是一个开源的GEO地图定位和跟踪程序。就像谷歌地图、雅虎地图一样,可以自动计算两地的距离,定位经纬度,与Google地图不同的是,该项目是建立在C#语言WinForm基础上的。可以对地图放大缩小,进行城市标记等。

GMap.NET的项目地址是
http://greatmaps.codeplex.com/
,我们可以下载相关的例子和源码进行学习和研究。我在Google上搜过相关的项目,好像介绍的文章不多,不过不影响这个控件的强大和易用。我们先看看它的界面截图:

GMap.NET号称是可以支持很多种地图来源的,不过我试了一下,好像有部分是有些问题,最好的效果是GoogleMapChina,如上图所示。

我用GoogleMapChina可以放大到很详细的街道图,做了一个地址查询的例子,如下所示:

控件可以绘出两地的行车线路或者步行线路等,而且能够算出两地的距离,不过对于地理编码的解析好像不是很准确,也获取不到公交线路等信息,不过应付一般的应用,应该是蛮不错的了。

这个控件默认使用了右键按住作为拖动,和GoogleMap用鼠标左键作为拖动有点不太一样(不过可以通过this.gMapControl1.DragButton = MouseButtons.Left;来实现左键拖动),两者皆能够支持滚轮放大缩小的操作。控件还支持经纬度的精确定位,绘制图标(支持绿色、红色的图标、十字符号等标记),支持中心点移动 ,导出地图图片等功能。由于地图控件支持路线的绘制,所以应该支持一般的GIS应用中的轨迹回放功能的。由于地图控件支持鼠标位置和经纬度坐标的转换功能,因此,可以随意获取到相关的经纬度信息。

这个Winform的地图控件,虽然对比Web的GoogleMap来说,很多功能还不具备,但是较普通的MapX和MapXtreme或者ArcGis等传统的GIS来说,不用付太多的费用(甚至不用付费用),就可以使用上精细的地图,不得不说是一个好消息。