一、引言

观察者模式Observer)是一种行为设计模式, 允许定义一种订阅机制, 可在对象事件发生时通知多个 “观察” 该对象的其他对象。

在程序设计中,需要为某对象建立一种“通知依赖关系”,当该对象的状态发生变化时,通过公告或广播的方式通知一系列相关对象,实现对象之间的联动。但这种一对多的对象依赖关系往往又会造成该对象与其相关的一系列对象之间一种特别紧密的耦合关系。观察者模式是一种使用频率较高的行为型设计模式,可以弱化上述的一对多依赖关系,实现对象之间关系的松耦合。观察者模式在工作中往往会在不知不觉中被用到。


二、观察者模式

比如有的单机闯关打斗类游戏,该游戏的老板决定将这款单机闯关打斗类游戏改造成类似于《魔兽世界》的大型多人角色扮演类游戏,以往单机游戏中的主角变成了游戏中的每个玩家。游戏本身是不收费的,但游戏中的各种道具(例如药品等)是要收费的。核心内容如下:

  • 引人“家族”概念,玩家可以自由加人某个家族,一个家族最多容纳20个玩家,不同家族的玩家之间可以根据游戏规则在指定时间和地点通过战斗获取利益。增加游戏收人,消耗游戏中的各种物资,例如补充生命值或补充魔法值的药品等。
  • 家族成员的聊天信息会被家族中的所有其他成员看到,当然,家族其他成员有权屏蔽家族的聊天信息。非本家族的玩家是看不到本家族成员聊天信息的。也就是实现家族成员聊天功能。

于是,程序开始了第一版的开发工作,代码如下:

class Fighter
{
public:
	Fighter(int playerID, const string& playerName)
		:m_playerID(playerID), m_playerName(playerName) {}
	virtual ~Fighter() {}
	void setFamilyID(int id)
	{
		m_familyID = id;
	}
private:
	int m_playerID;
	string m_playerName;
	int m_familyID = -1;//表示无家族     
	//...
};

//"战士"类玩家,父类为Fighter
class F_Warrior :public Fighter {
public:
	F_Warrior(int tmpID, string tmpName) :Fighter(tmpID, tmpName) {} 
};
//"法师"类玩家,父类为Fighter
class F_Mage :public Fighter
{
public:
	F_Mage(int tmpID, string tmpName) :Fighter(tmpID, tmpName) {} 
};

因为玩家在游戏中会创建很多家族,每个家族都用唯一的ID值(数字)来代表(在实际的游戏中,这些家族信息会被保存到数据库中)。Fighter类中提供了成员函数SetFamilyID,通过调用该函数设置某玩家的家族ID值以标记该玩家加人了该ID值所代表的家族。还要引人一个全局的list容器,用来保存所有玩家的列表,以方便对玩家进行操作。增加如下代码:

class Fighter;
list<Fighter*>g_players;

每个玩家来到游戏中之后,都需要加入这个列表中。

当一个玩家发送一条聊天信息时,同家族的其他玩家也应该收到这条聊天信息。在类Fighter中引l人SayWords成员函数,表示某玩家说了一句话,在其中会调用NotifyWords成员函数把这条聊天信息发送给其他玩家。在Fighter类定义中加人如下代码:

public:
	void SayWords(string word)
	{
		if (m_familyID != -1)
		{
			//该玩家属于某个家族,应该把聊天内容信息传送给该家族的其他玩家
			for (auto iter = g_players.begin(); iter != g_players.end(); ++iter)
				if (m_familyID == (*iter)->m_familyID)
				{
					//同一个家族的其他玩家也应该收到聊天信息
					//NotifyWords((*iter).(tmpContent);
				}
		}
	}
private:
	void NotifyWords(Fighter* otherPlayer, string tmpContent)
	{
		cout << "玩家: " << otherPlayer->m_playerName << " 收到了玩家:" << m_playerName << " .发送的聊天信息:" << tmpContent << endl;
	}

这样同一个家族的玩家就可以进行对话了。

// 创建玩家并添加到全局玩家列表
F_Warrior* player1 = new F_Warrior(1, "战士张三");
F_Warrior *player2 = new F_Warrior(2, "法师李四");
F_Warrior *player3 = new F_Warrior(3, "战士王五");

g_players.push_back(player1);
g_players.push_back(player2);
g_players.push_back(player3);

// 设置家族ID
player1->setFamilyID(1);
player2->setFamilyID(1);
player3->setFamilyID(2);

// 玩家张三发送消息,只有李四能收到,因为它们在同一个家族
player1->SayWords("大家好,我是战士张三!");

// 玩家王五发送消息,由于没有其他家族成员,不会有人收到消息
player3->SayWords("大家好,我是战士王五!");

但是这段代码的运行效率不高,如果游戏中有上万个玩家,那么当玩家每说一句话时,Fighter中的SayWords成员函数的for循环就要遍历上万个玩家并在其中找到相同家族的玩家来发送聊天信息。

如果把隶属于某个家族的所有玩家收集到一个列表中,那么当该家族中的某个玩家发出一条聊天信息后,就只需要遍历该玩家所在家族的列表,并向列表中的所有玩家发送该玩家的聊天信息。因为一个家族最多容纳20个玩家,所以这个遍历最多循环20次,相比于在一万个玩家中遍历,效率高得多。注释掉原有代码,重新用以下代码来实现原来的功能:

class Fighter;
class Notifier
{
public:
	virtual void addTolist(Fighter* player) = 0;
	virtual void removeFromLists(Fighter* player) = 0;
	virtual void notify(Fighter* player, string words) = 0;
	virtual ~Notifier() {}
private:
};
class Fighter
{
public:
	Fighter(int playerID, const string& playerName)
		:m_playerID(playerID), m_playerName(playerName) {}
	virtual ~Fighter() {}
	void setFamilyID(int id)
	{
		m_familyID = id;
	}
	int GetFamilyID()
	{
		return m_familyID;
	}
	void SayWords(string words, Notifier* notifier)
	{
		notifier->notify(this, words);
	}
	virtual void NotifyWords(Fighter* otherPlayer, string tmpContent)
	{
		cout << "玩家: " << m_playerName << " 收到了玩家:" << otherPlayer->m_playerName << " .发送的聊天信息:" << tmpContent << endl;
	}
private:

	int m_playerID;
	string m_playerName;
	int m_familyID = -1;//表示无家族     
	//...
};

//"战士"类玩家,父类为Fighter
class F_Warrior :public Fighter {
public:
	F_Warrior(int tmpID, string tmpName) :Fighter(tmpID, tmpName) {}
};
//"法师"类玩家,父类为Fighter
class F_Mage :public Fighter
{
public:
	F_Mage(int tmpID, string tmpName) :Fighter(tmpID, tmpName) {}
};
class TalkNotifier :public Notifier
{
public:
	void addTolist(Fighter* player) override
	{
		int tmpfamilyid = player->GetFamilyID();
		if (tmpfamilyid != -1)//加人了某个家族
		{
			auto iter = g_players.find(tmpfamilyid);
			if (iter != g_players.end())
				iter->second.push_back(player);//直接把该玩家加入到该家族
			else
			{
				list<Fighter*>temp;
				g_players.insert({ tmpfamilyid, temp });
				g_players[tmpfamilyid].push_back(player);
			}
		}

	}
	void removeFromLists(Fighter* player)override
	{
		int tmpfamilyid = player->GetFamilyID();
		if (tmpfamilyid != -1)//加入了某个家族
		{
			auto iter = g_players.find(tmpfamilyid);
			if (iter != g_players.end())
				g_players[tmpfamilyid].remove(player);
		}
	}
	void notify(Fighter* talker, string words)override // talker是讲话的玩家
	{
		int tmpfamilyid = talker->GetFamilyID();
		if (tmpfamilyid != -1)
		{
			auto itermap = g_players.find(tmpfamilyid);
			if (itermap != g_players.end()) {
				//遍历该玩家所属家族的所有成员
				for (auto iterlist = itermap->second.begin();
					iterlist != itermap->second.end(); ++iterlist)
				{
					(*iterlist)->NotifyWords(talker, words);
				}
			}
		}
	}
private:
	map<int, list<Fighter*>>g_players;

};

上面的代码同样实现了家族中一个人说话时,全家族的人都能看到聊天信息,同时也实现了不看家族其他人聊天信息的功能,代码的实现并不复杂,将属于同一个家族的玩家放到一个list容器中,当该家族中的某个玩家说话时,通过遍历list容器将说话内容广播给该家族中的每个玩家。

Notifier* notifier = new TalkNotifier();

// 创建玩家并添加到家族中
F_Warrior* player1 = new F_Warrior(1, "战士张三");
F_Warrior* player2 = new F_Warrior(2, "法师李四");
F_Warrior* player3 = new F_Warrior(3, "战士王五");
F_Warrior* player4 = new F_Warrior(4, "战士赵六");

// 设置家族ID并添加到通知器列表
player1->setFamilyID(1);
player2->setFamilyID(1);
player3->setFamilyID(1);
player4->setFamilyID(2);

notifier->addTolist(player1);
notifier->addTolist(player2);
notifier->addTolist(player3);
notifier->addTolist(player4);

// 玩家张三发送消息,只有李四能收到,因为它们在同一个家族
player1->SayWords("全族人立即到沼泽地集结,准备进攻!", notifier);
cout << "王五不想再收到家族其他成员的聊天信息了---" << endl;
notifier->removeFromLists(player3);//将王五从家族列表中删除
player2->SayWords("请大家听从族长的调遣,前往沼泽地!", notifier);
// 玩家王五发送消息,由于没有其他家族成员,不会有人收到消息

// 清理资源
notifier->removeFromLists(player1);
notifier->removeFromLists(player2);
notifier->removeFromLists(player3);
notifier->removeFromLists(player4);

在这里插入图片描述

观察者一般包括四种角色:

  • 主题Subject):也叫作观察目标,指被观察的对象。这里指Notifier类。提供增加和删除观察者对象的接口(addToListremoveFromList)。
  • 具体主题ConcreteSubject):维护一个观察者列表,当状态发生改变时,调用notify向各个观察者发出通知。这里指TalkNotifier子类。
  • 观察者Observer):当被观察的对象状态发生变化时候,观察者自身会收到通知。这里指Fighter类。
  • 具体观察者类ConcreteObserver):调用观察目标的addToList成员函数将自身加人到观察者列表中,当具体目标状态发生变化时自身会接到通知(NotifyWords成员函数会被调用)。这里指F_WarriorF_Mage子类。

观察者模式角色关系图:

在这里插入图片描述

观察者模式结构

在这里插入图片描述
引入观察者模式的定义:定义对象间的一种一对多的依赖关系,当一个对象状态发送改变时,所有依赖于它的对象都会自动得到通知。

对象之间是指Notifier类对象与Fighter类对象,也就是通知器类对象与玩家类对象之间。一对多的依赖关系是指一个通知器对应多个玩家,换句话说,就是多个玩家都依赖于该通知器,这些玩家处于同一家族中,甚至可以根据策划需求将不同家族的人也加人进来。

当通知器类对象的状态发生改变时,所有依赖于这个通知器对象的玩家类对象都会收到说话内容,程序开发人员可以将说话内容显示到同一家族各个成员所代表玩家的游戏界面上。通知器类对象的状态改变也可以与玩家类对象无关。例如,通知器类对象状态的改变来自系统公告或来自游戏管理员主动发送的信息,而不是来自某个玩家说话。换句话说。玩家类对象也许无法知道通知器类对象的状态是如何发生变化的,只是知道了通知器类对象状态发生了变化这件事。

所有的玩家都是“观察者”,观察目标(被观察对象)就是“通知器”。观察者实际上是被动地得到通知而不是主动去观察。所以,该模式中的“观察者”这3个字听起来并不是那么合适。

要进一步加深对该模式理解,通常会利用杂志和报纸订阅的例子进行说明:

如果订阅了一份杂志或报纸,那就不需要再去报摊查询新出版的刊物了。出版社(即应用中的 “发布者”) 会在刊物出版后直接将最新一期寄送至你的邮箱中。出版社负责维护订阅者列表,了解订阅者对哪些刊物感兴趣。当订阅者希望出版社停止寄送新一期的杂志时,他们可随时从该列表中退出。


三、总结

观察者设计模式也叫作发布-订阅(Publish-Subscribe)模式,如果用最通俗易懂的语言来描述,应该是这样:观察者(Fighter子类)提供一个特定的成员函数(NotifyWords),并把自已加人到通知器(Notifier子类)的一个对象列表中(订阅/注册),当通知器意识到有事件发生的时候,通过遍历对象列表找到每个观察者并调用观察者提供的特定成员函数(发布)来达到通知观察者某个事件到来的目的。观察者可以在特定的成员函数(NotifyWords)中编写实现代码来实现收到通知后想做的事情。

当一个对象的状态发生变化,并且需要改变其它对象的时候;或者当应用中一些对象必须观察其它对象的时候可以使用观察者模式。当然订阅者和发布者可能是没有子类的,因此也就不需要继承了,这个根据实际情况,具体问题具体分析就可以了。

拥有一些值得关注的状态的对象通常被称为目标, 由于它要将自身的状态改变通知给其他对象, 我们也将其称为发布者 (publisher)。 所有希望关注发布者状态变化的其他对象被称为订阅者 (subscribers)。

观察者模式在观察者和观察目标之间建立了一个抽象的耦合。观察目标只需要维持一个抽象的观察者列表,并不需要了解具体的观察者类。改变观察者和观察目标的一方,只要调用接口不发生改变,就不会影响另外一方。松耦合的双方都依赖抽象而不是具体类,满足依赖倒置原则。观察目标会向观察者列表中的所有观察者发送通知(而不是让观察者不断向观察目标查询状态的变化),从而简化一对多系统的设计难度。可以通过增加代码的方式来增加新的观察者或观察目标,满足开闭原则。

我们也将其称为发布者 (publisher)。 所有希望关注发布者状态变化的其他对象被称为订阅者 (subscribers)。

观察者模式在观察者和观察目标之间建立了一个抽象的耦合。观察目标只需要维持一个抽象的观察者列表,并不需要了解具体的观察者类。改变观察者和观察目标的一方,只要调用接口不发生改变,就不会影响另外一方。松耦合的双方都依赖抽象而不是具体类,满足依赖倒置原则。观察目标会向观察者列表中的所有观察者发送通知(而不是让观察者不断向观察目标查询状态的变化),从而简化一对多系统的设计难度。可以通过增加代码的方式来增加新的观察者或观察目标,满足开闭原则。

中介者的主要目标是消除一系列系统组件之间的相互依赖。 这些组件将依赖于同一个中介者对象。 观察者的目标是在对象之间建立动态的单向连接, 使得部分对象可作为其他对象的附属发挥作用。

Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐